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

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

tomcat-nio

  • LimitLatch:负责控制最大连接数,使用NIO的话默认最大连接数是10000,连接数达到这个阈值之后会被拒绝。
  • Acceptor:每一个Acceptor都运行在单独的线程中,Acceptor会有不断轮询调用accept方法查看是否有连接请求,最后返回一个ChannelPoller来处理。
  • Poller:本质上是Selector,因为Poller中持有了Selector。它也是运行在单独的线程中,内部维护了一个同步队列的数组。一旦有Channel可读,就会把这个Channel塞到SocketProcessor,然后把这个SocketProcessor交给线程池Executor处理。

Poller部分代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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部分代码:

1
2
3
4
5
6
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也是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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方法分析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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 参数配置

1
2
3
4
5
public class Poller implements Runnable {
    private Selector selector;
    private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
    // ....
}

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

1
2
3
4
5
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模型的操作差异等等。
Licensed under CC BY-NC-SA 4.0