Tomcat的NIO是基于I/O复用来实现的。对这点一定要清楚,不然我们的讨论就不在一个逻辑线上。I/O模型一共有阻塞式I/O,非阻塞式I/O,I/O复用(select/poll/epoll),信号驱动式I/O和异步I/O。首先看下IO的五种模型。下面是摘录前辈的文章 ,分析的不错,仅做收藏
1.1 五种I/O模型
1)阻塞I/O
2)非阻塞I/O
3)I/O复用
4)事件(信号)驱动I/O
5)异步I/O
1.2 为什么要发起系统调用?
因为进程想要获取磁盘中的数据,而能和磁盘打交道的只能是内核, 进程通知内核,说要磁盘中的数据
此过程就是系统调用
1.3 一次I/O完成的步骤
当进程发起系统调用时候,这个系统调用就进入内核模式, 然后开始I/O操作
I/O操作分为俩个步骤:
1) 磁盘把数据装载进内核的内存空间
2) 内核的内存空间的数据copy到用户的内存空间中(此过程才是真正I/O发生的地方)
注意: io调用大多数都是阻塞的
过程分析
整个过程:此进程需要对磁盘中的数据进行操作,则会向内核发起一个系统调用,然后此进程,将会被切换出去,
此进程会被挂起或者进入睡眠状态,也叫不可中 断的睡眠,因为数据还没有得到,只有等到系统调用的结果完成后,
则进程会被唤醒,继续接下来的操作,从系统调用的开始到系统调用结束经过的步骤:
①进程向内核发起一个系统调用,
②内核接收到系统调用,知道是对文件的请求,于是告诉磁盘,把文件读取出来
③磁盘接收到来着内核的命令后,把文件载入到内核的内存空间里面
④内核的内存空间接收到数据之后,把数据copy到用户进程的内存空间(此过程是I/O发生的地方)
⑤进程内存空间得到数据后,给内核发送通知
⑥内核把接收到的通知回复给进程,此过程为唤醒进程,然后进程得到数据,进行下一步操作
2.1 阻塞
是指调用结果返回之前,当前线程会被挂起(线程进入睡眠状态) 函数只有在得到结果之后,才会返回,才能继续执行
阻塞I/O系统怎么通知进程?
I/O 完成后, 系统直接通知进程, 则进程被唤醒
第一阶段是指磁盘把数据装载到内核的内存中空间中
第二阶段是指内核的内存空间的数据copy到用户的内存空间 (这个才是真实I/O操作)
2.2 非阻塞
非阻塞:进程发起I/O调用,I/O自己知道需过一段时间完成,就立即通知进程进行别的操作,则为非阻塞I/O
非阻塞I/O,系统怎么通知进程?
每隔一段时间,问内核数据是否准备完成,系统完成后,则进程获取数据,继续执行(此过程也称盲等待)
缺点: 无法处理多个I/O,比如用户打开文件,ctrl+C想终止这个操作,是无法停掉的
第一阶段是指磁盘把数据装载到内核的内存中空间中
第二阶段是指内核的内存空间的数据copy到用户的内存空间 (这个才是真实I/O操作)
2.3 I/O多路复用 select
为什么要用I/O多路复用
某个进程阻塞多个io上 ,一个进程即要等待从键盘输入信息, 另一个准备从硬盘装入信息
比如通过read这样的命令, 调用了来个io操作,一个io完成了,一个io没有完成, 阻塞着键盘io,磁盘io完成了 ,
这个进程也是不能响应, 因为键盘io还没有完成,还在阻塞着 , 这个进程还在睡眠状态 ,这个时候怎么办 ?
由此需要I/O多路复用。
执行过程
以后进程在调用io的时候, 不是直接调用io的功能,在系统内核中, 新增了一个系统调用, 帮助进程监控多个io,
一旦一个进程需要系统调用的时候, 向内核的一个特殊的系统调用,发起申请时,这个进程会被阻塞在这个复用器的调用上,
所以复用这个功能会监控这些io操作,任何一个io完成了,它都会告诉进程,其中某个io完成,如果进程依赖某个io操作,
那么这个时候,进程就可以继续后面的操作. 能够帮组进程监控这些io的工具叫做io复用器
Linux中 I/O 复用器
select: 就是一种实现,进程需要调用的时候,把请求发送给select ,可以发起多个,但是最多只能支持1024个,先天性的限制
poll: 没有限制,但是多余1024个性能会下降
所以早期的apache 本身prefork mpm模型,主进程在接受多个用户请求的时候,在线请求数超过1024个,就不工作了.
那么io复用会比前俩种好吗?
本来进程和系统内核直接沟通的 ,在中间加一个i/o复用select, 如果是传话,找人传话,那么这个传话最后会是什么样的呢?
虽然解决了多个系统调用的问题,多路io复用本身的后半段依然是阻塞的,阻塞在select 上, 而不是阻塞在系统调用上,
但是他第二段仍然是阻塞的,由于要扫描所有多个io操作, 多了一个处理机制,性能未必上升, 性能上也许不会有太大的改观
第一阶段是指磁盘把数据装载到内核的内存中空间中
第二阶段是指内核的内存空间的数据copy到用户的内存空间 (这个才是真实I/O操作)
2.4 事件驱动
进程发起调用,通过回调函数, 内核会记住是那个进程申请的,一旦第一段完成了,就可以向这个进程发起通知,
这样第一段就是非阻塞的,进程不需要盲等了, 但是第二段依然是阻塞的
事件驱动机制(event-driven)
正是由于事件驱动机制 ,才能同时相应多个请求的
比如: 一个web服务器. 一个进程响应多个用户请求
缺陷: 第二段仍然是阻塞的
俩种机制
如果一个事件通知一个进程,进程正在忙, 进程没有听见, 这个怎么办?
水平触发机制: 内核通知进程来读取数据,进程没来读取数据,内核需要一次一次的通知进程
边缘触发机制: 内核只通知一次让进程来取数据,进程在超时时间内,随时可以来取数据, 把这个事件信息状态发给进程,好比发个短息给进程,
nginx
nginx默认采用了边缘触发驱动机制
第一阶段是指磁盘把数据装载到内核的内存中空间中
第二阶段是指内核的内存空间的数据copy到用户的内存空间 (这个才是真实I/O操作)
2.5 异步AIO
无论第一第二段, 不再向系统调用提出任何反馈, 只有数据完全复制到服务进程内存中后, 才向服务进程返回ok的信息,其它时间,
进程可以随意做自己的事情,直到内核通知ok信息
注意: 只在文件中可以实现AIO, 网络异步IO 不可能实现
nginx:
nginxfile IO 文件异步请求的
一个进程响应N个请求
静态文件界别: 支持sendfile
避免浪费复制时间: mmap 支持内存映射,内核内存复制到进程内存这个过程, 不需要复制了, 直接映射到进程内存中
支持边缘触发
支持异步io
解决了c10k的问题
c10k : 有一万个同时的并发连接
c100k: 你懂得
第一阶段是指磁盘把数据装载到内核的内存中空间中
第二阶段是指内核的内存空间的数据copy到用户的内存空间 (这个才是真实I/O操作)
前四种I/O模型属于同步操作,最后一个AIO则属于异步操作
2.6 五种模型比较
同步阻塞
俩段都是阻塞的,所有数据准备完成后,才响应
同步非阻塞
磁盘从磁盘复制到内核内存中的时候, 不停询问内核数据是否准备完成. 盲等
性能有可能更差 ,看上去他可以做别的事情了, 但是其实他在不停的循环.
但还是有一定的灵活性的
缺点: 无法处理多个I/O,比如用户打开文件,ctrl+C想终止这个操作,是无法停掉的
同步IO
如果第二段是阻塞的 ,代表是同步的
第一种,第二种,io复用,事件驱动,都是同步的.
异步IO
内核后台自己处理 ,把大量时间拿来处理用户请求。
下面看下tomcat NIO的请求流程。
这里先来说下用户态和内核态,直白来讲,如果线程执行的是用户代码,当前线程处在用户态,如果线程执行的是内核里面的代码,当前线程处在内核态。更深层来讲,操作系统为代码所处的特权级别分了4个级别。不过现代操作系统只用到了0和3两个级别。0和3的切换就是用户态和内核态的切换。更详细的可参照《深入理解计算机操作系统》。I/O复用模型,是同步非阻塞,这里的非阻塞是指I/O读写,对应的是recvfrom操作,因为数据报文已经准备好,无需阻塞。说它是同步,是因为,这个执行是在一个线程里面执行的。有时候,还会说它又是阻塞的,实际上是指阻塞在select上面,必须等到读就绪、写就绪等网络事件。有时候我们又说I/O复用是多路复用,这里的多路是指N个连接,每一个连接对应一个channel,或者说多路就是多个channel。复用,是指多个连接复用了一个线程或者少量线程(在Tomcat中是Math.min(2,Runtime.getRuntime().availableProcessors()))。
上面提到的网络事件有连接就绪,接收就绪,读就绪,写就绪四个网络事件。I/O复用主要是通过Selector复用器来实现的,可以结合下面这个图理解上面的叙述。
Selector图解.png
二、TOMCAT对IO模型的支持
tomcat支持IO类型图.png
tomcat从6以后开始支持NIO模型,实现是基于JDK的java.nio包。这里可以看到对read body 和response body是Blocking的。关于这点在第6.3节源代码阅读有重点介绍。
三、TOMCAT中NIO的配置与使用
在Connector节点配置protocol="org.apache.coyote.http11.Http11NioProtocol",Http11NioProtocol协议下默认最大连接数是10000,也可以重新修改maxConnections的值,同时我们可以设置最大线程数maxThreads,这里设置的最大线程数就是Excutor的线程池的大小。在BIO模式下实际上是没有maxConnections,即使配置也不会生效,BIO模式下的maxConnections是保持跟maxThreads大小一致,因为它是一请求一线程模式。
四、NioEndpoint组件关系图解读
tomcatnio组成.png
我们要理解tomcat的nio最主要就是对NioEndpoint的理解。它一共包含LimitLatch、Acceptor、Poller、SocketProcessor、Excutor5个部分。LimitLatch是连接控制器,它负责维护连接数的计算,nio模式下默认是10000,达到这个阈值后,就会拒绝连接请求。Acceptor负责接收连接,默认是1个线程来执行,将请求的事件注册到事件列表。有Poller来负责轮询,Poller线程数量是cpu的核数Math.min(2,Runtime.getRuntime().availableProcessors())。由Poller将就绪的事件生成SocketProcessor同时交给Excutor去执行。Excutor线程池的大小就是我们在Connector节点配置的maxThreads的值。在Excutor的线程中,会完成从socket中读取http request,解析成HttpServletRequest对象,分派到相应的servlet并完成逻辑,然后将response通过socket发回client。在从socket中读数据和往socket中写数据的过程,并没有像典型的非阻塞的NIO的那样,注册OP_READ或OP_WRITE事件到主Selector,而是直接通过socket完成读写,这时是阻塞完成的,但是在timeout控制上,使用了NIO的Selector机制,但是这个Selector并不是Poller线程维护的主Selector,而是BlockPoller线程中维护的Selector,称之为辅Selector。详细源代码可以参照 第6.3节。
五、NioEndpoint执行序列图
tomcatnio序列图.png
在下一小节NioEndpoint源码解读中我们将对步骤1-步骤11依次找到对应的代码来说明。
六、NioEndpoint源码解读
6.1、初始化
无论是BIO还是NIO,开始都会初始化连接限制,不可能无限增大,NIO模式下默认是10000。
public void startInternal() throws Exception {
if (!running) {
//省略代码...
initializeConnectionLatch();
//省略代码...
}
}
protected LimitLatch initializeConnectionLatch() {
if (maxConnections==-1) return null;
if (connectionLimitLatch==null) {
connectionLimitLatch = new LimitLatch(getMaxConnections());
}
return connectionLimitLatch;
}
6.2、步骤解读
下面我们着重叙述跟NIO相关的流程,共分为11个步骤,分别对应上面序列图中的步骤。
步骤1:绑定IP地址及端口,将ServerSocketChannel设置为阻塞。
这里为什么要设置成阻塞呢,我们一直都在说非阻塞。Tomcat的设计初衷主要是为了操作方便。这样这里就跟BIO模式下一样了。只不过在BIO下这里返回的是Socket,NIO下这里返回的是SocketChannel。
public void bind() throws Exception {
//省略代码...
serverSock.socket().bind(addr,getBacklog());
serverSock.configureBlocking(true);
//省略代码...
selectorPool.open();
}
步骤2:启动接收线程
public void startInternal() throws Exception {
if (!running) {
//省略代码...
startAcceptorThreads();
}
}
//这个方法实际是在它的超类AbstractEndpoint里面
protected final void startAcceptorThreads() {
int count = getAcceptorThreadCount();
acceptors = new Acceptor[count];
for (int i = 0; i < count; i++) {
acceptors[i] = createAcceptor();
Thread t = new Thread(acceptors[i], getName() + "-Acceptor-" + i);
t.setPriority(getAcceptorThreadPriority());
t.setDaemon(getDaemon());
t.start();
}
}
步骤3:ServerSocketChannel.accept()接收新连接
protected class Acceptor extends AbstractEndpoint.Acceptor {
@Override
public void run() {
while (running) {
try {
//省略代码...
SocketChannel socket = null;
try {
socket = serverSock.accept();//接收新连接
} catch (IOException ioe) {
//省略代码...
throw ioe;
}
//省略代码...
if (running && !paused) {
if (!setSocketOptions(socket)) {
//省略代码...
}
} else {
//省略代码...
}
} catch (SocketTimeoutException sx) {
} catch (IOException x) {
//省略代码...
} catch (OutOfMemoryError oom) {
//省略代码...
} catch (Throwable t) {
//省略代码...
}
}
}
}
步骤4:将接收到的链接通道设置为非阻塞
步骤5:构造NioChannel对象
步骤6:register注册到轮询线程
protected boolean setSocketOptions(SocketChannel socket) {
try {
socket.configureBlocking(false);//将连接通道设置为非阻塞
Socket sock = socket.socket();
socketProperties.setProperties(sock);
NioChannel channel = nioChannels.poll();//构造NioChannel对象
//省略代码...
getPoller0().register(channel);//register注册到轮询线程
} catch (Throwable t) {
//省略代码...
}
//省略代码...
}
步骤7:构造PollerEvent,并添加到事件队列
protected ConcurrentLinkedQueue<Runnable> events = new ConcurrentLinkedQueue<Runnable>();
public void register(final NioChannel socket)
{
//省略代码...
PollerEvent r = eventCache.poll();
//省略代码...
addEvent(r);
}
步骤8:启动轮询线程
public void startInternal() throws Exception {
if (!running) {
//省略代码...
// Start poller threads
pollers = new Poller[getPollerThreadCount()];
for (int i=0; i<pollers.length; i++) {
pollers[i] = new Poller();
Thread pollerThread = new Thread(pollers[i], getName() + "-ClientPoller-"+i);
pollerThread.setPriority(threadPriority);
pollerThread.setDaemon(true);
pollerThread.start();
}
//省略代码...
}
}
步骤9:取出队列中新增的PollerEvent并注册到Selector
public static class PollerEvent implements Runnable {
//省略代码...
@Override
public void run() {
if ( interestOps == OP_REGISTER ) {
try {
socket.getIOChannel().register(socket.getPoller().getSelector(), SelectionKey.OP_READ, key);
} catch (Exception x) {
log.error("", x);
}
} else {
//省略代码...
}//end if
}//run
//省略代码...
}
步骤10:Selector.select()
public void run() {
// Loop until destroy() is called
while (true) {
try {
//省略代码...
try {
if ( !close ) {
if (wakeupCounter.getAndSet(-1) > 0) {
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
}
//省略代码...
}
//省略代码...
} catch ( NullPointerException x ) {
//省略代码...
} catch ( CancelledKeyException x ) {
//省略代码...
} catch (Throwable x) {
//省略代码...
}
//省略代码...
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
KeyAttachment attachment = (KeyAttachment)sk.attachment();
if (attachment == null) {
iterator.remove();
} else {
attachment.access();
iterator.remove();
processKey(sk, attachment);//此方法跟下去就是把SocketProcessor交给Excutor去执行
}
}//while
//省略代码...
} catch (OutOfMemoryError oom) {
//省略代码...
}
}//while
//省略代码...
}
步骤11:根据选择的SelectionKey构造SocketProcessor提交到请求处理线程
public boolean processSocket(NioChannel socket, SocketStatus status, boolean dispatch) {
try {
//省略代码...
SocketProcessor sc = processorCache.poll();
if ( sc == null ) sc = new SocketProcessor(socket,status);
else sc.reset(socket,status);
if ( dispatch && getExecutor()!=null ) getExecutor().execute(sc);
else sc.run();
} catch (RejectedExecutionException rx) {
//省略代码...
} catch (Throwable t) {
//省略代码...
}
//省略代码...
}
6.3、NioBlockingSelector和BlockPoller介绍
上面的序列图有个地方我没有描述,就是NioSelectorPool这个内部类,是因为在整体理解tomcat的nio上面在序列图里面不包括它更好理解。在有了上面的基础后,我们在来说下NioSelectorPool这个类,对更深层了解Tomcat的NIO一定要知道它的作用。NioEndpoint对象中维护了一个NioSelecPool对象,这个NioSelectorPool中又维护了一个BlockPoller线程,这个线程就是基于辅Selector进行NIO的逻辑。以执行servlet后,得到response,往socket中写数据为例,最终写的过程调用NioBlockingSelector的write方法。代码如下:
public int write(ByteBuffer buf, NioChannel socket, long writeTimeout,MutableInteger lastWrite) throws IOException {
SelectionKey key = socket.getIOChannel().keyFor(socket.getPoller().getSelector());
if ( key == null ) throw new IOException("Key no longer registered");
KeyAttachment att = (KeyAttachment) key.attachment();
int written = 0;
boolean timedout = false;
int keycount = 1; //assume we can write
long time = System.currentTimeMillis(); //start the timeout timer
try {
while ( (!timedout) && buf.hasRemaining()) {
if (keycount > 0) { //only write if we were registered for a write
//直接往socket中写数据
int cnt = socket.write(buf); //write the data
lastWrite.set(cnt);
if (cnt == -1)
throw new EOFException();
written += cnt;
//写数据成功,直接进入下一次循环,继续写
if (cnt > 0) {
time = System.currentTimeMillis(); //reset our timeout timer
continue; //we successfully wrote, try again without a selector
}
}
//如果写数据返回值cnt等于0,通常是网络不稳定造成的写数据失败
try {
//开始一个倒数计数器
if ( att.getWriteLatch()==null || att.getWriteLatch().getCount()==0) att.startWriteLatch(1);
//将socket注册到辅Selector,这里poller就是BlockSelector线程
poller.add(att,SelectionKey.OP_WRITE);
//阻塞,直至超时时间唤醒,或者在还没有达到超时时间,在BlockSelector中唤醒
att.awaitWriteLatch(writeTimeout,TimeUnit.MILLISECONDS);
}catch (InterruptedException ignore) {
Thread.interrupted();
}
if ( att.getWriteLatch()!=null && att.getWriteLatch().getCount()> 0) {
keycount = 0;
}else {
//还没超时就唤醒,说明网络状态恢复,继续下一次循环,完成写socket
keycount = 1;
att.resetWriteLatch();
}
if (writeTimeout > 0 && (keycount == 0))
timedout = (System.currentTimeMillis() - time) >= writeTimeout;
} //while
if (timedout)
throw new SocketTimeoutException();
} finally {
poller.remove(att,SelectionKey.OP_WRITE);
if (timedout && key != null) {
poller.cancelKey(socket, key);
}
}
return written;
}
也就是说当socket.write()返回0时,说明网络状态不稳定,这时将socket注册OP_WRITE事件到辅Selector,由BlockPoller线程不断轮询这个辅Selector,直到发现这个socket的写状态恢复了,通过那个倒数计数器,通知Worker线程继续写socket动作。看一下BlockSelector线程的代码逻辑:
public void run() {
while (run) {
try {
......
Iterator iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (run && iterator != null && iterator.hasNext()) {
SelectionKey sk = (SelectionKey) iterator.next();
KeyAttachment attachment = (KeyAttachment)sk.attachment();
try {
attachment.access();
iterator.remove(); ;
sk.interestOps(sk.interestOps() & (~sk.readyOps()));
if ( sk.isReadable() ) {
countDown(attachment.getReadLatch());
}
//发现socket可写状态恢复,将倒数计数器置位,通知Worker线程继续
if (sk.isWritable()) {
countDown(attachment.getWriteLatch());
}
}catch (CancelledKeyException ckx) {
if (sk!=null) sk.cancel();
countDown(attachment.getReadLatch());
countDown(attachment.getWriteLatch());
}
}//while
}catch ( Throwable t ) {
log.error("",t);
}
}
events.clear();
try {
selector.selectNow();//cancel all remaining keys
}catch( Exception ignore ) {
if (log.isDebugEnabled())log.debug("",ignore);
}
}
使用这个辅Selector主要是减少线程间的切换,同时还可减轻主Selector的负担。
七、关于性能
下面这份报告是我们压测的一个结果,跟想象的是不是不太一样?几乎没有差别,实际上NIO优化的是I/O的读写,如果瓶颈不在这里的话,比如传输字节数很小的情况下,BIO和NIO实际上是没有差别的。NIO的优势更在于用少量的线程hold住大量的连接。还有一点,我们在压测的过程中,遇到在NIO模式下刚开始的一小段时间内容,会有错误,这是因为一般的压测工具是基于一种长连接,也就是说比如模拟1000并发,那么同时建立1000个连接,下一时刻再发送请求就是基于先前的这1000个连接来发送,还有TOMCAT的NIO处理是有POLLER线程来接管的,它的线程数一般等于CPU的核数,如果一瞬间有大量并发过来,POLLER也会顿时处理不过来。
压测1.jpeg
压测2.jpeg
八、总结
NIO只是优化了网络IO的读写,如果系统的瓶颈不在这里,比如每次读取的字节说都是500b,那么BIO和NIO在性能上没有区别。NIO模式是最大化压榨CPU,把时间片都更好利用起来。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源如内存,有关线程资源可参照这篇文章《一台java服务器可以跑多少个线程》。因此,使用的线程越少越好。而I/O复用模型正是利用少量的线程来管理大量的连接。在对于维护大量长连接的应用里面更适合用基于I/O复用模型NIO,比如web qq这样的应用。所以我们要清楚系统的瓶颈是I/O还是CPU的计算