Tomcat的NioEndpoint实现分析

在上一篇<Tomcat系统架构>中提到,Tomcat的网络通信层支持多种 I/O 模型。本文将介绍NioEndpoint,它是直接使用NIO实现了 I/O 多路复用。

# NioEndpoint的处理流程

NioEndpoint的处理流程如下:

NioEndpoint

  • Acceptor实现了Runnable接口,运行在一个独立的线程中。Acceptorrun方法在循环中调用ServerSocketChannel.accept(),将返回的SocketChannel包装成NioSocketWrapper,然后将NioSocketWrapper注册进Poller

  • Poller同样实现了Runnable接口,运行在一个独立的线程中。Poller的核心任务是检测I/O事件,它在无限循环中调用Selector.select(),会得到准备就绪的NioSocketWrapper列表,为每个NioSocketWrapper生成一个SocketProcessor任务,然后把任务扔进线程池Executor去处理。

  • Executor是可配置的线程池,负责运行SocketProcessor任务。SocketProcessor实现了Runnable接口,在run方法中会调用ConnectionHandler.process(NioSocketWrapper, SocketEvent)处理当前任务关联的NioSocketWrapper

ConnectionHandler内部使用一个ConcurrentHashMap建立了NioSocketWrapperProcessor之间的映射。从上一篇<Tomcat系统架构>的介绍我们知道,Processor负责应用层协议的解析,那么我们需要为每个NioSocketWrapper创建并关联一个Processor

为什么要建立NioSocketWrapperProcessor之间的关联呢?因为Processor在从NioSocketWrapper中读取字节流进行协议解析时,数据可能并不完整,这时需要释放工作线程,当Poller再次触发I/O读取事件时,可以根据NioSocketWrapper找回关联的Processor,继续进行未完成的协议解析工作。

Processor解析的结果是生成Tomcat的Request对象,然后调用Adapter.service(request, response)方法。Adapter的职责是将Tomcat的Request对象转换为标准的ServletRequest后,传递给Servlet引擎,最终会调用到用户编写的Servlet.service(ServletRequest, ServletResponse)

# NioEndpoint的线程模型

我们注意到,在Tomcat 9的实现中,AcceptorPoller都只有一个线程,并且不可配置。Poller检测到的I/O事件会被扔进Executor线程池中处理,最终Servlet.service也是在Executor中执行。这是一种常见的NIO线程模型,将I/O事件的检测和处理分开在不同的线程。

但这种处理方式也有缺点。当Selector检测到数据就绪事件时,运行Selector线程的CPU已经在CPU cache中缓存了数据。这时切换到另外一个线程去读,这个读取线程很可能运行在另一个CPU核,此前缓存在CPU cache中的数据就没用了。同时这样频繁的线程切换也增加了系统内核的开销。

同样是基于NIO,Jetty使用了不同的线程模型:线程自己产生的I/O事件,由当前线程处理,“Eat What You Kill”,同时,Jetty可能会新建一个新线程继续检测和处理I/O事件。

这篇博客详细的介绍了Jetty的 “Eat What You Kill” 策略。Jetty也支持类似Tomcat的ProduceExecuteConsume策略,即I/O事件的产出和消费用不同的线程处理。

Threading-PEC

ExecuteProduceConsume策略,也就是 “Eat What You Kill”,I/O事件的生产者自己消费任务。

Threading-EPC

Jetty对比了这两种策略,使用ExecuteProduceConsume能达到更高的吞吐量。

benchmark

其实,Netty也使用了和 “Eat What You Kill” 类似的线程模型。

netty-thread-model

Channel注册到EventLoop,一个EventLoop能够服务多个ChannelEventLoop仅在一个线程上运行,因此所有I/O事件均由同一线程处理。

# blocking write的实现

当通过Response向客户端返回数据时,最终会调用NioSocketWrapper.write(boolean block, ByteBuffer from)NioSocketWrapper.write(boolean block, byte[] buf, int off, int len),将数据写入socket。

我们注意到write方法的第一个参数block,它决定了write是使用blocking还是non-blocking方式。比较奇怪,虽然是NioEndpoint,但write动作也不全是non-blocking

一般NIO框架在处理write时都是non-blocking方式,先尝试SocketChannel.write(ByteBuffer),如果buffer.remaining() > 0,将剩余数据以某种方式缓存,然后把SelectionKey.OP_WRITE添加到SelectionKeyinterest set,等待被Selector触发时再次尝试写出,直到buffer中没有剩余数据。

那是什么因素决定了NioSocketWrapper.writeblocking还是non-blocking呢?

我们看一下Http11OutputBuffer.isBlocking的实现:

1
2
3
4
5
6
7
/**
* Is standard Servlet blocking IO being used for output?
* @return <code>true</code> if this is blocking IO
*/
protected final boolean isBlocking() {
    return response.getWriteListener() == null;
}

如果response.getWriteListener()不为null,说明我们注册了WriteListener接收write事件的通知,这时我们肯定是在使用异步Servlet。

也就是说,当我们使用异步Servlet时,才会使用NioSocketWrapper.writenon-blocking方式,普通的Servlet都是使用blocking方式的write。

NioEndpoint在实现non-blocking的write时和一般的NIO框架类似,那它是如何实现blocking方式的write呢?

Tomcat的NIO connector有一个配置参数selectorPool.sharedselectorPool.shared的缺省值为true,这时会创建一个运行在独立线程中BlockPoller。调用者在发起blocking write时,会将SocketChannel注册到这个BlockPoller中,然后await在一个CountDownLatch上。当BlockPoller检测到准备就绪的SocketChannel,会通过关联的CountDownLatch唤醒被阻塞的调用者。这时调用者尝试往SocketChannel中写入,如果buffer中还有剩余数据,那么会再把SocketChannel注册回BlockPoller,并继续await,重复前面的过程,直到数据完全写出,最后调用者从blocking的write方法返回。

当设置selectorPool.sharedfalse时,NioEndpoint会为每个发起blocking write的线程创建一个Selector,执行和上面类似的过程。当然NioEndpoint会使用NioSelectorPool来缓存Selector,并不是每次都创建一个新的SelectorNioSelectorPool中缓存的Selector的最大数量由selectorPool.maxSelectors参数控制。

至此,相信你对NioEndpoint的内部实现已经有了整体的了解。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus