一、常见的 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 多路复用模型。 总体流程:
- 创建一个 Selector,并在 Selector 上注册各种监听的事件,然后调用 select 方法,等待监听的事件发生。
- 如果在 Channel 上发生了 Selector 监听的事件,就创建一个新的线程从 Channel 中读取数据。
NioEndPoint 组件一共包含了以下组件:
LimitLatch
、Acceptor
、Poller
、SocketProcessor
和Executor
。
- LimitLatch:负责控制最大连接数,使用NIO的话默认最大连接数是10000,连接数达到这个阈值之后会被拒绝。
- Acceptor:每一个
Acceptor
都运行在单独的线程中,Acceptor
会有不断轮询调用accept方法查看是否有连接请求,最后返回一个Channel
给Poller
来处理。 - Poller:本质上是
Selector
,因为Poller中持有了Selector
。它也是运行在单独的线程中,内部维护了一个同步队列的数组。一旦有Channel
可读,就会把这个Channel
塞到SocketProcessor
,然后把这个SocketProcessor
交给线程池Executor
处理。
Poller
部分代码:
|
|
SocketProcessor
部分代码:
|
|
- Executor:线程池,负责运行
SocketProcessor
,SocketProcessor
的 run 方法会调用Http11Processor
来读取和解析请求数据。Http11Processor
是应用层协议的封装,它会调用容器获得响应,再把响应通过Channel
写出。
三、组件分析
3.1 LimitLatch分析
LimitLatch
用来控制连接个数,当连接数到达最大时阻塞线程,直到后续组件处理完一个连接后将连接数减 1。到达最大连接数后操作系统底层还是会接收客户端连接,但是LimitLatch
会已经不再接收连接。内部实现了一个抽象队列同步器Sync
来控制并发和实现同步队列。AbstractQueuedSynchronizer(简称:AQS,抽象队列同步器) 是 Java 并发包中的一个核心类,它在内部维护一个状态和一个线程队列,可以用来控制线程什么时候挂起,什么时候唤醒。可以扩展AQS来实现自己的同步器,而且 Java 并发包里的锁和条件变量等等都是通过 AQS 来实现的,这里的LimitLatch
也是:
|
|
- 用户线程通过调用``LimitLatch
的 countUpOrAwait 方法来拿到锁,如果暂时无法获取,这个线程会被阻塞到 AQS 的队列中。
Sync`类重写了 AQS 的tryAcquireShared()方法。它的实现逻辑是如果当前连接数 count 小于 limit,线程能获取锁,返回 1,否则获取失败进入等待,返回 -1。 - 同样的
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、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方法分析:
|
|
3.3 Poller分析
上面说过,``Poller`本质上是Selector,且内部维护了一个同步队列,使用SynchronizedQueue用来保证同一时刻只有一个 Acceptor 线程对队列进行读写。同时有多个 Poller 线程在运行,每个 Poller 线程都有自己的 Queue。每个 Poller 线程可能同时被多个 Acceptor 线程调用来注册 PollerEvent。同样 Poller 的个数可以通过 pollers 参数配置
|
|
存放的是PollerEvent
。而PollerEvent
则是持有本次连接的Channel和操作事件。
|
|
Poller
类是与 NioEndpoint
相关的关键类之一。该类负责实现轮询(Polling)机制,不断的通过内部的 Selector 对象向内核查询 Channel 的状态,监听和处理来自客户端的事件,如连接建立、数据读取、数据写入等。一旦可读就生成任务类 SocketProcessor
交给 Executor 去处理。Poller
的另一个重要任务是循环遍历检查自己所管理的 SocketChannel
是否已经超时,如果有超时就关闭这个 SocketChannel
。
下面是 Poller
类的主要方法及其作用:可以看得出和Selector
功能十分相似
register(SocketChannel socket, NioEndpoint.KeyAttachment att)
:- 用于将给定的
SocketChannel
注册到 Selector 中。 att
参数是NioEndpoint
中的关键附加对象,用于存储与连接相关的信息。
- 用于将给定的
cancelledKey(NioEndpoint.KeyAttachment ka, SocketChannel socket)
:- 处理连接的取消注册,通常在连接关闭时调用。
- 将相关的
SocketChannel
从 Selector 中注销。
processKey(SelectionKey sk, NioEndpoint.KeyAttachment ka)
:- 处理给定的 SelectionKey。
- 根据 SelectionKey 的事件类型(OP_ACCEPT、OP_READ、OP_WRITE 等),调用适当的处理方法。
run():
- 执行轮询过程,处理所有已注册的事件。
- 调用
Selector.select()
方法等待就绪的事件。 - 遍历已选择的 SelectionKey 集合,并调用
processKey
处理每个事件。
registerReadInterest(NioEndpoint.KeyAttachment ka)
:- 注册对读事件的兴趣,使得 Selector 监听读事件。
- 在需要从客户端读取数据时调用。
registerWriteInterest(NioEndpoint.KeyAttachment ka)
:- 注册对写事件的兴趣,使得 Selector 监听写事件。
- 在需要向客户端写入数据时调用。
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 类也是不一样的,比如有 AsynchronousSocketChannel
和 SocketChannel
,为了对 Http11Processor
屏蔽这些差异,Tomcat 设计了一个包装类叫作 SocketWrapper
,Http11Processor
只调用 SocketWrapper
的方法去读写数据。
因此Tomcat中有这两个Channel:NioChannel 和 Nio2Channel,它们都是与I/O操作相关的通道,但它们基于不同的Java NIO(New I/O)API。
- NioChannel: 同步非阻塞
- 使用的是 Java NIO 中的
java.nio.channels.SocketChannel
。 - 是早期版本的 Tomcat 中默认的通道实现。
- 对于每个连接,它通常使用一个线程来处理读取和写入操作。
- 在处理 I/O 操作时,通常使用非阻塞的方式。
- 使用的是 Java NIO 中的
- Nio2Channel: 异步
- 使用的是 Java NIO 2 中的
java.nio.channels.AsynchronousSocketChannel
。 - 是引入在 Java 7 中的 NIO 2 的 Tomcat 7 及更高版本中的通道实现。
- 支持异步 I/O 操作,可以提高并发性能,特别适用于处理大量连接的情况。
- 使用了回调机制,当 I/O 操作完成时,系统会调用预定义的回调函数。
- 提供了更灵活的异步 I/O 支持,允许应用程序更好地处理并发连接。
- 使用的是 Java NIO 2 中的
总体而言,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、原子类、并发容器,线程池等。
- 还有设计上还有很多值得学习的,例如
Http11Processor
和SocketProcessor
通过SocketWrapper
来屏蔽不同I/O模型的操作差异等等。