一、常见的 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
部分代码:
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:线程池,负责运行
SocketProcessor
,SocketProcessor
的 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;
}
// 省略代码
}
- 用户线程通过调用``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
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
功能十分相似
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模型的操作差异等等。