前言
本系列为本人 Java 编程方法论 响应式解读系列的 webflux 部分, 现分享出来, 前置知识 Rxjava2 ,Reactor 的相关解读已经录制分享视频, 并发布在 b 站, 地址如下:
Rxjava 源码解读与分享: https://www.bilibili.com/video/av34537840
Reactor 源码解读与分享: https://www.bilibili.com/video/av35326911
NIO 源码解读相关视频分享: https://www.bilibili.com/video/av43230997
NIO 源码解读视频相关配套文章:
BIO 到 NIO 源码的一些事儿之 BIO
BIO 到 NIO 源码的一些事儿之 NIO 上
BIO 到 NIO 源码的一些事儿之 NIO 中
BIO 到 NIO 源码的一些事儿之 NIO 下 之 Selector BIO 到 NIO 源码的一些事儿之 NIO 下 Buffer 解读 上 BIO 到 NIO 源码的一些事儿之 NIO 下 Buffer 解读 下
其中, Rxjava 与 Reactor 作为本人书中内容将不对外开放, 大家感兴趣可以花点时间来观看视频, 本人对着两个库进行了全面彻底细致的解读, 包括其中的设计理念和相关的方法论, 也希望大家可以留言纠正我其中的错误.
为什么需要 Spring WebFlux
在我们要面临越来越高的并发处理, 传统的 Spring Web MVC 已经无法满足我们的需求, 即我们需要一个无阻塞的且通过很少的硬件资源 (体现就是通过很少数量的线程) 的 Web 框架来处理并发任务. Servlet 3.1 确实为非阻塞 I/O 提供了相应 API. 但是, 使用它时, Servlet 其余部分 API 的在执行时就是同步 (比如 Filter) 或阻塞(getParameter,getPart). 我们知道, Tomcat 这类服务器其有一个 Servlet Worker 线程池, 而使用 Spring Web MVC 的话, 对于请求的处理过程将会在 DispatcherServlet 中进行, 而其内部默认并不会进行异步处理, 所以, 当有 I/O 或者耗时操作的时候, 很可能会阻塞当前 Servlet 所在线程.(参考网上关于 SpringMVC 异步操作的相关博文), 关于其异步改造, 本人也在 RxJava2 的相关分享视频的项目实例中进行有改造, 大家可回顾. 而我们的目的就是将当前 Servlet 所在线程给让出来, 这样可以接收更多的请求. 那两者的区别到底在什么地方, Spring WebFlux 到底有何意义可供我们迁移学习. 相信大家在接触过 Tomcat 之后, 都会去学习一下 Tomcat 的配置文件 server.xml, 从中我们也知道 Connector, 其主要功能是: 接收连接请求, 创建 Request 和 Response 对象用于和请求端交换数据; 然后分配线程让 Servlet 容器来处理这个请求, 并把产生的 Request 和 Response 对象传给 Servlet. 当 Servlet 处理完请求后, 也会通过 Connector 将响应返回给客户端. 所以我们从 Connector 入手, 讨论一些与 Connector 有关问题, 包括 NIO/BIO 模式, 线程池, 连接数等. 根据协议的不同, Connector 可以分为 HTTP Connector,AJP Connector 等, 此处只讨论 HTTP Connector.
Tomcat 下 Connector 中的协议
Connector 在处理 HTTP 请求时, 会使用不同的 protocol. 不同的 Tomcat 版本支持的 protocol 不同, 其中典型的 protocol 包括 BIO,NIO 和 APR(Tomcat7 中支持这 3 种, Tomcat8 增加了对 NIO2 的支持, 而在 Tomcat8.5 和 Tomcat9.0, 则去掉了对 BIO 的支持).
Connector 使用哪种 protocol, 可以通过 Tomcat 配置文件 server.xml 中的 < connector > 元素中的 protocol 属性进行指定, 也可以使用默认值. 如果没有指定 protocol, 则使用默认值 HTTP/1.1, 其含义如下: 在 Tomcat7 中, 自动选取使用 BIO 或 APR(如果找到 APR 需要的本地库, 则使用 APR, 否则使用 BIO); 在 Tomcat8 中, 自动选取使用 NIO 或 APR(如果找到 APR 需要的本地库, 则使用 APR, 否则使用 NIO).
无论是 BIO, 还是 NIO,Connector 处理请求的大致流程是一样的: 在 accept 队列中接收连接(当客户端向服务器发送请求时, 如果客户端与服务端完成三次握手建立了连接, 则服务端将该连接放入 accept 队列); 在连接中获取请求的数据, 生成 request; 调用 Servlet 容器处理请求; 返回 response.
为了便于大家的理解, 这里先明确一下连接与请求的关系:
连接是 TCP 层面的(传输层), 对应 socket.
请求是 HTTP 层面的(应用层), 必须依赖于 TCP 的连接实现.
一个 TCP 连接中可能传输多个 HTTP 请求.
BIO 是 Blocking IO, 顾名思义是阻塞的 IO;NIO 是 Non-blocking IO, 则是非阻塞的 IO. 而 APR 是 Apache Portable Runtime, 是 Apache 可移植运行库, 利用本地库可以实现高可扩展性, 高性能; Apr 是在 Tomcat 上运行高并发应用的首选模式, 但是需要安装 apr,apr-utils,tomcat-native 等包.
在 BIO 实现的 Connector 中, 请求主要是由 JioEndpoint 对象来处理. JioEndpoint 维护了 Acceptor 和 Worker, 通过 Acceptor 接收 socket, 然后从 Worker 线程池中找出空闲的线程处理 socket, 如果 worker 线程池没有空闲线程, 则 Acceptor 将阻塞. 其中 Worker 是 Tomcat 自带的线程池, 如果通过 < Executor > 配置了其他线程池, 原理与 Worker 类似.
在 NIO 实现的 Connector 中, 处理请求的主要实体是 NIOEndpoint 对象. NIOEndpoint 中除了包含 Acceptor 和 Worker 外, 还是用了 Poller, 处理流程如下图所示:
图中 Acceptor 及 Worker 分别是以线程池形式存在, Poller 是一个单线程(此处是其与 Netty 最大的区别). 注意, 与 BIO 的实现一样, 这里, 需要提及的是, 在 server.xml 中没有配置 < Executor>, 则以 Worker 线程池运行, 如果配置了 < Executor>, 则以基于 java.util.concurrent.ThreadPoolExecutor 线程池运行.
由图可知, Acceptor 接收 socket 后 (这里虽然是基于 NIO 的 connector, 但是在接收 socket 方面还是传统的 serverSocket.accept() 方式, 获得 SocketChannel 对象, 然后封装在一个 tomcat 的 org.apache.tomcat.util.NET.NIOChannel 实现类对象, 并将之包装为一个 PollerEvent 对象), 并不是直接使用 Worker 中的线程处理请求, 而是先将 PollerEvent 对象发送给了 Poller, 而 Poller 是实现 NIO 的关键. Acceptor 向 Poller 发送包装后的请求通过添加队列的操作实现, 这里使用了典型的生产者 - 消费者模式. 同时, 在 Poller 中, 维护了一个 Selector 对象; 当 Poller 从队列中取出 socket 后, 注册到该 Selector 中; 然后通过遍历 Selector, 找出其中可读的 socket, 并使用 Worker 中的线程处理相应请求. 与 BIO 类似, Worker 也可以被自定义的线程池代替.
通过上述过程可以看出, 在 NIOEndpoint 处理请求的过程中, 无论是 Acceptor 接收 socket, 还是线程处理请求(添加到 Poller 队列是同步的), 使用的仍然是阻塞方式; 但在读取 socket 并交给 Worker 中的线程的这个过程中, 使用非阻塞的 NIO 实现, 这是 NIO 模式与 BIO 模式的最主要区别(其他区别对性能影响较小). 而也是由于这个区别, 在并发量较大的情形下可以给 Tomcat 效率带来显著提升.
目前大多数 HTTP 请求使用的是长连接(HTTP/1.1 默认 keep-alive 为 true), 而长连接意味着, 一个 TCP 的 socket 在当前请求结束后, 如果没有新的请求到来, socket 不会立马释放, 而是等 timeout 后再释放. 如果使用 BIO, 读取 socket 并交给 Worker 中的线程这个过程是阻塞的, 也就意味着在 socket 等待下一个请求或等待释放的过程中, 处理这个 socket 的工作线程会一直被占用, 无法释放; 因此 Tomcat 可以同时处理的 socket 数目不能超过最大线程数, 性能受到了极大限制. 而使用 NIO, 读取 socket 并交给 Worker 中的线程这个过程是非阻塞的(是由 Poller 所在线程维护的), 并不会占用工作线程, 因此 Tomcat 可以同时处理的 socket 数目不受最大线程数约束, 并发性能也就大大提高, 但 Poller 同时也是其性能瓶颈.
因此, 随着 NIO 所实现 Connector 的引入, 客户端到服务器的通信是非阻塞的, 但是服务器到 servlet 的连接仍然是阻塞的, 也就意味着每个请求都会阻塞一个线程, 也就导致我们会看到一个线程处理一个请求的模型. 因此, 随着 Servlet 容器的发展, Servlet API 也就需要非阻塞支持, 也就是 Servlet 3.1+.
关于 Tomcat 下 Connector 的更多深入解读, 有感兴趣的可以参考本人的另一篇博文 tomcat 从启动到接轨 Servlet 二三事
来源: https://juejin.im/post/5c6fe823e51d455b8c18d8aa