Web 容器与 Netty:线程模型之下的 IO 模型

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型(本篇)

容器怎么处理连接

云盘服务端内嵌 Jetty 9 提供 HTTP 接口。配置中的几个参数决定它如何处理连接:QueuedThreadPool 的 min/max 线程数(量级模糊化)、ServerConnector 的 acceptor 与 selector 数量、监听端口。当时我改过线程池大小,对 acceptor 和 selector 的含义只有字面理解,未进一步分析。acceptor 数量对应接收新连接的线程,accept() 只是一次系统调用,通常 1-2 个线程即可;selector(Jetty 里叫 SelectorManager,Tomcat 里叫 Poller)数量对应轮询就绪事件的线程,通常按 CPU 核数配置一两个;线程池承担业务处理,请求解析、业务调用和响应写出都在这里运行,容量上限主要受它限制。后文的 NIO 一节会展开三者的分工。

从单线程到线程池:云盘转 Java 后的第一堂并发课)讲了 Node.js 事件循环与 Jetty 请求线程的差异,(线程池参数、队列与快慢接口隔离)提到线程池参数怎么定、快慢接口怎么隔离,(流对流的失败率、NIO 与零拷贝)提到流对流路径上的 IO 拷贝与零拷贝。这三篇分别讨论并发、线程池和数据拷贝路径,未展开容器如何处理连接,以及 Netty 使用的模型。本文说明这两个问题。

接入层:Nginx 反向代理与 OpenResty 的边界讲 Nginx 时提到,接入层使用事件驱动处理大量连接,业务进程为每个连接分配一个线程,两者的容量模型不同。本文进一步说明容器的线程模型如何工作,以及 Netty 的事件循环与 Nginx 的事件驱动是否属于同类模型。

BIO:每连接一线程

BIO connector(也叫 JIO connector)是 Tomcat 7 及之前默认、8.0 仍保留的阻塞 IO 模型。8.0 起默认 connector 换成 NIO,8.5 把 BIO 实现移除,纯 Java HTTP connector 只剩 NIO/NIO2。BIO 采用「每连接分配一个处理线程」的模型:Acceptor 线程在监听端口上阻塞调用 accept(),每拿到一个 socket 就交给线程池中的一个 worker 线程。worker 从读取请求、处理业务到写出响应均被占用,等待 IO 时也不会释放。Jetty 在同步 Servlet 模式下也是如此:一个请求会持续占用 QueuedThreadPool 中的工作线程。

这个模型的容量上限由线程池大小决定。线程数受两方面限制:每条线程默认栈占约 512KB 到 1MB,线程数增加会提高内存占用;线程数远超 CPU 核数后,上下文切换开销会抵消并发收益。BIO 容器可以处理短连接和快请求;面对文件下载、长轮询等大量长连接时,线程池可能很快达到上限,后续请求需要排队。

这种模型的优点是业务代码可以使用阻塞式写法,JDBC 和下游调用的阻塞行为都能对应到一个请求线程栈,便于调试。

NIO connector:accept / select / dispatch

NIO 的 Selector 使一个线程可以同时监控多个连接的就绪事件。容器在 connector 上使用 NIO,将接收连接、轮询就绪事件和处理业务分为三个环节,由不同线程承担。

Tomcat 的 NIO connector(8.0 起成为默认纯 Java connector)三段职责:

  • Acceptor:一个线程,阻塞 accept() 接收新连接,拿到 socket 后注册到 Poller 的 Selector 上。
  • Poller:少量线程(按核数,一两个起),每个 Poller 持有一个 Selector,轮询其上所有连接的就绪事件(OP_READ 等)。连接数据就绪后,将读取和业务处理任务 dispatch 给 worker 线程池。
  • Worker 线程池:处理请求解析与业务调用,和 BIO 一样可以阻塞写,区别是它只在有数据可处理时才被占用,连接空闲等待时不占 worker。

Jetty 的 ServerConnector 结构与此对应:acceptor 线程接收连接,selector 线程(SelectorManager 管理的 ManagedSelector)轮询就绪事件并 dispatch,QueuedThreadPool 执行业务。accept / select / dispatch 三个环节的拆分与 Tomcat 一致。

NIO 注册代码的最小示例如下:

Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
    selector.select();
    for (SelectionKey key : selector.selectedKeys()) {
        if (key.isAcceptable()) {
            // accept 新连接,注册 OP_READ
        } else if (key.isReadable()) {
            // 读就绪,dispatch 到 worker 线程池处理
        }
    }
    selector.selectedKeys().clear();
}

NIO connector 仍使用线程池,业务仍可阻塞。它避免空闲连接等待时占用线程。长连接注册在 Poller 的 Selector 上,不占用 worker;有数据时才由 worker 处理。容量上限仍受 worker 线程池大小约束,同样大小的线程池可以处理比 BIO 更多的空闲长连接。

Netty 的 Reactor 模型

Netty 4.1 使用 Reactor 模型和 ChannelPipeline。业务逻辑也会在事件循环线程上执行;容器 NIO connector 则将业务保留在线程池中执行。

一组 EventLoopGroup 分工:bossGroup(通常 1 个 EventLoop)负责 accept,每接到一个连接就将其注册到 workerGroup 的某个 EventLoop 上;workerGroup 的每个 EventLoop 都是单线程,管理多个 Channel,并在自身的事件循环中轮询这些 Channel 的就绪事件和执行 ChannelPipeline。ChannelPipeline 由一系列 ChannelHandler 组成,编解码和业务逻辑通过 handler 链配置。

最小 Bootstrap:

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup(); // 默认 2×CPU 核数
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     protected void initChannel(SocketChannel ch) {
         ch.pipeline().addLast(new FrameDecoder(), new BusinessHandler());
     }
 });
b.bind(8080).sync();

BusinessHandler 中一次阻塞的 JDBC 调用会占用整个 EventLoop。这个 EventLoop 管理的成百上千个 Channel 在阻塞期间都无法得到处理,因此业务代码不能阻塞事件循环线程。在线程模型中,阻塞会占用一个线程,容量受线程池大小限制;在 Reactor 模型中,阻塞会占用一个 EventLoop,并影响该 EventLoop 管理的所有连接。

在 handler 不阻塞的前提下,少量 EventLoop 可以管理大量连接,线程数不随连接数增长,吞吐上限主要受 CPU 和内存约束。业务代码需要按非阻塞方式组织,或将阻塞操作显式提交给其他线程池,例如单独的业务 EventLoopGroup 或自定义线程池;这比容器线程模型增加了实现和维护要求。

Nginx 的事件驱动和 Netty 的 Reactor 都以少量线程处理大量连接。Nginx 本体的处理逻辑固定为反向代理和负载均衡,业务逻辑通过 OpenResty 的 Lua 注入(接入层:Nginx 反向代理与 OpenResty 的边界);Netty 通过 ChannelPipeline 允许业务自行编写 handler,因此这些 handler 不能阻塞事件循环线程。

BIO、容器 NIO、Netty Reactor 三种模型的线程与连接对应关系

容量计算:Little’s law

两种模型的容量差异可以用 Little’s law 估算:L = λ × W,并发数等于吞吐乘以平均处理耗时。

线程模型下,并发数上限就是线程池大小。200 个线程、单请求平均 50ms,吞吐上限约 200 / 0.05 = 4000 QPS;线程数达到上限后,吞吐不再随连接数增长,多出的请求排队。提高吞吐需要增加线程数,但线程数受内存和上下文切换约束,增加到几百时会接近实际可用上限。

Reactor 模型下,EventLoop 数量通常按 CPU 核数配置(几个到十几个),不随连接数增长。每个连接占用文件描述符和少量内存,不需要专门线程。大量连接场景中的吞吐瓶颈主要是 CPU(事件循环跑满)和内存(连接缓冲、handler 状态),而不是线程数。

Netty 的调研范围

Netty 当时只用于调研,未上线生产。云盘有长连接需求(推送、IM 文件通道),因此需要确认是否应从容器线程模型切换到 Netty。

调研结论是:业务系统可以先使用容器线程模型。容器 NIO connector 已能处理一定量的空闲长连接,业务代码保持阻塞式写法更易维护。当连接数超过线程池可处理的范围,或长连接占比使 worker 线程长期被占用时,再考虑引入 Netty 或基于 Netty 的框架。和 2016 系列第 9 篇一样,这部分是调研认知,未上线生产。

Dubbo、gRPC、Elasticsearch 的传输层都使用 Netty。这些系统需要处理大量长连接(RPC 长连接、节点间通信),并自行控制网络层,容器的 Servlet 模型不适合这类需求。理解 Netty 主要是为了读懂这些中间件的线程模型与故障行为,业务代码直接编写 Netty handler 的场景较少。RPC 框架:像本地调用一样调远程的代价讲 RPC 框架时会回到这一点。

使用边界

  1. 业务系统可先使用容器线程模型。 该模型支持阻塞式写法,便于调试,容量受线程池大小限制。多数 Web 业务的连接数和长连接占比可由容器处理。
  2. 大量长连接需要评估 Netty。 推送、IM、网关等连接数大或长连接占比高的场景中,空闲连接可能占满线程模型的线程池,可考虑 Netty 或基于 Netty 的框架。
  3. 容器 NIO connector 适合空闲长连接占用 worker 的场景。 它使空闲长连接不占用 worker,业务仍在线程池中以阻塞方式运行。适用范围:当瓶颈是空闲连接占用 worker,且连接总数仍在容器线程模型的处理范围内时,调大 NIO connector 的线程池可能足够。
  4. Netty 的 handler 不能阻塞事件循环线程。 JDBC、同步下游调用和磁盘 IO 等阻塞操作应显式提交给业务线程池。慢 handler 会使同一 EventLoop 管理的所有连接无法处理,这也是 Netty 在业务侧维护要求较高的原因。
  5. 中间件的网络层常使用 Netty。 阅读中间件源码或排查其线程行为时,需要理解 Netty 的 EventLoop 与 ChannelPipeline 模型;业务侧直接编写 Netty handler 的机会有限。
  6. 容量估算可使用 Little’s law。 线程模型下并发上限即线程数,Reactor 的瓶颈主要在 CPU 与内存。换模型前应确认当前瓶颈是线程数、CPU 还是内存,避免为不存在的瓶颈更换技术栈。

调研认知的边界

当时对 Netty 源码的了解限于能编写 Bootstrap、理解 ChannelPipeline 与 EventLoop 的分工。内存管理(ByteBuf 的 direct buffer 与池化、ChannelHandlerContext 的引用计数),以及 EpollEventLoopGroupNioEventLoopGroup 的差异,均限于文档认知。Tomcat NIO connector 的 Poller 内部实现(任务队列、超时管理)也只了解到机制层,未阅读源码。本文关于 Netty 的内容均为调研认知,未在生产中验证。

参考资料


657 字 · 55 段落
ximing

Follow onGitHub

相关文章