tomcat NIO2是基于java jdk nio2实现的,想要弄明白tomcat的实现,我们必须要理解jdk nio2的实现原理
异步IO
异步IO对相对同步IO来说的,我们平时用的到无论是阻塞io还是非阻塞io,比如select,pool,epoll,读写io等都是同步io,应用在知道读事件后,是我们的用户线程真正去读io数据,即从内核态的缓冲区copy到用户态的,这个copy动作是用户态线程做的,而且需要等这个copy动作完成才能继续往下执行。
而异步IO是这个读io,即copy的动作是内核帮你完成的,用户线程接收到的事件是copy完成事件,即数据已经帮你copy好了在你指定的缓冲buffer里,你直接拿来用就可以了。
linux 的异步io只支持对直接io,即direct_io,就是读写都不经过操作系统的高速缓存,这类一般都是数据库使用的,比如mysql的数据页缓存是用的direct_io,节省了内核的缓存。
如果是非direct_io 要想在linux实现异步,目前都是通过用户态的线程池来模拟的,比如jdk提供的异步io就是通过用户态的线程池和epoll来实现的,需要注意的是具体的读写操作还是由用户态的线程来完成。
JDK NIO2
jdk nio2 linux下是通过epoll来模拟异步io的,对应的实现是LinuxAsynchronousChannelProvider
Tomcat NIO2
NIO2 是相对http1 nio说的,nio2是基于jdk nio2版本实现的网络io模型
tomcat nio2 是tomcat7之后支持的,但不是默认的网络模型,通过connector配置
protocol="org.apache.coyote.http11.Http11Nio2Protocol" 来指定使用nio2网络模型
tomcat nio2 的网络初始化主要是NIO2Endpoint2,初始化如下:
public void bind() throws Exception {
// Create worker collection
// Tomcat 建议nio2的 protocol不用tomcat提供的executor,需要独立创建一个。
if (getExecutor() == null) {
createExecutor();
}
//jdk nio2 需要的线程池,返回的是一个EPollPort的实现
if (getExecutor() instanceof ExecutorService) {
threadGroup = AsynchronousChannelGroup.withThreadPool((ExecutorService) getExecutor());
}
// AsynchronousChannelGroup needs exclusive access to its executor service
if (!internalExecutor) {
log.warn(sm.getString("endpoint.nio2.exclusiveExecutor"));
}
serverSock = AsynchronousServerSocketChannel.open(threadGroup);
socketProperties.setProperties(serverSock);
InetSocketAddress addr = new InetSocketAddress(getAddress(), getPortWithOffset());
serverSock.bind(addr, getAcceptCount());
// Initialize SSL if needed
initialiseSsl();
}
上面的代码核心是启动一个epoll的线程,来监听epoll并处理事件,如果是io事件,则执行完io操作后,提交一个任务到线程池异步化,当前线程继续处理其他的事件。
这里我们可以看出,默认是一个线程来处理所有的io事件的,包含accept链接,io读写,优秀的程序员一看这里存在瓶颈,也不符合reactor模型的思想哈,正是这个原因,jdk提供来一个参数:
sun.nio.ch.internalThreadPoolSize
来指定这个线程数,默认是1,多个线程时,只会由一个线程阻塞在epollwait上,其他的线程阻塞在内部的queue上。
说完了上面的epoll io 事件的原理,下面就是创建server socket
上面AsynchronousServerSocketChannel.open创建一个异步的UnixAsynchronousServerSocketChannelImpl,并配置为非阻塞的,核心代码如下:
UnixAsynchronousServerSocketChannelImpl(Port port)
throws IOException
{
super(port);
try {
//配置为非阻塞,不像nio1
IOUtil.configureBlocking(fd, false);
} catch (IOException x) {
nd.close(fd); // prevent leak
throw x;
}
this.port = port;
this.fdVal = IOUtil.fdVal(fd);
// add mapping from file descriptor to this channel
port.register(fdVal, this);
}
NIO2 Acceptor
nio2 的acceptor 不是一个独立的线程,是通过上面创建的线程池来运行的,只是一个特效的task,代码如下:
@Override
protected void startAcceptorThread() {
// Instead of starting a real acceptor thread, this will instead call
// an asynchronous accept operation
if (acceptor == null) {
acceptor = new Nio2Acceptor(this);
acceptor.setThreadName(getName() + "-Acceptor");
}
acceptor.state = AcceptorState.RUNNING;
//直接执行acceptor任务,就是管他有没有链接过来,先accept下再说,万一有链接来了呢,不就省了一次注册事件的系统调用不。
getExecutor().execute(acceptor);
}
上面说的特殊是指Acceptor实现了异步IO事件的CompletionHandler,来接收accept是否完成的事件,我们先看下Nio2Acceptor的run()方法实现:
@Override
public void run() {
// The initial accept will be called in a separate utility thread
if (!isPaused()) {
//if we have reached max connections, wait
try {
//这里是还是计算链接的个数是否达到配置的阀值,是否要阻塞
countUpOrAwaitConnection();
} catch (InterruptedException e) {
// Ignore
}
if (!isPaused()) {
// Note: as a special behavior, the completion handler for accept is
// always called in a separate thread.
//这里的意思是accept操作的线程和回调的线程是两个独立的线程,无论是通过一次线程池主动accept成功回调还是通过epoll线程执行accept的回调,都是异步的。
serverSock.accept(null, this);
} else {
state = AcceptorState.PAUSED;
}
} else {
state = AcceptorState.PAUSED;
}
}
熟悉tomcat nio1的同学,肯定就知道重写了原理acceptor的run方法,这里只有accept了,没有接收完新建立的链接去读数据的代码,因为是异步io的模式,接受新来的链接也是通过异步模拟的,会通过线程池异步回调这个Nio2Acceptor的completed方法,如果接受成功的话,这个后面分析,这里先分析accept 链接的过程。
serverSock.accept(null, this)内部的核心代码在UnixAsynchronousServerSocketChannelImpl的implAccept方法,如下:
Future implAccept(Object att,
CompletionHandler handler)
{
// complete immediately if channel is closed
if (!isOpen()) {
Throwable e = new ClosedChannelException();
if (handler == null) {
return CompletedFuture.withFailure(e);
} else {
Invoker.invoke(this, handler, att, null, e);
return null;
}
}
if (localAddress == null)
throw new NotYetBoundException();
// cancel was invoked with pending accept so connection may have been
// dropped.
if (isAcceptKilled())
throw new RuntimeException("Accept not allowed due cancellation");
// check and set flag to prevent concurrent accepting
// 防止并行accept
if (!accepting.compareAndSet(false, true))
throw new AcceptPendingException();
// attempt accept
FileDescriptor newfd = new FileDescriptor();
InetSocketAddress[] isaa = new InetSocketAddress[1];
Throwable exc = null;
try {
begin();
//accept链接
int n = accept(this.fd, newfd, isaa);
if (n == IOStatus.UNAVAILABLE) {
// 这个时候不一定有新链接过来,所有需要处理没有链接的情况
// need calling context when there is security manager as
// permission check may be done in a different thread without
// any application call frames on the stack
PendingFuture result = null;
synchronized (updateLock) {
if (handler == null) {
this.acceptHandler = null;
result = new PendingFuture(this);
this.acceptFuture = result;
} else {
this.acceptHandler = handler;
this.acceptAttachment = att;
}
this.acceptAcc = (System.getSecurityManager() == null) ?
null : AccessController.getContext();
this.acceptPending = true;
}
// register for connections
port.startPoll(fdVal, Net.POLLIN);
return result;
}
} catch (Throwable x) {
// accept failed
if (x instanceof ClosedChannelException)
x = new AsynchronousCloseException();
exc = x;
} finally {
end();
}
AsynchronousSocketChannel child = null;
if (exc == null) {
// connection accepted immediately
try {
child = finishAccept(newfd, isaa[0], null);
} catch (Throwable x) {
exc = x;
}
}
// re-enable accepting before invoking handler
enableAccept();
if (handler == null) {
return CompletedFuture.withResult(child, exc);
} else {
//异步回调handler的事件,handler就是我们的acceptor
Invoker.invokeIndirectly(this, handler, att, child, exc);
return null;
}
}
上面这么大一段代码,主要干了三件事情:
- 检查是否存在并行accept,即多个线程accept,只能同时一个线程accept。
- 没有链接时,需要在epoll上重新注册读事件,本次accept任务就算结束了,待链接来时会重新去accept。
- 有链接时,完成链接的建立,同时异步回调accept的complete方法,通过接受链接完成,进入后续的处理。
建链完成 Complete
上面完成了新链接的accept操作后,就通过异步任务的方式即当前线程创建一个task提交到线程池,回调Nio2Acceptor对应的complete方法,complete方法的代码如下:
public void completed(AsynchronousSocketChannel socket,
Void attachment) {
// Successful accept, reset the error delay
errorDelay = 0;
// Continue processing the socket on the current thread
// Configure the socket
if (isRunning() && !isPaused()) {
//没有链接限制时,当前回调的线程继续去accept,如果还有链接,则同样是通过另个线程来执行回调处理。
if (getMaxConnections() == -1) {
serverSock.accept(null, this);
} else {
// Accept again on a new thread since countUpOrAwaitConnection may block
// 有限制的hua
getExecutor().execute(this);
}
//为读链接上的数据做准备,并开始解析协议内容。
if (!setSocketOptions(socket)) {
closeSocket(socket);
}
} else {
if (isRunning()) {
state = AcceptorState.PAUSED;
}
destroySocket(socket);
}
}
completed 方法首先判断是否有链接限制,如果没有,则直接继续accept,如果有链接,则会做完accept后,继续处理当前链接,因为accept完成后,同样提交一个任务异步处理接受的链接。
如果是️限制的,默认是有限制的,则是提交一个任务,因为如果链接数达到限制了会阻塞住执行任务的线程,我们不能阻塞住处理当前链接的线程,否则就出现客户端发起了请求等不到结果。
还有一点需要注意的是accept事件的回调一定是异步的,这个和读写IO事件不一样,下面可以看到。
setSocketOptions 方法里面会为创建的channel创建一个Nio2SocketWrapper,该wrapper有一个内部readCompletionHandler和writeCompletionHandler,对应一个链接上读和写的异步io操作的回调处理者,setSocketOptions方法如下:
protected boolean setSocketOptions(AsynchronousSocketChannel socket) {
Nio2SocketWrapper socketWrapper = null;
try {
// Allocate channel and wrapper
Nio2Channel channel = null;
if (nioChannels != null) {
channel = nioChannels.pop();
}
if (channel == null) {
SocketBufferHandler bufhandler = new SocketBufferHandler(
socketProperties.getAppReadBufSize(),
socketProperties.getAppWriteBufSize(),
socketProperties.getDirectBuffer());
if (isSSLEnabled()) {
channel = new SecureNio2Channel(bufhandler, this);
} else {
channel = new Nio2Channel(bufhandler);
}
}
// 异步读写wrapper
Nio2SocketWrapper newWrapper = new Nio2SocketWrapper(channel, this);
channel.reset(socket, newWrapper);
connections.put(socket, newWrapper);
socketWrapper = newWrapper;
// Set socket properties
socketProperties.setProperties(socket);
socketWrapper.setReadTimeout(getConnectionTimeout());
socketWrapper.setWriteTimeout(getConnectionTimeout());
socketWrapper.setKeepAliveLeft(Nio2Endpoint.this.getMaxKeepAliveRequests());
// Continue processing on the same thread as the acceptor is async
// 后面对http协议解析,并处理请求和同步io是一样的,false 参数表示用当前线程继续执行,不异步,和nio 一不一样。
return processSocket(socketWrapper, SocketEvent.OPEN_READ, false);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
log.error(sm.getString("endpoint.socketOptionsError"), t);
if (socketWrapper == null) {
destroySocket(socket);
}
}
// Tell to close the socket if needed
return false;
}
上面processSocket会开始解析http协议,解析前需要把整个包的内容读出来,读完后会回调这个readCompletionHandler的complete方法,complete的代码如下:
@Override
public void completed(Integer nBytes, ByteBuffer attachment) {
if (log.isDebugEnabled()) {
log.debug("Socket: [" + Nio2SocketWrapper.this + "], Interest: [" + readInterest + "]");
}
readNotify = false;
synchronized (readCompletionHandler) {
if (nBytes.intValue() < 0) {
failed(new EOFException(), attachment);
} else {
//没有读到数据时,readInterest为true,而且不是内部调,则需要通知process去 开始处理这个请求
if (readInterest && !Nio2Endpoint.isInline()) {
readNotify = true;
} else {
// Release here since there will be no
// notify/dispatch to do the release.
readPending.release();
}
readInterest = false;
}
}
if (readNotify) {
//读到数据,继续执行解析http协议以及后面的处理。
getEndpoint().processSocket(Nio2SocketWrapper.this, SocketEvent.OPEN_READ, false);
}
}
这是正常的流程,不知道大家有没有注意到,nio2是链接accept后,直接去读,并不是先注册一个读事件,等内核发现可读时再回调通知去真正的读,这是nio2模型的一个优化,即少一次epoll注册事件的系统调用,所有就存在有可能,这次是读不到数据的,读不到的时,而且有超时时间的话,会提交一个读超时的定时任务到超时线程池,同时向epoll注册读事件,等超时时间到后,检查是否读到,没有就直接超时,回调对应的failed方法。
这里还有一个需要注意的地方,就是我们从接受一个新的链接完成时是异步的回调Nio2Acceptor的complete方法的,然后一直由这个线程执行这个链接上的读操作,会涉及到io操作。因为要把链接上的数据从内核态读到用户态,这个过程也是当前线程执行的,读完以后回调呢,是异步的还是继续由当前线程执行做直接回调,答案是直接回调,还是有当前线程执行,原因是jdk nio2 的异步回调时会给执行回调任务的线程绑定一个group,就是serversocket,代码如下:
private Runnable bindToGroup(final Runnable task) {
final AsynchronousChannelGroupImpl thisGroup = this;
return new Runnable() {
public void run() {
//该当前线程的上下文即threadlocal帮定group
Invoker.bindToGroup(thisGroup);
task.run();
}
};
}
Invoker.bindToGroup(thisGroup)的代码如下:
static void bindToGroup(AsynchronousChannelGroupImpl group) {
myGroupAndInvokeCount.set(new GroupAndInvokeCount(group));
}
上面绑定这个group是干啥用的呢,就是线程池的任务在执行io操作完成时,回调对应的handler的complete方法时,是有当前线程执行还是通过异步回调就是根据这个判断的,部分核心代码如下:
Invoker.GroupAndInvokeCount myGroupAndInvokeCount = null;
boolean invokeDirect = false;
boolean attemptRead = false;
if (!disableSynchronousRead) {
if (handler == null) {
attemptRead = true;
} else {
//myGroupAndInvokeCount 是当前线程绑定的group的
myGroupAndInvokeCount = Invoker.getGroupAndInvokeCount();
invokeDirect = Invoker.mayInvokeDirect(myGroupAndInvokeCount, port);
// okay to attempt read with user thread pool
attemptRead = invokeDirect || !port.isFixedThreadPool();
}
}
disableSynchronousRead
默认是false,这个系统属性的作用等下再讲,下面判断是否直接调用的判断是:
static boolean mayInvokeDirect(GroupAndInvokeCount myGroupAndInvokeCount,
AsynchronousChannelGroupImpl group)
{
if ((myGroupAndInvokeCount != null) &&
(myGroupAndInvokeCount.group() == group) &&
(myGroupAndInvokeCount.invokeCount() < maxHandlerInvokeCount))
{
return true;
}
return false;
}
这里就是根据前面绑定的myGroupAndInvokeCount来判断的,除了绑定了线程上下文group,一个监听端口对应一个全局的group,还有一个条件是回调的次数限制即maxHandlerInvokeCount
,默认是16次,通过sun.nio.ch.maxCompletionHandlersOnStack
指定。意思就是防止回调次数过多,线程栈溢出。
好了上面提到的系统属性disableSynchronousRead
,这个是啥意思呢,就是如果为true的,那线程池的线程都不执行具体的io读写操作,都由epoll线程来执行,这样的就是读要向epoll注册一次事件,由epoll线程来回调。
写了这么多,写还没有提及,不过和读差不多,有时间单独起一偏,文章不能写太长,下面上一章图,对nio2 的线程模型做个总结:
上了图后,再提一点,分析了NIO2,那到底是用默认的NIO线程模型还是用NIO2异步的线程模型,建议用NIO2,NIO2的优势主要为:
- 注册读事件调用次数减少,系统调用开销很大需要用户态到内核态切换。
- 直接基于epoll实现,可以配置多个epoll thread,就能支持同时大量链接的请求,默认的nio模型是一个poll线程。