前言
在《从阻塞IO到io_uring: Linux IO模型的演进之路》我们从BIO开始,一路向前走到了io_uring, 在前进的路上我们始终的动机只有一个就是用更少的资源来获得更多的吞吐量。BIO的Blocking 阻塞在读数据这个地方,而从客户端发送的数据抛开路上传输的时间,也还有从路由器到网卡,然后从网卡到内存中。这个时候就需要通知内核处理了,内核唤起进程开始真正的读数据。我们的线程就一直阻塞在这里,解决这个问题,我们可以用线程池,将每一个连接都交给一个线程处理,这样就不至于同时只能处理一个请求。但随着连接的增多,这种方式的弊端就显现出来,我们不能无限制的增加线程,线程多了上下文切换成本也会显著增加。下面的图片展示了数据从网络到我们进程的过程:
连接建立之后我们的线程就等在这里, 等待数据的到来:
于是我们将目光盯上了操作系统,我们请求操作系统为我们提供这样的api,在我们接受连接之后,将连接交给操作系统进行监控,一旦对应我们感兴趣的事件满足才触发下面的程序接着运行。这也就是select调用和poll调用,但是这两个调用还要求我们保存一份连接对应fd,高并发的时候复制fd仍然是一个显著的成本。
于是Epoll出现了,Epoll的出现修复了这一点,内核里面保存了一份fd,我们只需要添加删除、修改fd。不需要自己额外再保存一份fd。但是到这里就为止了嘛,我们讨论的select、poll、 epoll 都是为网络IO而专门准备的,有一类数据库软件也对操作系统提起了需求,也引入了一组异步IO API。
但是只支持O_DIRECT模式,也就是绕过高速缓存直接读写设备,但让这个接口支持网络IO就需要付出更多的努力,Linus称其为比较丑陋的设计。于是Linux的维护者们开始了新的api设计,新的api设计基于这样的观察,随着硬件越来越快,中断驱动(interrup-driven)模式效率已经低于轮询模式(polling for completions),这也就是io_uring。
老是说这是在有限的资源下提升吞吐量的第一个答案,但这个答案的缺陷是有时候你不得不对底层网络有一些了解,才能写出性能良好的软件。毕竟Linux和Windows平台的系统api是不一样的。但好消息是现在的高级语言一般都内置了网络库,以Java为例,Java的一套网络api写起来就能在windows上和Linux通用。但NIO还是有些难用,但这其实也不是多么大的问题,这催生了像Netty这样的优秀网络矿机,它们封装了底层细节,让开发者能更容易地构建高性能应用。
那能不能再简单一些呢,我总是希望我写起来是同步的,但是用起来是异步的,同步的编写难度,异步的吞吐量。能不能不让我关心具体的网络api呢,我希望就是当我阻塞在read调用的时候,JVM能不能帮我将线程切出去。这也就是虚拟线程的引入动机。
但我们不禁思考: 有没有一种更简单的方式呢? 我们既能享受同步编程的直观与简单,又能获得异步编程的高吞吐量? 我希望当我写下stream.read()(这是伪代码)这样的阻塞代码时,JVM能够在背后悄悄地把线程资源让出去,而不是真的让它傻等,这正是Java虚拟线程的诞生的核心动机。 我们在JEP 425: Virtual Threads (Preview)中可以看到,对应的论述: "对于服务器应用来说通常需要同时处理多个独立的用户请求,一般的风格是一个请求一个线程(Thread-Per-Request)风格"。注意这里是请求,不是连接, 那么该怎么理解这个Thread-Per-Request风格呢? 简单来说,‘连接’(Connection)是网络层面的概念,代表客户端和服务器之间的TCP通道。而‘请求’(Request)是应用层(HTTP)的概念。在一个长连接(Keep-Alive)中,可以传输多个请求。‘Thread-Per-Request’风格意味着,我们为一个完整的HTTP请求(从接收报文、处理业务逻辑到发送响应的整个过程)分配一个线程,而不是为一个TCP连接分配一个线程。当这个请求处理完毕后,线程就被释放(返回线程池),即使底层的TCP连接仍然保持着。
接下来,我们将深入Tomcat的NIO处理机制,看看它是如何通过经典的Reactor模式来实现这一点的。”
一窥Tomcat的运作流程
概述
在Tomcat中我们可以看到这一点,在NioEndpoint中我们可以看到:
public void startInternal() throws Exception {
if (!running) {
running = true;
paused = false;
if (socketProperties.getProcessorCache() != 0) {
processorCache = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
socketProperties.getProcessorCache());
}
if (socketProperties.getEventCache() != 0) {
eventCache = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
socketProperties.getEventCache());
}
int actualBufferPool =
socketProperties.getActualBufferPool(isSSLEnabled() ? getSniParseLimit() * 2 : 0);
if (actualBufferPool != 0) {
nioChannels = new SynchronizedStack<>(SynchronizedStack.DEFAULT_SIZE,
actualBufferPool);
}
// Create worker collection
if (getExecutor() == null) {
createExecutor(); // 语句一
}
initializeConnectionLatch();
// Start poller thread
poller = new Poller(); // 语句二
Thread pollerThread = new Thread(poller, getName() + "-Poller");
pollerThread.setPriority(threadPriority);
pollerThread.setDaemon(true);
pollerThread.start(); // 语句三
startAcceptorThread(); // 语句四
}
}
一般的Spring Boot Web项目, 用Tomcat做Servlet容器的,会走到NioEndpoint的startInternal方法,你可以在启动Spring Boot的时候在这里打上断点来对我的话进行验证。我们重点关注语句一、二、三、四。语句一创建了一个线程池, getExecutor获取的是NioEndpoint中的成员变量executor,点进去看就会发现这个变量和方法都来自于其父类AbstractEndpoint,NioEndpoint的继承层次图如下所示:
如果你滑到NioEndpoint这个类的上方就会发现对这个类的注释:
NIO tailored thread pool, providing the following services:
专门为NIO定制的线程池,提供以下服务:
Socket acceptor thread
处理连接的线程
Socket poller thread
轮询socket线程
Worker threads pool
Worker线程池
TODO: Consider using the virtual machine's thread pool.
Acceptor
在NioEndpoint的startAcceptorThread方法,我们可以通过方法名推测,这里开启了一个处理连接的线程:
protected void startAcceptorThread() {
acceptor = new Acceptor<>(this);
String threadName = getName() + "-Acceptor";
acceptor.setThreadName(threadName);
Thread t = new Thread(acceptor, threadName);
t.setPriority(getAcceptorThreadPriority());
t.setDaemon(getDaemon());
t.start();
}
现在我们只用关注Acceptor的大致结构,看源码我们注意先看大致结构,在去考察细节,带着问题去看。不要迷失在细节里面,现在让我们来关注Acceptor的结构,Acceptor实现了Runnable,我们重点看重写的run方法即可:
@SuppressWarnings("deprecation")
@Override
public void run() {
int errorDelay = 0;
long pauseStart = 0;
try {
//
while (!stopCalled) {
// 对endpoint做状态检查
while (endpoint.isPaused() && !stopCalled) {
if (state != AcceptorState.PAUSED) {
pauseStart = System.nanoTime();
// Entered pause state
state = AcceptorState.PAUSED;
}
if ((System.nanoTime() - pauseStart) > 1_000_000) {
// Paused for more than 1ms
try {
if ((System.nanoTime() - pauseStart) > 10_000_000) {
Thread.sleep(10);
} else {
Thread.sleep(1);
}
} catch (InterruptedException e) {
// Ignore
}
}
}
if (stopCalled) {
break;
}
state = AcceptorState.RUNNING;
try {
// 判断有没有到达最大连接 语句一
endpoint.countUpOrAwaitConnection();
if (endpoint.isPaused()) {
continue;
}
U socket = null;
try {
// 接收连接 语句二
socket = endpoint.serverSocketAccept();
} catch (Exception ioe) {
// We didn't get a socket
endpoint.countDownConnection();
if (endpoint.isRunning()) {
// Introduce delay if necessary
errorDelay = handleExceptionWithDelay(errorDelay);
// re-throw
throw ioe;
} else {
break;
}
}
// Successful accept, reset the error delay
errorDelay = 0;
// Configure the socket
if (!stopCalled && !endpoint.isPaused()) {
// setSocketOptions() will hand the socket off to
// an appropriate processor if successful
// 语句三
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket);
}
} else {
endpoint.destroySocket(socket);
}
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
String msg = sm.getString("endpoint.accept.fail");
// APR specific.
// Could push this down but not sure it is worth the trouble.
if (t instanceof org.apache.tomcat.jni.Error) {
org.apache.tomcat.jni.Error e = (org.apache.tomcat.jni.Error) t;
if (e.getError() == 233) {
// Not an error on HP-UX so log as a warning
// so it can be filtered out on that platform
// See bug 50273
log.warn(msg, t);
} else {
log.error(msg, t);
}
} else {
log.error(msg, t);
}
}
}
} finally {
stopLatch.countDown();
}
state = AcceptorState.ENDED;
}
理一下,Acceptor的run逻辑,也就是状态检查,也就是countUpOrAwaitConnection()这个方法:
protected void countUpOrAwaitConnection() throws InterruptedException {
if (maxConnections==-1) {
return;
}
LimitLatch latch = connectionLimitLatch;
if (latch!=null) {
latch.countUpOrAwait();
}
}
maxConnections这个变量来自AbstractEndpoint:
private int maxConnections = 8*1024;
LimitLatch 其实也就是用来限制最大连接数的,到达8192之后,后面的连接在这里等待。如果对AQS有些不了解,可以参看《背景知识: 理解LimitLatch背后的AQS》,这里对AQS做了一个简单的介绍。
回到语句二
回到语句二也就是setSocketOptions,在写这篇文章的时候,我就在想Acceptor是怎么和Poller里面协作的,也就是Acceptor接受到的Socket对象该怎么转移给Poller,然后在Acceptor的run方法找呀没找到。后面问了下大模型,大模型告诉我是通过endpoint.setSocketOptions(socket)这行代码来产生关联的。
我反思了一下一方面是setSocketOptions这个方法名让我下意识里面认为只是设置一些参数,然后就没有去看了,其实上面有注释,还是读英文读的太少了:
setSocketOptions() will hand the socket off to an appropriate processor if successful
如果成功的话, setSocketOptions将会把Socket转移给另外一个合适的处理器。
我们来接着看setSocketOptions的逻辑:
protected boolean setSocketOptions(SocketChannel socket) {
NioSocketWrapper socketWrapper = null;
try {
// Allocate channel and wrapper
NioChannel 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 SecureNioChannel(bufhandler, this);
} else {
channel = new NioChannel(bufhandler);
}
}
NioSocketWrapper newWrapper = new NioSocketWrapper(channel, this);
//
channel.reset(socket, newWrapper);
connections.put(socket, newWrapper);
socketWrapper = newWrapper;
// Set socket properties
// Disable blocking, polling will be used
// 这里将当前的通道设置为非阻塞模式
socket.configureBlocking(false);
if (getUnixDomainSocketPath() == null) {
socketProperties.setProperties(socket.socket());
}
// 设置一些必要参数
socketWrapper.setReadTimeout(getConnectionTimeout());
socketWrapper.setWriteTimeout(getConnectionTimeout());
socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests());
poller.register(socketWrapper);
return true;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
try {
log.error(sm.getString("endpoint.socketOptionsError"), t);
} catch (Throwable tt) {
ExceptionUtils.handleThrowable(tt);
}
if (socketWrapper == null) {
destroySocket(socket);
}
}
// Tell to close the socket if needed
return false;
}
宏观上来看这段代码就是将获取的SocketChannel先包装为NioChannel,然后NioChannel包装为NioSocketWrapper ,然后将通道设置为非阻塞的,设置一些超时时间。然后将包装之后的NioSocketWrapper 交给poller。
每一次包装都是一次增强,注意这个nioChannels这个变量,这个是对NioChannel的缓存,NioChannel里面有SocketBufferHandler这个对象,我们看下SocketBufferHandler构造函数的逻辑:
public SocketBufferHandler(int readBufferSize, int writeBufferSize,
boolean direct) {
this.direct = direct;
if (direct) {
readBuffer = ByteBuffer.allocateDirect(readBufferSize);
writeBuffer = ByteBuffer.allocateDirect(writeBufferSize);
} else {
readBuffer = ByteBuffer.allocate(readBufferSize);
writeBuffer = ByteBuffer.allocate(writeBufferSize);
}
}
这里执行的是对readBuffer和writeBuffer的初始化工作,而ByteBuffer可以理解为对一个字节数组的封装,这个数组可能位于堆外,也可能位于堆内。想来nioChannels是为了复用这部分申请出来的内存。Poller中register方法的逻辑,我们单独放到Poller章节来讲。现在我们的主线是理清楚Acceptor的职能,现在我们已经能够大致对Acceptor的只能有一些了解了,限制TCP连接数,对Socket做一些参数设置之后,让后将包装之后的SocketWrapper交给Poller,也就是通过 poller.register(socketWrapper)。
Poller
我们接着来看poller.register的逻辑
// 注册对读事件感兴趣
public void register(final NioSocketWrapper socketWrapper) {
socketWrapper.interestOps(SelectionKey.OP_READ); //this is what OP_REGISTER turns into.
PollerEvent event = null;
if (eventCache != null) {
event = eventCache.pop();
}
if (event == null) {
event = new PollerEvent(socketWrapper, OP_REGISTER);
} else {
event.reset(socketWrapper, OP_REGISTER);
}
// 将socketWrapper包装为PollerEvent
addEvent(event);
}
这段其实大致的逻辑就是将SocketWrapper包装为PollerEvent,然后调用addEvent, addEvent这个方法相对简单:
private void addEvent(PollerEvent event) {
events.offer(event);
if (wakeupCounter.incrementAndGet() == 0) {
selector.wakeup();
}
}
events事实上是一个队列,这里是事实上入队操作。Poller实现了Runnable,然后我们观察run方法的实现:
@Override
public void run() {
// Loop until destroy() is called
while (true) {
boolean hasEvents = false;
try {
if (!close) {
hasEvents = events();
if (wakeupCounter.getAndSet(-1) > 0) {
// If we are here, means we have other stuff to do
// Do a non blocking select
keyCount = selector.selectNow();
} else {
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0);
}
if (close) {
events();
timeout(0, false);
try {
selector.close();
} catch (IOException ioe) {
log.error(sm.getString("endpoint.nio.selectorCloseFail"), ioe);
}
break;
}
// Either we timed out or we woke up, process events first
if (keyCount == 0) {
hasEvents = (hasEvents | events());
}
} catch (Throwable x) {
ExceptionUtils.handleThrowable(x);
log.error(sm.getString("endpoint.nio.selectorLoopError"), x);
continue;
}
// 语句一
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
// Walk through the collection of ready keys and dispatch
// any active event.
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
// Attachment may be null if another thread has called
// cancelledKey()
if (socketWrapper != null) {
processKey(sk, socketWrapper);
}
}
// Process timeouts
timeout(keyCount,hasEvents);
}
getStopLatch().countDown();
}
看源码我们也不一定要求每一行都看懂,我们带着问题去看,我们本身关心的问题在于一个连接建立之后,客户端发送数据过来之后,Tomcat是如何处理的。或者换一种问题描述,客户端传送的数据是怎么送到了我们的Controller中,我们本身在Spring Boot web项目里面观察这个过程。在语句一里面我们可以看到,这里是在不断的检查是否有就绪的事件,如果有就绪的事件就调用processKey方法开始处理:
protected void processKey(SelectionKey sk, NioSocketWrapper socketWrapper) {
try {
if (close) {
cancelledKey(sk, socketWrapper);
} else if (sk.isValid()) {
if (sk.isReadable() || sk.isWritable()) {
if (socketWrapper.getSendfileData() != null) {
processSendfile(sk, socketWrapper, false);
} else {
unreg(sk, socketWrapper, sk.readyOps());
boolean closeSocket = false;
// Read goes before write
if (sk.isReadable()) {
if (socketWrapper.readOperation != null) {
if (!socketWrapper.readOperation.process()) {
closeSocket = true;
}
} else if (socketWrapper.readBlocking) {
synchronized (socketWrapper.readLock) {
socketWrapper.readBlocking = false;
socketWrapper.readLock.notify();
}
} else if (!processSocket(socketWrapper, SocketEvent.OPEN_READ, true)) { // 语句一
closeSocket = true;
}
}
if (!closeSocket && sk.isWritable()) {
if (socketWrapper.writeOperation != null) {
if (!socketWrapper.writeOperation.process()) {
closeSocket = true;
}
} else if (socketWrapper.writeBlocking) {
synchronized (socketWrapper.writeLock) {
socketWrapper.writeBlocking = false;
socketWrapper.writeLock.notify();
}
} else if (!processSocket(socketWrapper, SocketEvent.OPEN_WRITE, true)) {
closeSocket = true;
}
}
if (closeSocket) {
cancelledKey(sk, socketWrapper);
}
}
}
} else {
// Invalid key
cancelledKey(sk, socketWrapper);
}
} catch (CancelledKeyException ckx) {
cancelledKey(sk, socketWrapper);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
log.error(sm.getString("endpoint.nio.keyProcessingError"), t);
}
}
打断点的其实会发现其实是走到了语句一这个位置:
我们接着看processSocket的逻辑即可:
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
try {
if (socketWrapper == null) {
return false;
}
SocketProcessorBase<S> sc = null;
if (processorCache != null) {
sc = processorCache.pop();
}
if (sc == null) {
sc = createSocketProcessor(socketWrapper, event);
} else {
sc.reset(socketWrapper, event);
}
Executor executor = getExecutor();
if (dispatch && executor != null) {
executor.execute(sc); // 语句一
} else {
sc.run();
}
} catch (RejectedExecutionException ree) {
getLog().warn(sm.getString("endpoint.executor.fail", socketWrapper) , ree);
return false;
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
// This means we got an OOM or similar creating a thread, or that
// the pool and its queue are full
getLog().error(sm.getString("endpoint.process.fail"), t);
return false;
}
return true;
}
最终走到了 executor.execute(sc)这一行:
sc 是NioEndpoint中SocketProcessor的实例, 我们接着来看SocketProcessor都做了些什么:
protected class SocketProcessor extends SocketProcessorBase<NioChannel> {
public SocketProcessor(SocketWrapperBase<NioChannel> socketWrapper, SocketEvent event) {
super(socketWrapper, event);
}
@Override
protected void doRun() {
/*
* Do not cache and re-use the value of socketWrapper.getSocket() in
* this method. If the socket closes the value will be updated to
* CLOSED_NIO_CHANNEL and the previous value potentially re-used for
* a new connection. That can result in a stale cached value which
* in turn can result in unintentionally closing currently active
* connections.
*/
Poller poller = NioEndpoint.this.poller;
if (poller == null) {
socketWrapper.close();
return;
}
try {
int handshake;
try {
if (socketWrapper.getSocket().isHandshakeComplete()) {
// No TLS handshaking required. Let the handler
// process this socket / event combination.
handshake = 0;
} else if (event == SocketEvent.STOP || event == SocketEvent.DISCONNECT ||
event == SocketEvent.ERROR) {
// Unable to complete the TLS handshake. Treat it as
// if the handshake failed.
handshake = -1;
} else {
handshake = socketWrapper.getSocket().handshake(event == SocketEvent.OPEN_READ, event == SocketEvent.OPEN_WRITE);
// The handshake process reads/writes from/to the
// socket. status may therefore be OPEN_WRITE once
// the handshake completes. However, the handshake
// happens when the socket is opened so the status
// must always be OPEN_READ after it completes. It
// is OK to always set this as it is only used if
// the handshake completes.
event = SocketEvent.OPEN_READ;
}
} catch (IOException x) {
handshake = -1;
if (logHandshake.isDebugEnabled()) {
logHandshake.debug(sm.getString("endpoint.err.handshake",
socketWrapper.getRemoteAddr(), Integer.toString(socketWrapper.getRemotePort())), x);
}
} catch (CancelledKeyException ckx) {
handshake = -1;
}
if (handshake == 0) {
SocketState state;
// Process the request from this socket
state = getHandler().process(socketWrapper, Objects.requireNonNullElse(event, SocketEvent.OPEN_READ));
if (state == SocketState.CLOSED) {
socketWrapper.close();
}
} else if (handshake == -1 ) {
getHandler().process(socketWrapper, SocketEvent.CONNECT_FAIL);
socketWrapper.close();
} else if (handshake == SelectionKey.OP_READ){
socketWrapper.registerReadInterest();
} else if (handshake == SelectionKey.OP_WRITE){
socketWrapper.registerWriteInterest();
}
} catch (CancelledKeyException cx) {
socketWrapper.close();
} catch (VirtualMachineError vme) {
ExceptionUtils.handleThrowable(vme);
} catch (Throwable t) {
log.error(sm.getString("endpoint.processing.fail"), t);
socketWrapper.close();
} finally {
socketWrapper = null;
event = null;
//return to cache
if (running && processorCache != null) {
processorCache.push(this);
}
}
}
}
handshake是握手的意思,所以看变量和方法命名我们可以猜到上面是在判断握手完成,握手完成之后进入下面这一行
state = getHandler().process(socketWrapper, Objects.requireNonNullElse(event, SocketEvent.OPEN_READ));
写到这里可能有同学会问啦,我们丢给线程池的是SocketProcessorBase,线程池接收的是Runnable的实例,应当执行run方法才对,的确是这样。
// 方法来自SocketProcessorBase
@Override
public final void run() {
Lock lock = socketWrapper.getLock();
lock.lock();
try {
if (socketWrapper.isClosed()) {
return;
}
doRun();
} finally {
lock.unlock();
}
}
我们到这里可以看到其实调用到子类的doRun,最后走到下面这一行:
state = getHandler().process(socketWrapper, Objects.requireNonNullElse(event, SocketEvent.OPEN_READ));
我们接着观察下面的处理逻辑:
这里走到了AbstractProtocol的ConnectionHandler中,然后这个类就看的我有点迷了,四处跳转,但是方法执行是栈模型的。我们可以在Controller中的方法打上断点来观察调用链:
最终走到了我们的控制器上面, 这些在一个栈里面看到,说明他们被相同的线程执行。也就是一个线程一个请求的由来。
Tomcat的整体运作流程
到现在我们捋一Tomcat的执行流程:
Acceptor专注处理连接,连接设置完之后交给Poller线程去管理,Poller负责管理I/O事件,Worker线程负责解析报文数据最终经过一道又一道链路传递给我们的控制器里面。原则上Selector可以管理多个事件,那为什么专门分离出来一个线程来管理IO就绪事件呢。假设我们让Acceptor也管理IO就绪事件,这样其实也不是不行,那么假设某个连接比较活跃,发送的报文率先到达,而且频率比较高。那么其他连接就绪的事件就会被晚处理,这样无疑不是我们向看到的,这也就是分离职能的原因。这也就是主从Reactor模型。
Reactor模型简介
最简单的Reactor模型是单Reactor单线程,一个线程同时处理连接就绪、IO就绪以及对应的处理。就像一家小饭馆的老板同时兼任厨子、服务员、收银员多种角色。
但是这里面也面临着一个问题就是活跃连接频繁导致读事件触发,会导致处理后面的连接有延迟。于是我们可以引入多线程,将IO就绪事件丢入线程池里面来处理。在这个模型下问题缓解了一点点:
这个模型只缓解了我们提出问题的一部分,更进一步的解决是,一个线程专门处理连接就绪,一个线程处理读写就绪,一个线程池处理最后的业务逻辑。
通常,从Reactor(在Tomcat中即Poller线程)主要关注并注册的是读事件(OP_READ)。这是因为服务器是被动接收方,它无法预测客户端何时会发送数据,只能依赖操作系统的通知(即IO就绪事件)。
而写事件(OP_WRITE) 的处理逻辑则有所不同。当业务线程(Worker线程)需要发送响应数据时,它会尝试直接将数据写入Socket的缓冲区。只有当缓冲区已满,无法一次性写入所有数据时,才有必要去注册一个写事件。这个写事件的目的是等待缓冲区再次变得可写,然后由Poller线程(或业务线程自身)得到通知,继续写入剩余的数据。如果数据能一次性成功写入,就根本无需注册写事件
用户线程注册write时机是只有当用户发送的数据无法一次性全部写入buffer时,才会去注册write事件,等待buffer重新可写时,继续写入剩下的发送数据、如果用户线程可以一次性将发送数据全部写入buffer,那么也就无需注册write事件到从Reactor中。
主从Reactor多线程模型是现在大部分主流网络框架中采用的一种IO线程模型。Netty和Tomcat都采用的这种模型。
Proactor简介
在参考文档[3]中指出ProactorIO线程模型将IO事件的监听,IO操作的执行,IO结果的dispatch统统交给内核来做。示意图里面说明的是向内核注册Proactor和CompletionHandler, 这个说法其实是有问题的。在《从阻塞IO到io_uring: Linux IO模型的演进之路 》[4]中指出新一代的异步IO框架io_uring的引入基于轮询快于中断,io_uring原生也是不支持注册回调函数的。但是内核不支持的话,我们可以基于io_uring的基础上再封装一层,在轮询到对应的事件完成做对应的回调即可。基于io_uring的Proactor模型组件介绍, 注意
- Completion handler 为用户程序定义的异步IO操作回调函数,我们轮询到对应的IO操作完成时的时候,触发对应的回调函数即可。
- Proactor是一个事件循环器负责从Completion queue(资料中称这个为Completion Event Queue 我认为这是对io_uring中CQ的抽象 )中获取异步IO操作结果触发对应的回调函数。
- Completion queue(资料中称这个为Completion Event Queue 我认为这是对io_uring中CQ的抽象 ) : 异步IO操作完成后,会产生对应的IO完成事件,将IO完成事件放入该队列里面
- Asynchronous Operation Processor: 这里其实内核的工作,负责异步IO的执行。执行完成后产生IO完成事件放入CQ中。
- 初始化操作通过io_uring将感兴趣的事件放入到SQ中。
注意内核是不接收回调函数的,我们是在io_uring的基础上做了一层封装,在从CQ轮询到事件的时候触发对应的回调函数。
因此,需要明确的是,现代Linux下的高性能Proactor模式,其"回调"机制通常是在用户态的应用层或框架层实现的,而非内核原生支持。这是对经典Proactor模型在现代操作系统上的一种适配于实现。
总结一下
至此,我们完成了一次从底层IO到上层并发模型的探索之旅,其核心始终围绕着"如何用更少的资源支撑更高的并发"。让我们回顾一下本次的探索路径:
- IO模型演进: 我们从BIO出发,途径NIO,最终到达io_uring, 理解了操作系统为提升吞吐量所做的努力。
- 虚拟线程的提出: 我们通过JEP 425提案,明确了虚拟线程的核心目标之一--以同步之名,行异步之实。
- Tomcat源码剖析: 我们深入了研究了Tomcat处理请求的流程,观察到了Acceptor、Poller和Worker线程池如何协同工作,理解了经典的Thread-Per-Request模型。
- 并发IO模型梳理: 最后,我们对比了Reactor和Proactor两种网络IO模型,并澄清了后者在现代Linux系统中的实现细节。
后面的文章我们会介绍虚拟线程其他的引入动机以及虚拟线程的实现。
参考资料
[1] JEP 425: Virtual Threads (Preview) https://openjdk.org/jeps/425
[2] 图解Linux网络包接收过程 https://zhuanlan.zhihu.com/p/256428917
[3] 聊聊Netty那些事儿之从内核角度看IO模型 https://mp.weixin.qq.com/s?__biz=Mzg2MzU3Mjc3Ng==&mid=2247483...
[4] 从阻塞IO到io_uring: Linux IO模型的演进之路 https://juejin.cn/post/7476273893821988879
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。