Tomcat之IO模型实现
在之前的文章:【Tomcat之整体架构】提到Tomcat支持NIO、NIO2和APR三种IO模型。本文先简单介绍IO模型,然后分析一下NIO模型如何实现,最后简单分析其他两种IO模型的特点。
1. I/O模型
对于一个网络IO通信过程,如网络数据读取,会涉及两个对象,一个是调用这个I/O操作的用户线程,另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。当用户发起I/O操作后,网络读取会经历两个步骤:
- 用户线程等待内核将数据从网卡拷贝到内核空间。
- 内核将数据从内核空间拷贝到用户空间。
各种I/O模型的区别就是,实现这两个步骤的方式是不一样的。
- 同步阻塞I/O:用户线程发起read调用就阻塞,让出cpu。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程叫醒。
- 同步非阻塞I/O:用户线程不断发起read调用。数据没到内核空间时,每次返回失败。直到数据到了内核空间,这次read调用,在等待数据从内核空间拷贝用用户空间这段时间,线程还是阻塞的,等数据到了用户空间再把线程唤醒。
- I/O多路复用:用户线程的读取操作分两步,线程先发起select调用,目的是问内核数据是否准备好了。等内核把数据准备好了,用户线程再发起read调用。在等待数据从内核空间拷贝用用户空间这段时间,线程还是阻塞的。为什么叫多路复用?因为一次select可以向内核查多个数据通道的状态。
- 异步I/O:用户线程发起read调用的同时,注册一个回调函数。read立即返回,等内核将数据准备好后,再调用指定的回调函数完成处理。这个过程,用户线程一直没有阻塞。
2. NioEndpoint组件
Tomcat的NioEndpoint组件实现了I/O多路复用模型。对于Java的多路复用器使用,总体工作流程如下:
- 创建一个Selector,在它身上注册各种感兴趣的事件,然后调用select方法,等待感兴趣的事情发生。
- 感兴趣的事情发生了,如:可读了,便创建一个新的线程从Channel中读数据。
Tomcat的NioEndpoint组件实现虽然标比较复杂,但基本原理就是上面的两步。内部涉及:LimitLatch、Acceptor、Poller、SocketProcessor、Executor共5个组件,工作流程如下:
2.1 LimitLatch
LimitLatch是连接控制器,它负责控制最大连接数,NIO模式下默认是10000,达到这个阈值后,连接请求被拒绝。
LimitLatch用来控制连接个数,当连接数到达最大时阻塞线程,直到后续组件处理完一个连接后将连接数减1。达到最大连接数后,底层还是会接收到客户端连接,但用户层已经不再接收。
LimitLatch内部类Sync扩展了AQS,来实现阻塞/唤醒线程作用。(这里并不展开AQS相关知识)
LimitLatch在Acceptor调用accept方法前会将连接数加1;在Poller中cancelledKey(关闭socket连接)或一些异常的场景下,会将连接数减1。
2.2 Acceptor
Acceptor跑在一个单独的线程中,它在一个死循环中调用accept方法来接收新连接,一旦有新的连接请求到来,accept方法返回一个Channel对象,接着把Channel对象交给Poller处理。
Acceptor实现了Runnable接口,因此可以跑在单独线程里。一个端口号只能对应一个ServerSocketChannel,因此这个ServerSocketChanel是在多个Acceptor线程之间共享的,它是由Endpoint完成初始化和端口绑定的。初始化流程:AbstractEndpoint调用init方法时会调用抽象方法bind()
java
//NioEndpoint#bind
serverSock = ServerSocketChannel.open();
socketProperties.setProperties(serverSock.socket());
InetSocketAddress addr = (getAddress()!=null?new InetSocketAddress(getAddress(),getPort()):new InetSocketAddress(getPort()));
serverSock.socket().bind(addr,getAcceptCount());
serverSock.configureBlocking(true);
从上面可以看出两个关键信息:
- bind方法第二个参数表示操作系统等待队列长度,上面提到应用层面连接数达到最大值时,操作系统可以继续接收连接,那么操作系统能继续接收的最大连接数就是这个队列长度,可以通过acceptCount参数配置,默认100。
- ServerSocketChannel被设置成阻塞模式,也就是说它是以阻塞的方式接收连接的。
Acceptor线程会启动在NioEndpoint的startInternal方法中被调用,逻辑如下:Acceptor线程数量可以通过acceptorThreadCount参数配置。
java
//Abstract#startAcceptorThreads
protected final void startAcceptorThreads() {
int count = getAcceptorThreadCount();
acceptors = new Acceptor[count];
for (int i = 0; i < count; i++) {
acceptors[i] = createAcceptor();
String threadName = getName() + "-Acceptor-" + i;
acceptors[i].setThreadName(threadName);
Thread t = new Thread(acceptors[i], threadName);
t.setPriority(getAcceptorThreadPriority());
t.setDaemon(getDaemon());
t.start();
}
}
Acceptor内部代码逻辑如下(简略后):主要是调用ServerSocketChannel方法,接收一个新连接,获取到SocketChannel对象,设置为非阻塞,然后随机获取一个Poller调用register方法,该方法内部把SocketChannel通过PollerEvent对象压入到Poller的Queue里。这是一个典型的生产者-消费者模式,Acceptor与Poller线程之间通过Queue通信。
java
//NioEndpoint$Acceptor#run
public void run() {
while (running) {
countUpOrAwaitConnection();
SocketChannel socket = serverSock.accept();
setSocketOptions(socket)
}
}
//NioEndpoint#setSocketOptions
protected Boolean setSocketOptions(SocketChannel socket) {
socket.configureBlocking(false);
Socket sock = socket.socket();
socketProperties.setProperties(sock);
NioChannel channel = 创建一个NioChannel;
getPoller0().register(channel);
}
2.3 Poller
Poller本质是一个Selector,也跑在单独线程里。Poller内部维护一个Channel数组,它在一个死循环里不断检测Channel的数据就绪状态,一旦有Channel可读,就生成一个SocketProcessor任务对象扔给Executor去处理。
Poller内部维护了一个Queue,定义如下:
swift
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
SynchronizedQueue的方法,如offer、poll等,都使用了Synchronized关键字进行修饰,用来保证同一时刻只有一个Acceptor对Queue进行读写。同时又多个Poller线程运行,每个Poller线程都有自己的Queue。每个Poller线程可能同时被多个Acceptor线程调用注册PollerEvents。Poller的个数可以通过pollerThreadCount参数配置。
Poller不断通过内部的Selector对象,向内核查询Channel的状态,一旦可读就生成任务类SocketProcessor交给Executor处理。
Poller还会不断处理各种PollerEvent的事件,来源有Acceptor传过来的,还有些Poller内部触发的。
Poller还有一个重要的任务,是循环遍历检查自己所管理的SocketChannel是否已经超时,如果有超时就关闭这个SocketChannel。
2.4 SocketProcessor
Poller会创建SocketProcessor任务类交给线程池处理,而SocketProcessor实现了Runnable接口,用来定义Executor中线程所执行的任务,主要是调用Http11Processor组件来处理请求,Http11Processor读取Channel的数据来生成ServletRequest对象,但是需要注意的地方:
Http11Processor并不是直接读取Channel,这是因为Tomcat支持同步非阻塞I/O模型和异步I/O模型。在Java Api中,相应的Channel也是不一样的。为了对Http11Processor屏蔽这些差异,Tomcat设计了一个包装类叫作SocketWrapper,Http11Processor只调用SocketWrapper的方法去读写数据。
2.5 Executor
Executor是一个线程池,负责运行SocketProcessor任务类,SocketProcessor的run方法会调用Http11Processor来读取和解析请求数据。Http11Processor是应用层协议的封装,它会调用容器获得响应,再把响应通过Channel写出。
2.6 小结
I/O模型是为了解决:内存与外部设备速度差异的问题。
平时说的阻塞或非阻塞是指:应用程序发起I/O操作时,是立即返回还是需要等待。
而同步和异步是值:应用程序在内核通信时,数据是从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。
3. Tomcat其他I/O模型
3.1 异步I/O
Java提供了NIO2,和NIO最大的区别在于,它是异步的。也就意味着:内核会主动将数据拷贝到用户空间并通知应用程序。
Windows的IOCP和Linux内核2.6的AIO都提供了异步I/O(但似乎Linux上并没有真正实现异步IO)的支持,Java的NIO2 API就是对操作系统异步I/O的封装。
3.2 APR
APR(Apache Portable Runtime Libraries)是 Apache 可移植运行时库,它是用 C 语言实现的,其目的是向上层应用程序提供一个跨平台的操作系统接口库。
Tomcat可以用它来处理包括文件和网络I/O。跟NioEndpoint一样,也实现了非阻塞I/O,区别是:NioEndpoint通过调用Java的NIO API来实现非阻塞I/O;AprEndpoint是通过JNI调用APR本地库来实现非阻塞I/O。
APR提升性能的关键:使用本地内存和零拷贝
3.2.1 JVM堆 VS 本地内存
Java实例一般在JVM堆上分配,而Java是通过JNI调用C代码来实现Socket通信,那么C代码运行过程中需要的内存又是哪分配?
运行一个Java程序,操作系统会创建一个进程来执行这个Java可执行程序,而每个进程都有自己的虚拟地址空间,JVM用到的内存只是进程空间的一部分,还有代码端、数据段、内存映射等。从JVM的角度来看,除了JVM内存之外的部分叫作本地内存。C程序代码在运行过程中用到的内存就是本地内存中分配的。通过一张图来理解:
Tomcat的Endpoint组件在接收网络数据时需要预先分配好一块Buffer,所谓的Buffer就是字节数组byte[],Java通过JNI调用把这块Buffer的地址传递给C代码,C代码通过操作系统API读取Socket并把数据填充到这块Buffer。Java NIO API提供了两种Buffer来接收数据:HeapByteBuffer和DirectByteBuffer。
HeapByteBuffer对象本身就是JVM上分配,并且它持有的字节数组也是JVM堆上分配。如果用HeapByteBuffer来接收网络数据,**需要把数据从内核先拷贝到一个临时的本地内存,再从临时本地内存拷贝到JVM堆。**而不是直接从内核中拷贝到JVM堆上。这是因为:数据从内核拷贝到JVM堆的过程,JVM可能会发生GC,GC过程中对象可能会被移动,也就是说JVM堆上的字节数组可能会被移动,这样的话Buffer地址就会失效。如果中间经过本地内存中转,从本地内存到JVM对的拷贝过程中,JVM可以保证不做GC。
这是由HotSpot VM层面保证的,具体来说HotSpot也不是什么时候都能GC,需要JVM中各个线程都处于"safepoint"时才能GC,本地内存到JVM堆的拷贝过程中是没有"safepoint"的,所以不会GC。
如果使用HeapByteBuffer会多一次拷贝,而DirectByteBuffer对象本身是在JVM堆上,但它持有的字节数组不是从JVM堆上分配的,而是从本地内存分配的。DirectByteBuffer对象有个long类型字段的address,记录着本地内存的地址,这样在接收数据的时候,直接把这个内存地址传递给C程序,C程序会将网络数据从内核拷贝到这个本地内存,JVM可以直接读取这个本地内存。相比HeapByteBuffer,这种方式少一次拷贝,因此它的速度会比DirectByteBuffer快好几倍。
Tomcat中的AprEndpoint就是通过DirectByteBuffer来接收数据,而NioEndpoint和Nio2Endpoint是通过HeapByteBuffer来接收数据。DirectByteBuffer因为是本地内存,创建/销毁的代价比较大,而且不好管理,发生内存泄漏难以定位。从稳定性考虑,NioEndpoint和Nio2Endpoint没必要冒这个风险。但实际上Netty已经有很好的处理方式了。
3.2.2 sendFile(零拷贝)
另一种网络通信的场景,就是静态文件的处理。浏览器通过Tomcat来获取一个Html文件,而Tomcat的处理逻辑无非就两步:
- 从磁盘读取HTML到内存
- 将这段内存内容通过Socket发送出去。
但传统的方式,有很多次的内存拷贝。
- 读取文件,首先是内核把文件读取到内核缓冲区。
- 如果使用HeapByteBuffer,文件数据从内核到JVM内存需要经过本地内存中转。
- 同样在将文件推入网络时,从JVM内存到内核缓存区需要本地内存中转。
- 最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。
拷贝情况如下:需要6次内存拷贝。并且read和write等系统调用将导致进程从用户态到内核态的切换,会耗费大量的CPU和内存资源。
Tomcat的AprEndpoint通过操作系统层面的sendfile特定解决这个问题。
scss
sendfile(socket,file,len)
它有两个关键参数:Socket和文件句柄。将文件从磁盘写入Socket只要两步。
- 将文件内容读取到内核缓冲区。
- 数据并没有从内核缓冲区复制到Socket关联的缓冲区,只有记录数据位置和长度的描述符被添加到Socket缓冲区中;接着把数据直接从内核传递给网卡。
3.3 小结
Tomcat中几种I/O模型:
- NIO:默认使用。
- NIO2:再Window下使用可以提供性能,实现异步IO。
- APR: 适用于需要频繁与操作系统进行交互的场景,比如网络通信,特别是TLS协议握手过程中需要多次网络交互;发送静态文件等。
4. 参考资料
- 《深入拆解Tomcat & Jetty》- 极客时间
- Tomcat源码分支8.5.x