Tomcat 网络I/O模型浅析

0 阅读6分钟

摘要:本文从Socket 系统调用说起,简单说明了网络I/O过程。介绍了Tomcat中最常用的两种实现NioEndpoint、Nio2Endpoint,并对工作线程池调优做了探讨。

本文中源码来自tomcat 9.0.x版本,地址github.com/apache/tomc…

一 从Socket Read 说起

Linux 系统中 Socket Read 系统调用,涉及从用户态→内核态的切换、内核态的核心处理逻辑、数据拷贝与返回三个核心阶段。先来看看几个概念:

  • 用户态 / 内核态:Linux 进程分为两种运行级别,用户态(低权限,应用程序运行)、内核态(高权限,操作系统内核运行),系统调用是用户态进入内核态的唯一合法方式。
  • Socket 本质:Linux 中 Socket 是一种文件描述符(fd),所有网络 I/O 最终都映射为文件操作
  • 阻塞 / 非阻塞:Socket 默认是阻塞的,若接收缓冲区无数据,read() 会阻塞进程;非阻塞模式下则直接返回。

Tomcat作为Web容器来处理网络请求,一项核心工作就是与操作系统进行网络I/O交互。 它将如何减少上下文切换、提升并发处理能力?这就需要了解Tomcat的两种常用网络I/O模型:NIO(NioEndpoint)和AIO (Nio2Endpoint)。

二 NioEndpoint

EndPoint 组件的主要工作就是处理 I/O,而 NioEndpoint 利用 java.nio包实现了多路复用 I/O 模型,封装了ServerSocketChannel、SocketChannel和Selector等类。是Tomcat 默认使用的I/O模型。

NioEndpoint 基于 主从Reactor多线程模型 设计。核心组件分为三层:

Acceptor (主 Reactor) → Poller (从 Reactor) → Worker 线程池 (业务处理)

NioEndpoint的start方法做了这几件事:

  1. 创建worker线程池
  2. 创建LimitLatch,用于限制并发连接数;
  3. 创建并启动 poller thread;
  4. 创建并启动Acceptor线程

2.1 Acceptor

Acceptor是单线程阻塞式 Accept的连接接收器,它实现了Runnable接口。主要功能包括:

  • Acceptor 是一个独立线程(实现 Runnable),在 while 循环中不断调用 endpoint.serverSocketAccept()接收新连接;
  • 在 NioEndpoint 中,ServerSocketChannel 被配置为阻塞模式即serverSock.configureBlocking(true),所以 accept 调用是阻塞的;
  • 接收到连接后调用 endpoint.setSocketOptions(socket) 将 socket 交给 Poller;
  • 通过 endpoint.countUpOrAwaitConnection(),使用 LimitLatch 控制最大连接数(默认 8192),达到上限时 Acceptor 线程阻塞等待。

在NioEndpoint#initServerSocket中,设置了ServerSocket的accept()为阻塞模式。

public class Acceptor<U> implements Runnable {
    // 持有endpoint
    private final AbstractEndpoint<?,U> endpoint;

    // 简化代码
    @Override
    public void run() {
        try {
            // Loop until we receive a shutdown command
            while (!stopCalled) {
                if (stopCalled) {
                    break;
                }
                state = AcceptorState.RUNNING;

                try {
                    U socket;
                    try {
                        // 阻塞式接收连接,返回SocketChannel
                        socket = endpoint.serverSocketAccept();
                    } catch (Exception e) {
                        endpoint.countDownConnection();
                    }
                    // 注册
                    if (!endpoint.setSocketOptions(socket)) {
                            endpoint.closeSocket(socket);
                    }
                } catch (Throwable t) {
                    // 省略
                }
            }
        } finally {
            stopLatch.countDown();
        }
        state = AcceptorState.ENDED;
    }

核心流程就是:

while (!stopCalled) {
    countUpOrAwaitConnection();          // 连接数限流
    socket = endpoint.serverSocketAccept();  // 阻塞等待新连接
    endpoint.setSocketOptions(socket);       // 注册到 Poller
}

2.2 Poller

Poller是NioEndpoint的一个内部类,是NIO 同步非阻塞模型 的核心体现,对应 Java NIO 的 Selector 机制

  1. 注册阶段:setSocketOptions 中将 SocketChannel 配置为 非阻塞(socket.configureBlocking(false)),然后调用 poller.register()将 socket 以 OP_REGISTER 事件加入事件队列
  2. 轮询阶段(Poller.run):
    • 先处理事件队列 events()将新注册的 socket 通过 sc.register(getSelector(), SelectionKey.OP_READ) 注册到 Selector,
    • 调用 selector.select(selectorTimeout) 或 selector.selectNow() 进行 IO 多路复用
    • 遍历就绪的 SelectionKey,对可读/可写事件调用 processSocket() 提交到 Worker 线程池

当向events队列添加事件时,会让阻塞的selector.select()立即返回,从而及时消费消息。

// 简化代码
public void run() {
    // Loop until destroy() is called
    while (true) {
        boolean hasEvents = false;
        try {
            if (!close) {
                hasEvents = events();
                if (wakeupCounter.getAndSet(-1) > 0) {
                    keyCount = selector.selectNow();
                } else {
                    keyCount = selector.select(selectorTimeout);
                }
                wakeupCounter.set(0);
            }
        } catch (Throwable x) {
            continue;
        }

        Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
        while (iterator != null && iterator.hasNext()) {
            SelectionKey sk = iterator.next();
            iterator.remove();
            NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
            if (socketWrapper != null) {
                // 提交给worker线程池,处理读写事件
                processKey(sk, socketWrapper);
            }
        }
    }
    getStopLatch().countDown();
}

2.3 流程图

三 Nio2Endpoint

Nio2Endpoint是异步非阻塞 AIO 模型,与 NioEndpoint 有本质区别:

  1. 没有 Poller:不需要 Selector 轮询,IO 操作完成后由 OS 回调 CompletionHandler
  2. Nio2Acceptor:继承 Acceptor 并实现 CompletionHandler<AsynchronousSocketChannel, Void>
  • 调用 serverSock.accept(null, this) 发起异步 accept
  • 连接到达时 OS 回调 completed() 方法,在回调中直接处理连接并再次发起 accept
  1. 读写操作:通过 readCompletionHandler 和 writeCompletionHandler 实现异步回调
  • 非阻塞读:getSocket().read(to, timeout, unit, to, readCompletionHandler)
  • 非阻塞写:getSocket().write(buffer, timeout, unit, buffer, writeCompletionHandler)

四 两种模型对比

特性NioEndpoint (NIO)Nio2Endpoint (AIO)
IO 模型同步非阻塞 + IO 多路复用异步非阻塞
Accept阻塞式(ServerSocketChannel blocking)异步回调(CompletionHandler)
IO 就绪检测Selector 轮询(Poller 线程)OS 内核通知(无需 Poller)
数据读写应用主动读写(就绪后由 Worker 执行)OS 完成后回调通知
线程模型Acceptor(1) + Poller(1) + Worker PoolAcceptor(异步) + Worker Pool

五 Tomcat线程调优

Tomcat 运行时关键指标有:吞吐量、响应时间、错误数、线程池、CPU 以及 JVM 内存

  • 前三个指标是业务指标,需要Tomcat 吞吐量要大、响应时间要短,并且错误数要少。
  • 后三个指标与系统资源有关,当某项到达瓶颈时,就会影响业务指标,比如线程池中线程数不足会影响吞
    吐量和响应时间;当内存不足时会触发频繁地 GC,耗费 CPU,导致业务指标恶化。

我们最常使用的调优措施,就是优化Worker Pool线程池(在NioEndpoint中Acceptor、Poller都是单线程,无法调整)

在NioEndpoint、Nio2Endpoint启动时,都会调用createExecutor()初始化线程池;其中配置参数包括:

参数描述
threadPriority(int) 线程优先级,默认是 5 (Thread.NORM_PRIORITY)
daemon(boolean) 是否 daemon 线程,默认为 true
maxThreads(int) 最大线程数,默认是 200
minSpareThreads(int) 最小线程数,默认是 25
maxIdleTime(int) 线程最大空闲时间,超过这个时间线程就会回收,直到线程数剩下 minSpareThreads 个,默认值是60秒
maxQueueSize(int) 线程池中任务队列的最大长度,默认是 Integer.MAX_VALUE
prestartminSpareThreads(boolean) 是否在线程池启动时就创建 minSpareThreads 个线程,默认为 false

最核心参数是maxThreads,它并非越大越好,过大或过小都有问题

  • 设置的过小,Tomcat会发生线程饥饿,请求将在队列中排队等待,使得响应时间变长;
  • 设置的过大,上下文切换开销剧增、内存占用高,反而拖慢整体性能。

maxThreads合理取值的目标,应该是让 CPU 利用率接近 xx%(如80%),同时避免频繁上下文切换。需要根据 CPU 核心数、 请求类型、请求平均耗时、 目标吞吐量 等综合考量。

通用计算方法:系统中并发数 ≈ QPS × 平均响应时间(秒)

比如,目标 QPS=1000,平均请求耗时=200ms,1000 × 0.2 = 200即maxThreads,可以设为 200~240(留 20% 冗余)

此外,maxThreads不能孤立设置,需配合以下参数。

参数建议值作用
minSpareThreadsmaxThreads × 20% ~ 30%保持核心线程,避免频繁创建 / 销毁线程
maxQueueSize1000~5000适当让请求排队等候,避免直接拒绝服务
connectionTimeout20000~30000(ms)控制连接等待超时,防止慢请求占住线程