Tomcat的NioEndpoint组件是怎么实现I/O多路复用

一、常见的 I/O 模型

常见的 I/O 模型有五中:同步阻塞 I/O 、同步非阻塞 I/O 、 I/O 多路复用、信号驱动 I/O和异步 I/O。 在网络 I/O 通信过程中,涉及到网络数据读取和写回。这里面数据的读写主要会经历两个步骤:

  • 用户线程等待内核将数据从网卡拷贝到内核空间
  • 内核将数据从内核空间拷贝到用户空间 这两个过程涉及到操作系统从用户态和内核态的转换成,这是一个重量级的操作。而各个 I/O 模型的区别就是这两个步骤的不一样。

1.1 同步阻塞 I/O

用户线程发起 read 操作后就会被阻塞,让出 CPU,等到内核将网卡数据从内核空间拷贝到用户空间后,再把用户线程唤醒。

1.2 同步非阻塞 I/O

用户线程会不断地发起read调用,数据没到内核空间时,每次都不会返回数据,知道数据到达内核空间,这一次read调用后,用户线程会被阻塞,等待数据从内核空间拷贝到用户空间,等数据到了用户空间后线程会被唤醒执行操作。

1.3 I/O 多路复用

用户线程的读操作分成两步,线程先发起select调用,目的是询问内核数据是否准备好了。等内核把数据准备好之后,用户线程再发起read调用。在等待数据从内核空间拷贝到用户空间这段时间里,现在还是阻塞的。 一次select调用可以想内核查询多个数据通道的状态,所以叫多路复用。

1.4 异步 I/O

当用户线程发起read调用的时候注册一个回调函数,read立即返回不阻塞用户线程,带内核处理完数据后,调用回调函数。在整个过程中用户线程都不会被阻塞。

二、Tomcat的NioEndPoint组件

Tomcat的NioEndPoint组件就是实现了 I/O 多路复用模型。 总体流程:

  1. 创建一个 Selector,并在 Selector 上注册各种监听的事件,然后调用 select 方法,等待监听的事件发生。
  2. 如果在 Channel 上发生了 Selector 监听的事件,就创建一个新的线程从 Channel 中读取数据。 NioEndPoint 组件一共包含了以下组件:LimitLatchAcceptorPollerSocketProcessorExecutor
  • LimitLatch:负责控制最大连接数,使用NIO的话默认最大连接数是10000,连接数达到这个阈值之后会被拒绝。
  • Acceptor:每一个Acceptor都运行在单独的线程中,Acceptor会有不断轮询调用accept方法查看是否有连接请求,最后返回一个ChannelPoller来处理。
  • Poller:本质上是Selector,因为Poller中持有了Selector。它也是运行在单独的线程中,内部维护了一个同步队列的数组。一旦有Channel可读,就会把这个Channel塞到SocketProcessor,然后把这个SocketProcessor交给线程池Executor处理。

Poller部分代码:

java 复制代码
public class Poller implements Runnable {  
  
private Selector selector;  
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();  
  
private volatile boolean close = false;  
// Optimize expiration handling  
private long nextExpiration = 0;  
  
private AtomicLong wakeupCounter = new AtomicLong(0);  
  
private volatile int keyCount = 0;  
  
public Poller() throws IOException {  
	this.selector = Selector.open();  
}
.....

SocketProcessor部分代码:

java 复制代码
protected class SocketProcessor extends SocketProcessorBase<NioChannel> {  
  
public SocketProcessor(SocketWrapperBase<NioChannel> socketWrapper, SocketEvent event) {  
	super(socketWrapper, event);  
}
.....
  • Executor:线程池,负责运行SocketProcessorSocketProcessor的 run 方法会调用Http11Processor来读取和解析请求数据。Http11Processor 是应用层协议的封装,它会调用容器获得响应,再把响应通过Channel写出。

三、组件分析

3.1 LimitLatch分析

LimitLatch用来控制连接个数,当连接数到达最大时阻塞线程,直到后续组件处理完一个连接后将连接数减 1。到达最大连接数后操作系统底层还是会接收客户端连接,但是LimitLatch会已经不再接收连接。内部实现了一个抽象队列同步器Sync来控制并发和实现同步队列。AbstractQueuedSynchronizer(简称:AQS,抽象队列同步器) 是 Java 并发包中的一个核心类,它在内部维护一个状态和一个线程队列,可以用来控制线程什么时候挂起,什么时候唤醒 。可以扩展AQS来实现自己的同步器,而且 Java 并发包里的锁和条件变量等等都是通过 AQS 来实现的,这里的LimitLatch也是:

java 复制代码
public class LimitLatch {

    private static final Log log = LogFactory.getLog(LimitLatch.class);

    private class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 1L;

        public Sync() {
        }

        @Override
        protected int tryAcquireShared(int ignored) {
            // 类似于加锁操作,连接数+1,如果连接数大于限制的最大连接数,失败
            long newCount = count.incrementAndGet();
            if (!released && newCount > limit) {
                // Limit exceeded
                count.decrementAndGet();
                return -1;
            } else {
                return 1;
            }
        }

        @Override
        protected boolean tryReleaseShared(int arg) {
            // 类似于释放锁操作,连接数-1
            count.decrementAndGet();
            return true;
        }
    }

    private final Sync sync;
    private final AtomicLong count;
    private volatile long limit;
    private volatile boolean released = false;

    // LimitLatch的构造函数,可以创建指定最大连接数的LimitLatch
    public LimitLatch(long limit) {
        this.limit = limit;
        this.count = new AtomicLong(0);
        this.sync = new Sync();
    }

    // 省略 limit 的setter getter方法 
    // 省略 count 的getter方法

    // 线程调用这个方法来获得接收新连接的许可,线程可能被阻塞
    public void countUpOrAwait() throws InterruptedException {
        if (log.isDebugEnabled()) {
            log.debug("Counting up["+Thread.currentThread().getName()+"] latch="+getCount());
        }
        sync.acquireSharedInterruptibly(1);
    }

    // 连接数-1,一般是连接读写完成,释放链接许可出来让别的线程能够连接,前面被阻塞的线程可能被唤醒(看系统的调度)
    public long countDown() {
        sync.releaseShared(0);
        long result = getCount();
        if (log.isDebugEnabled()) {
            log.debug("Counting down["+Thread.currentThread().getName()+"] latch="+result);
        }
        return result;
    }

   // 省略代码
}
  1. 用户线程通过调用``LimitLatch的 countUpOrAwait 方法来拿到锁,如果暂时无法获取,这个线程会被阻塞到 AQS 的队列中。Sync`类重写了 AQS 的tryAcquireShared()方法。它的实现逻辑是如果当前连接数 count 小于 limit,线程能获取锁,返回 1,否则获取失败进入等待,返回 -1。
  2. 同样的Sync重写了 AQS 的releaseShared() 方法,其实就是当一个连接请求处理完了,这时候count的数量就会-1,释放出一个链接许可,这样就可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。

值得关注的设计和编程的是:AQS其实就是一个模板类(模板设计模式),它帮我们搭好了一个模板,用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由实现者来决定。还有就是定义成原子变量的当前线程连接数 count 和volatile关键字修饰的 limit 变量,这些都是并发编程的运用。

3.2 Acceptor分析

Acceptor实现了Runnable接口,所以它就是一个线程,并且单独运行。一个端口号只能对应一个ServerSocketChannel,因此这个 ServerSocketChannel 是在多个Acceptor线程之间共享的,它是 Endpoint 的属性,由 Endpoint 完成初始化和端口绑定,例如NioEndPoint中的initServerSocket方法:

org.apache.tomcat.util.net.NioEndpoint#initServerSocket

java 复制代码
protected void initServerSocket() throws Exception {
        if (!getUseInheritedChannel()) {
            serverSock = ServerSocketChannel.open();
            socketProperties.setProperties(serverSock.socket());
            InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
            serverSock.socket().bind(addr,getAcceptCount());
        } else {
            // Retrieve the channel provided by the OS
            Channel ic = System.inheritedChannel();
            if (ic instanceof ServerSocketChannel) {
                serverSock = (ServerSocketChannel) ic;
            }
            if (serverSock == null) {
                throw new IllegalArgumentException(sm.getString("endpoint.init.bind.inherited"));
            }
        }
        serverSock.configureBlocking(true); //mimic APR behavior
    }

1、bind 方法的第二个参数表示操作系统的等待队列长度,在上面说过,当应用层面的连接数到达最大值时,操作系统可以继续接收连接,那么操作系统能继续接收的最大连接数就是这个队列长度,可以通过 acceptCount 参数配置,默认是 100

2、``ServerSocketChannel`被设置成阻塞模式,也就是说它是以阻塞的方式接收连接的。

ServerSocketChannel通过 accept() 接受新的连接,accept() 方法返回获得 SocketChannel 对象,然后将 SocketChannel 对象封装在一个``PollerEvent对象中,并将PollerEvent对象压入 Poller的 Queue 里,这是个典型的生产者 - 消费者模式,Acceptor Poller线程之间通过 Queue(队列) 通信。例如NioEndpoint:org.apache.tomcat.util.net.NioEndpoint#setSocketOptions`

Acceptor的run方法分析:

java 复制代码
public class Acceptor<U> implements Runnable {

    private static final Log log = LogFactory.getLog(Acceptor.class);
    private static final StringManager sm = StringManager.getManager(Acceptor.class);
    private static final int INITIAL_ERROR_DELAY = 50;
    private static final int MAX_ERROR_DELAY = 1600;
    private final AbstractEndpoint<?,U> endpoint;
    private String threadName;
    protected volatile AcceptorState state = AcceptorState.NEW;
    public Acceptor(AbstractEndpoint<?,U> endpoint) {
        this.endpoint = endpoint;
    }
    // 省略getter setter
    @Override
    public void run() {
        int errorDelay = 0;
        // 会一直轮询,直到shutdown
        while (endpoint.isRunning()) {
            // 如果EndPoint被暂停,Acceptor也会暂停,一直轮询每轮休息50毫秒,直到EndPoint不再暂停
            while (endpoint.isPaused() && endpoint.isRunning()) {
                state = AcceptorState.PAUSED;
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                }
            }
            if (!endpoint.isRunning()) {
                // 如果EndPoint结束运行,Acceptor也会结束运行
                break;
            }
            state = AcceptorState.RUNNING;
            try {
                // 如果已到达最大连接数,那就会被阻塞。countUpOrAwaitConnection 最终是调用 LimitLatch 来限制的
                endpoint.countUpOrAwaitConnection();
                // 当达到LimitLatch限制的最大连接数,Endpoint可能会被暂停
                // 这时候 Acceptor 不再接收连接
                if (endpoint.isPaused()) {
                    continue;
                }
                U socket = null;
                try {
                    // 接收连接请求,也就是生成一个Socket,就是 SocketChannel accept()
                    socket = endpoint.serverSocketAccept();
                } catch (Exception ioe) {
                    // 拿不到Socket的话,释放连接,LimitLatch 的count -1
                    endpoint.countDownConnection();
                    if (endpoint.isRunning()) {
                        // 如果EndPoint的状态还是在运行中,那么就线程进入休眠,并返回下一次休眠的时间
                        // 会调用 Thread.sleep(currentErrorDelay) 让线程让出CPU,防止线程一直轮询获取不到Socket而一直发生异常触发大量的日志
                        // 例如,如果达到了打开文件的ulimit限制,就可能发生这种情况。
                        errorDelay = handleExceptionWithDelay(errorDelay);
                        // re-throw
                        throw ioe;
                    } else {
                        break;
                    }
                }
                // 成功accept建立连接,errorDelay设置为0
                errorDelay = 0;
                if (endpoint.isRunning() && !endpoint.isPaused()) {
                    // setSocketOptions() 成功的话会把Socket交给对应的EndPoint
                    // 例如:AprEndPoint、NioEndPoint、Nio2EndPoint
                    if (!endpoint.setSocketOptions(socket)) {
                        // 如果失败的话,会关闭该Socket
                        endpoint.closeSocket(socket);
                    }
                } else {
                    // EndPoint 已结束运行的话,销毁该Socket
                    endpoint.destroySocket(socket);
                }
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                String msg = sm.getString("endpoint.accept.fail");
                // APR specific.
                // Could push this down but not sure it is worth the trouble.
                if (t instanceof Error) {
                    Error e = (Error) t;
                    if (e.getError() == 233) {
                        // Not an error on HP-UX so log as a warning
                        // so it can be filtered out on that platform
                        // See bug 50273
                        log.warn(msg, t);
                    } else {
                        log.error(msg, t);
                    }
                } else {
                        log.error(msg, t);
                }
            }
        }
        state = AcceptorState.ENDED;
    }
    // 省略 handleExceptionWithDelay

    public enum AcceptorState {
        NEW, RUNNING, PAUSED, ENDED
    }
}

3.3 Poller分析

上面说过,``Poller`本质上是Selector,且内部维护了一个同步队列,使用SynchronizedQueue用来保证同一时刻只有一个 Acceptor 线程对队列进行读写。同时有多个 Poller 线程在运行,每个 Poller 线程都有自己的 Queue。每个 Poller 线程可能同时被多个 Acceptor 线程调用来注册 PollerEvent。同样 Poller 的个数可以通过 pollers 参数配置

java 复制代码
public class Poller implements Runnable {
    private Selector selector;
    private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
    // ....
}

存放的是PollerEvent。而PollerEvent则是持有本次连接的Channel和操作事件。

java 复制代码
public static class PollerEvent implements Runnable {
    private NioChannel socket;
    private int interestOps;
    // ...
}

Poller 类是与 NioEndpoint 相关的关键类之一。该类负责实现轮询(Polling)机制,不断的通过内部的 Selector 对象向内核查询 Channel 的状态,监听和处理来自客户端的事件,如连接建立、数据读取、数据写入等。一旦可读就生成任务类 SocketProcessor 交给 Executor 去处理。Poller 的另一个重要任务是循环遍历检查自己所管理的 SocketChannel 是否已经超时,如果有超时就关闭这个 SocketChannel

下面是 Poller 类的主要方法及其作用:可以看得出和Selector功能十分相似

  1. register(SocketChannel socket, NioEndpoint.KeyAttachment att)
    • 用于将给定的 SocketChannel 注册到 Selector 中。
    • att 参数是 NioEndpoint 中的关键附加对象,用于存储与连接相关的信息。
  2. cancelledKey(NioEndpoint.KeyAttachment ka, SocketChannel socket)
    • 处理连接的取消注册,通常在连接关闭时调用。
    • 将相关的 SocketChannel 从 Selector 中注销。
  3. processKey(SelectionKey sk, NioEndpoint.KeyAttachment ka)
    • 处理给定的 SelectionKey。
    • 根据 SelectionKey 的事件类型(OP_ACCEPT、OP_READ、OP_WRITE 等),调用适当的处理方法。
  4. run():
    • 执行轮询过程,处理所有已注册的事件。
    • 调用 Selector.select() 方法等待就绪的事件。
    • 遍历已选择的 SelectionKey 集合,并调用 processKey 处理每个事件。
  5. registerReadInterest(NioEndpoint.KeyAttachment ka)
    • 注册对读事件的兴趣,使得 Selector 监听读事件。
    • 在需要从客户端读取数据时调用。
  6. registerWriteInterest(NioEndpoint.KeyAttachment ka)
    • 注册对写事件的兴趣,使得 Selector 监听写事件。
    • 在需要向客户端写入数据时调用。
  7. timeout(long now)
    • 处理超时事件,通常用于关闭空闲连接。
    • 根据传入的当前时间 now,检查连接是否超过了指定的超时时间。

3.4 SocketProcessor分析

我们知道,Poller 的run()方法中会轮询SelectionKey,然后根据对应的事件调用org.apache.tomcat.util.net.AbstractEndpoint#processSocket方法创建 SocketProcessor 任务类交给线程池处理,而 SocketProcessor 实现了 Runnable 接口,用来定义 Executor 中线程所执行的任务,主要就是调用 Http11Processor 组件来处理请求。Http11Processor 读取 Channel 的数据来生成 ServletRequest 对象,这里需要注意:

Http11Processor 并不是直接读取 Channel 的。这是因为 Tomcat 支持同步非阻塞 I/O 模型和异步 I/O 模型,在 Java API 中,相应的 Channel 类也是不一样的,比如有 AsynchronousSocketChannelSocketChannel,为了对 Http11Processor 屏蔽这些差异,Tomcat 设计了一个包装类叫作 SocketWrapperHttp11Processor 只调用 SocketWrapper 的方法去读写数据。

因此Tomcat中有这两个Channel:NioChannel 和 Nio2Channel,它们都是与I/O操作相关的通道,但它们基于不同的Java NIO(New I/O)API。

  1. NioChannel: 同步非阻塞
    • 使用的是 Java NIO 中的 java.nio.channels.SocketChannel
    • 是早期版本的 Tomcat 中默认的通道实现。
    • 对于每个连接,它通常使用一个线程来处理读取和写入操作。
    • 在处理 I/O 操作时,通常使用非阻塞的方式。
  2. Nio2Channel: 异步
    • 使用的是 Java NIO 2 中的 java.nio.channels.AsynchronousSocketChannel
    • 是引入在 Java 7 中的 NIO 2 的 Tomcat 7 及更高版本中的通道实现。
    • 支持异步 I/O 操作,可以提高并发性能,特别适用于处理大量连接的情况。
    • 使用了回调机制,当 I/O 操作完成时,系统会调用预定义的回调函数。
    • 提供了更灵活的异步 I/O 支持,允许应用程序更好地处理并发连接。

总体而言,Nio2Channel 在处理大量并发连接时表现更好,因为它能够充分利用异步 I/O 操作的优势。然而,选择使用哪种通道取决于具体的应用场景和系统要求。在一些情况下,NioChannel 可能仍然是一个合适的选择。

3.5 Executor线程池

这个后续单独说一些Tomcat定制的线程池。

四、总结

  • 网络I/O模型就是为了解决内存和外部设备速度差异的问题。阻塞或非阻塞是指应用程序在发起 I/O 操作时,是立即返回还是等待 。而同步和异步,是指应用程序在与内核通信时,数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发
  • Tomcat 的 EndPoint 组件的主要工作就是处理 I/O,而 NioEndpoint 利用 Java NIO API 实现了多路复用 I/O 模型。其中关键的一点是,读写数据的线程自己不会阻塞在 I/O 等待上,而是把这个工作交给 Selector。这个可以这样理解:**有两个线程,一个线程负责接收连接,连接准备就绪后,后续的读写操作交给另外一个线程,第一个线程只负责接收连接。**就好像餐厅的门口接待员的只负责接待客人,然后把客人交给大厅的服务员,接待员只负责接待客人。类型的如Netty的Boss Group和Work Group
  • 同时 Tomcat 在这个过程中运用到了很多 Java 并发编程技术,比如 AQS、原子类、并发容器,线程池等。
  • 还有设计上还有很多值得学习的,例如Http11ProcessorSocketProcessor通过SocketWrapper来屏蔽不同I/O模型的操作差异等等。
相关推荐
一只叫煤球的猫2 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
bobz9652 小时前
tcp/ip 中的多路复用
后端
bobz9652 小时前
tls ingress 简单记录
后端
皮皮林5513 小时前
IDEA 源码阅读利器,你居然还不会?
java·intellij idea
你的人类朋友3 小时前
什么是OpenSSL
后端·安全·程序员
bobz9654 小时前
mcp 直接操作浏览器
后端
前端小张同学6 小时前
服务器部署 gitlab 占用空间太大怎么办,优化思路。
后端
databook6 小时前
Manim实现闪光轨迹特效
后端·python·动效
武子康7 小时前
大数据-98 Spark 从 DStream 到 Structured Streaming:Spark 实时计算的演进
大数据·后端·spark
该用户已不存在7 小时前
6个值得收藏的.NET ORM 框架
前端·后端·.net