Tomcat NIO 模型的实现

Tomcat 对 BIO 和 NIO 两种模型都进行了实现,其中 BIO 的实现理解起来比较简单,而 NIO 的实现就比较复杂了,并且它跟常用的 Reactor 模型也略有不同,具体设计如下:

Tomcat NIO 模型的实现

可以看出多了一个 BlockPoller 的设计,这是因为在 Servlet 规范中 ServletInputStream 和 ServletOutputStream 是阻塞的,所以请求体和响应体的读取和发送需要阻塞处理。请求行读取SSL 握手使用非阻塞的 Poller 处理。一次连接基本的处理流程是:

  • Acceptor 接收 TCP 连接,并将其注册到 Poller 上
  • Poller 发现通道有就绪的 I/O 事件,将事件分配给线程池中的线程处理
  • 线程池线程首先在 Poller 上非阻塞完成请求行和 SSL 握手的处理,然后通过容器调用 Servlet,生成响应,最后如果需要读取请求体或者发送响应,那就会将通道注册到 BlockPoller 上模拟阻塞完成

接下来分析核心代码的实现,源码来自 Tomcat 6.0.53 版本,之所以使用这个版本是因为看起来简单直观没有太多的抽象,也不影响来理解核心的处理逻辑。首先看下连接处理的方法调用情况,可右键直接打开图片查看大图:

Tomcat NIO 模型的实现

相关类或接口的功能如下:

  • Acceptor: 阻塞监听和接收通道连接
  • Poller: 事件多路复用器,通知 I/O 事件的发生并分配合适的处理器
  • PollerEvent: 是对通道、SelectionKey 的附加对象和通道关注事件的封装
  • SocketProcessor: 线程池调度单元,它处理 SSL 握手,调用 Handler 解析协议
  • Handler: 通道处理接口,用于适配多种协议,如 HTTP、AJP
  • NioEndpoint: 服务启停初始化的入口
  • NioSelectorPool: 提供一个阻塞读写使用的 Selector 池和一个单例 Selector
  • NioBlockingSelector: 提供阻塞读和写的方法
  • BlockPoller: 与 NioBlockingSelector 配合完成模拟阻塞
  • NioChannel: 封装 SocketChannel 和读写使用的 ByteBuffer
  • KeyAttachment: Key 的附加对象,它包含通道最后访问时间和用于模拟阻塞使用的读写闭锁

1. Acceptor 注册通道到 Poller 上

Acceptor 和 Poller 分属两个不同的线程,通常情况下 Poller 阻塞在 select() 方法的调用上,此方法会锁住内部的 publicKeys 集合,所以 Acceptor 接收到通道连接不能直接注册到 Poller 上,否则会造成死锁。Tomcat 使用生产者-消费者模式来进行并发协作,缓冲区使用的是 ConcurrentLinkedQueue *队列。

Acceptor 接收到连接的 SocketChannel 后,将其配置成非阻塞模式,封装成 NioChannel,最后调用 getPoller0().register(NioChannel) 加入到某个 Poller 的事件队列中。

public void register(final NioChannel socket) {
socket.setPoller(this); // 关联此 Poller
KeyAttachment key = keyCache.poll();
final KeyAttachment ka = key!=null?key:new KeyAttachment();
// 重置或者初始化 KeyAttachment 对象
ka.reset(this,socket,getSocketProperties().getSoTimeout());
PollerEvent r = eventCache.poll();
// 声明此通道关注的事件
ka.interestOps(SelectionKey.OP_READ);//this is what OP_REGISTER turns into.
// 将此通道和 SelectionKey 附件对象封装成 PollerEvent 对象
if ( r==null) r = new PollerEvent(socket,ka,OP_REGISTER);
else r.reset(socket,ka,OP_REGISTER);
// 加入到 Poller 的 events 队列中
addEvent(r);
}
public void addEvent(Runnable event) {
events.offer(event); // 插入队列
if ( wakeupCounter.incrementAndGet() == 0 )
selector.wakeup(); // 唤醒 Selector
}

Poler 有个 events() 方法,用于遍历事件队列进行处理,events() 会在 select 调用超时或者被唤醒且没有通道发生 I/O 事件时被调用,代码如下:

public boolean events() {
boolean result = false;
Runnable r = null;
// 遍历事件队列
while ( (r = events.poll()) != null ) {
result = true;// 有事件待处理
try {
r.run(); // 本质调用的是 PollerEvent.run()
if ( r instanceof PollerEvent ) {
// 重置并缓存 PollerEvent 对象
((PollerEvent)r).reset();
eventCache.offer((PollerEvent)r);
}
} catch ( Throwable x ) {
log.error("",x);
}
}
return result;
}

可以看出这里有个关键对象 PollerEvent,它内部有个 interestOps 属性,表示要处理的事件类型,它有三个可能的值分别是:

  • NioEndpoint.OP_REGISTER: 通道注册事件
  • SelectionKey.OP_READ: 通道重新声明在 Poller 上关注读事件
  • SelectionKey.OP_WRITE: 通道重新声明在 Poller 上关注写事件

OP_REGISTER 的处理就是将通道注册到 Selector 上的最终实现,代码如下:

if ( interestOps == OP_REGISTER ) {
try {
// 将 SocketChannel 注册到 Poller 的 Selector 上并指定关注的事件和附加对象
socket.getIOChannel().register(socket.getPoller().getSelector(), SelectionKey.OP_READ, key);
} catch (Exception x) {
log.error("", x);
}
}

至此已完成了通道注册,接下来看一下 PollerEvent 为什么还要处理 OP_READ 和 OP_WRITE 事件。

2. PollerEvent 对 OP_READ 和 OP_WRITE 的处理

PollerEvent(又或者说 Poller)要处理读写事件,就是因为程序需要一次非阻塞的读或写操作。一开始通道是在 Poller 上声明关注的事件,但是在发生 I/O 事件后,Poller 就会把此通道就绪的事件从它关注的事件中移除(原因见下文),所以如果需要非阻塞的读或写,只能再次在这个 Poller 上重新声明。

解析请求行是非阻塞的,解析过程中,由于 TCP 存在粘包/拆包的问题,可能导致数据读取不完整,需要再次从通道读取,此时就要在关联的 Poller 上重新关注读事件,核心代码:

// 拿到通道在 Poller 上对应的 SelectionKey
final SelectionKey key = socket.getIOChannel().keyFor(socket.getPoller().getSelector());
try {
boolean cancel = false;
if (key != null) {
...
// 将 interestOps 合并到 key 现有关注的事件集合中
int ops = key.interestOps() | interestOps;
// 更新 key 和 附加对象关注的操作
att.interestOps(ops);
key.interestOps(ops);
att.setCometOps(ops);
} else {
cancel = true;
}
}catch (CancelledKeyException ckx) {}

3. Poller 对 I/O 事件的处理

Poller 就是 Reactor,主要功能是将就绪的 SelectionKey 分配给处理器处理,此外它还检查通道是否超时。它在调用 select 方法时会根据条件确定是阻塞还是非阻塞,代码如下:

if ( !close ) {
if (wakeupCounter.getAndSet(-1) > 0) {
// wakeupCounter 大于0,意味着 Acceptor 接收了大量连接,产生大量 PollerEvent 急
// 需 Poller 消费处理,此时进行一次非阻塞调用
keyCount = selector.selectNow();// 非阻塞直接返回
} else {
// wakeupCounter 等于0,阻塞等待 IO 事件发生或被唤醒
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0);
}

当有通道 I/O 事件就绪时,Poller 将会创建一个 SocketProcessor 提交线程池处理,具体代码不再贴出。在这个过程中有一个将当前就绪的事件从 SelectionKey 中移除的操作,这是为了后续能够在 BlockPoller 上阻塞读写时,防止多个线程的干扰,具体代码如下:

protected void unreg(SelectionKey sk, KeyAttachment attachment, int readyOps) {
// 取反再与 - 表示从 sk.interestOps() 中清除 readyOps 所在的位
reg(sk,attachment,sk.interestOps()& (~readyOps));
}
protected void reg(SelectionKey sk, KeyAttachment attachment, int intops) {
sk.interestOps(intops);
attachment.interestOps(intops);
//attachment.setCometOps(intops);
}

检查超时的方法是 Poller.timedout(keyCount, hasEvents),它在 Poller 的每次循环上都被调用,但不是每次都处理超时,因为这会产生过多的负载,而超时可等待几秒钟再超时也没事。Poler 有一个名为 nextExpiration 的成员变量,它表示检查超时的最短时间间隔,在这个时间内,如果只是 select() 调用超时(表示负载不大)会执行处理超时。

4. SocketProcessor 的处理

SocketProcessor 处理 SSL 握手和调用 Handler 进行实际的 I/O 操作。Handler 的子类 Http11ConnectionHandler 会创建 一个 Http11NioProcessor 对象最终处理 Socket,这里不分析具体的协议处理,来看看几种处理结果:

public SocketState process(NioChannel socket) {
Http11NioProcessor processor = null;
try {
processor = connections.remove(socket);
...
SocketState state = processor.process(socket);
if (state == SocketState.LONG) {
// 在处理request和生成response之间,保持socket和此processor的关联
connections.put(socket, processor);
// 通常是收到了不完整的请求行,再次以 OP_READ 注册到 Poller 上
socket.getPoller().add(socket);
} else if (state == SocketState.OPEN) {
// 长连接,Http 保活,回收 processor
release(socket, processor);
// 此时已处理一个完整的请求并响应,再次注册到 Poller 上,等待处理下个请求
socket.getPoller().add(socket);
} else if (state == SocketState.SENDFILE) {
// 处理文件
connections.put(socket, processor);
} else {
// 连接关闭,回收 processor
release(socket, processor);
}
return state;
} catch (...) {...}
release(socket, processor);
return SocketState.CLOSED;
}

5. 模拟阻塞的实现

模拟阻塞是通过 NioBlockingSelector 和 BlockPoller,以及 KeyAttachment 中的两个 CountDownLatch 读写闭锁合作完成。这里分析阻塞读,阻塞写的实现类似。一般的,在读取 POST 请求参数时会使用模拟阻塞完成,来看下 NioBlockingSelector.read() 方法的具体实现:

public int read(ByteBuffer buf, NioChannel socket, long readTimeout) throws IOException {
// 拿到通道在 Poller 上注册的 key
SelectionKey key = socket.getIOChannel().keyFor(socket.getPoller().getSelector());
if ( key == null ) throw new IOException("Key no longer registered");
KeyReference reference = new KeyReference();
// key 的附加对象
KeyAttachment att = (KeyAttachment) key.attachment();
int read = 0; // 读取的字节数
boolean timedout = false; // 是否超时
int keycount = 1; //assume we can write 假设通道可读
long time = System.currentTimeMillis(); //start the timeout timer
try {
while ( (!timedout) && read == 0) {
if (keycount > 0) { //only read if we were registered for a read
// 尝试读取一次,如果通道无数据可读则返回 0,若连接断开则返回 -1
int cnt = socket.read(buf);
if (cnt == -1) throw new EOFException();
read += cnt;
if (cnt > 0) break;
}
try {
// 初始化读闭锁
if ( att.getReadLatch()==null || att.getReadLatch().getCount()==0) att.startReadLatch(1);
// 将此通道注册到 BlockPoller,关注读取事件
poller.add(att,SelectionKey.OP_READ, reference);
// 阻塞等待通道可读
att.awaitReadLatch(readTimeout,TimeUnit.MILLISECONDS);
}catch (InterruptedException ignore) {
Thread.interrupted();
}
if ( att.getReadLatch()!=null && att.getReadLatch().getCount()> 0) {
// 被打断了,但是没有接收到 blockPoller 的提醒
keycount = 0;
// 继续循环等待可读
}else {
//通道可读,重置读闭锁
keycount = 1;
att.resetReadLatch();
}
if (readTimeout > 0 && (keycount == 0)) // 如果超时了,则不再读取,抛异常
timedout = (System.currentTimeMillis() - time) >= readTimeout;
} //while
if (timedout)
throw new SocketTimeoutException();
} finally {
poller.remove(att,SelectionKey.OP_READ); // 移除注册
if (timedout && reference.key!=null) {
poller.cancelKey(reference.key); // 超时取消
}
reference.key = null;
}
return read;
}

BlockPoller 实现逻辑与 Poller 大致相同,不同的地方在于对就绪 key 的处理,核心代码如下:

Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (run && iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
KeyAttachment attachment = (KeyAttachment)sk.attachment();
try {
attachment.access();
iterator.remove();
// 移除已就绪的事件
sk.interestOps(sk.interestOps() & (~sk.readyOps()));
// 可读或可写时减少对应闭锁的值,此时阻塞在 NioBlockingSelector.read() 上的线程继续执行读取
if ( sk.isReadable() ) {
countDown(attachment.getReadLatch());
}
if (sk.isWritable()) {
countDown(attachment.getWriteLatch());
}
}catch (CancelledKeyException ckx) {
if (sk!=null) sk.cancel();
countDown(attachment.getReadLatch());
countDown(attachment.getWriteLatch());
}
}//while

6. 小结

至此,本文对连接的接收、分发以及模拟阻塞的核心代码实现进行了分析,为了更好的理解内部流程,尽可能的使用简洁的代码仿写了这部分功能。

源码地址https://github.com/tonwu/rxtomcat 位于 rxtomcat-net 模块

7. Tomcat 8.5 版本变化

7.1 替换缓存数据结构

Tomcat 对 PollerEvent、NioChannel 和 Processor 对象进行了缓存,目的是减少 GC 提高系统性能,这是一种用空间换时间,被称为对象池的优化手段。从版本 8.* 开始,缓存数据结构从 ConcurrentLinkedQueue 换成了自定义的同步栈 SynchronizedStack。SynchronizedStack 的 javadoc 明确说明:

当需要创建一个无需缩小的可重用对象池时,这是 ConcurrentLinkedQueue 无 GC 的主要替代方案。目的是尽可能快地以最少的垃圾提供最少的所需功能。

在这个特殊的情况下,ConcurrentLinkedQueue 有很多功能是不需要的,所以就实现了一个有重点的类,可以专注完成一件事,来提升性能。但它不是 ConcurrentLinkedQueue 的替代品。

7.2 LimitLatch

Acceptor 在接收连接前添加了一个 LimitLatch(类似信号量)来控制总连接数。分析下如果不加有什么现象,在极端情况下,线程池没有空闲线程并且它内部的队列已满,当有通道发生可读或可写事件时,Poller 会关闭此通道,此时系统负载已达到最高,如果 Acceptor 还在继续接收连接并请求注册,而不加限制,那么就会一直重复 PollerEvent 入队出队和 Poller 单纯关闭通道的操作,浪费系统资源。

上一篇:nuxt Window 或 Document未定义解决方案


下一篇:MySQL高可用复制管理工具 —— Orchestrator使用