
Java 来说 Tomcat 是什么?有了 Web 框架为什么还需要 Tomcat?AI 项目,思考 Python Web 规范 ASGI/WSGI 那个适合 IO/CPU 密集型Linux,传输层的数据包在内核态和用户态如何流转的,为何修改一些缓存区的阈值会影响性能对每个人而言,真正的职责只有一个:找到自我。然后在心中坚守其一生,全心全意,永不停息。所有其它的路都是不完整的,是人的逃避方式,是对大众理想的懦弱回归,是随波逐流,是对内心的恐惧 ——赫尔曼·黑塞《德米安》
技术的接触往往是先学会如何用,然后才慢慢了解起原理,但是为了由浅及深的讲解,本文反过来,先介绍基本原理从 Linux内核 出发,解析阻塞IO与epoll、io_uring的本质,再延伸至高级语言框架的IO模型设计,所以对于前面讲到的问题,最少有个大概认知才适合阅读本文。
网络IO模型 是所有后端服务的基础,它决定了程序如何处理网络请求、利用系统资源以及支撑并发的能力。从Linux内核的底层实现到Python的WSGI/ASGI规范,再到Java Tomcat的线程模型,每一层抽象都蕴含着对IO效率的极致追求。
在后端开发中,我们不用深入网络 IO 模型也能写出可运行的代码,核心是 Web 服务器( Tomcat、Nginx、Uvicorn等)已经封装了底层网络IO 逻辑,帮我们处理了 “如何高效监听连接、接收数据、调度进程” 这些复杂问题,让开发者直接使用Web框架聚焦业务逻辑。
但是我们可能会面临这样的问题:
答案的核心在于网络IO模型——它决定了程序如何与操作系统协作处理网络数据,直接影响CPU利用率、内存消耗和响应延迟的请求的吞吐量。
以一个简单的Web请求为例,从用户发起起到到服务端返回响应,数据需要经历:
每一个环节的效率都与IO模型密切相关。低效的模型会让CPU在进程上下文切换、等待IO中浪费大量时间;而高效的模型能让CPU专注于业务处理,最大化资源利用率。我们先从最基础的内核网络IO开始,这是一切的基石
所有高级语言的IO操作最终都依赖操作系统内核实现,理解 Linux 内核的IO机制,是掌握高级框架设计原理的基础。
在具体的了解之前,需要对一些名词有基本的认知:
网络IO阻塞:进程因为等待某个事件而主动让出CPU挂起的操作。在网络IO中,当进程等待 socket 上的数据时,如果数据还没有到来,那就把当前进程状态从TASKRUNNKNG修改为TASKINTERRUPTIPLE,然后主动让出CPU。由调度器来调度下一个就绪状态的进程来执行。
Web 服务器: 专门处理 HTTP 请求的软件,负责网络通信层面的工作,监听网络端口(80/443)接收和解析 HTTP 请求等, 比如 Tomcat、Jetty、WebLogic、Gunicorn、Uvicorn 等,epoll 特性主要在 Web 服务器 层面被使用
Web 框架: 为 Web 应用开发提供结构和工具的软件库,路由管理(URL 映射) ,请求处理,数据库交互,会话管理等,比如 Spring Boot、 Spring MVC 、Quarkus 、Tornado、FastAPI、Django、Flask 等,上面讲的 ASGI/WSGI web 规范是框架与服务器之间的契约。
在用户态,我们通过 socket() 函数创建的是一个整数句柄,但内核中实际构建了一套复杂的对象体系。这些结构的设计直接影响了后续IO操作的效率。
核心数据结构关系
// 简化的socket内核结构关系
struct socket {
struct file *file; // 关联的文件对象(内核用文件系统统一管理资源)
struct sock *sk; // 核心套接字对象(包含协议相关状态)
struct proto_ops *ops; // 协议操作集(如TCP的连接、读写方法)
};
struct sock {
struct proto *sk_prot; // 协议处理函数(如TCP的发送/接收逻辑)
struct sk_buff_head sk_receive_queue;// 接收队列(存储待处理的数据包)
struct socket_wq *sk_wq;// 等待队列(阻塞时进程挂起的地方)
void (*sk_data_ready)(struct sock *sk, int len); // 数据就绪回调函数
// 其他字段:状态(如ESTABLISHED)、窗口大小、超时时间等
};
结构体描述:
struct socket:用户态与内核态交互的桥梁,关联文件对象和核心套接字struct sock:包含TCP/UDP等协议的核心状态,是IO操作的实际载体proto_ops与proto:封装了协议相关的操作方法(多态设计,支持不同协议)socket 创建的源码流程
下面是一个py 写的TCP 客户端,AF_INET:表示 ipv4,SOCK_STREAM: tcp传输协议
>>> from socket import socket, AF_INET, SOCK_STREAM
>>> s = socket(AF_INET, SOCK_STREAM)
>>> s.connect(('localhost', 20000))
>>> s.send(b'Hello')
5
>>> s.recv(8192)
b'Hello'
>>>
当调用socket(AF_INET, SOCK_STREAM, 0)时,内核执行以下步骤:
SYSCALL_DEFINE3(socket, ...)(net/socket.c)sock_create() → __sock_create()sock_alloc()分配struct socketnet_proto_family(协议族操作集)pf->create(如AF_INET对应inet_create)sock_type(如SOCK_STREAM)查找inetsw_array中的TCP配置socket->ops = &inet_stream_ops(TCP的操作方法)struct sock并绑定sk->sk_prot = &tcp_prot(TCP的协议处理函数)sock_init_data()设置sk->sk_data_ready = sock_def_readable// inet_create关键代码(net/ipv4/af_inet.c)
static int inet_create(...) {
// 遍历协议表,找到SOCK_STREAM对应的TCP配置
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
if (protocol == answer->protocol) {
sock->ops = answer->ops; // 绑定inet_stream_ops
answer_prot = answer->prot; // 绑定tcp_prot
break;
}
}
// 分配并初始化sock对象
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot);
sock_init_data(sock, sk); // 设置sk_data_ready等回调
}
这些初始化工作为后续的connect、recv等操作奠定了基础,尤其是sk_data_ready回调,将在数据到达时触发进程唤醒。
同步阻塞IO(BIO)是最直观的IO模型:用户进程调用recv后,如果数据未到达,就进入阻塞状态,直到数据就绪才被唤醒。这种模型实现简单,但在高并发场景下性能极差。
下面是一个 python 写的BIO Demo
from socketserver import BaseRequestHandler, TCPServer
class EchoHandler(BaseRequestHandler):
def handle(self):
print('Got connection from', self.client_address)
whileTrue:
#接收客户端发送的数据, 这次接收数据的最大字节数是8192
msg = self.request.recv(8192)
# 接收的到数据在发送回去
ifnot msg:
break
self.request.send(msg)
if __name__ == '__main__':
# 20000端口,默认IP为本地IP,监听到消息交个EchoHandler处理器
serv = TCPServer(('', 20000), EchoHandler)
serv.serve_forever()
同步阻塞IO模型也分为 单线程和多线程阻塞IO模型,上面的 Demo 是单线程,同一时间只能处理一个客户端连接,新连接会被放入监听队列等待,直到当前连接处理完成。处理连接时(handle 方法执行期间),服务器会阻塞在 recv 调用上(等待客户端发送数据),期间无法接受新连接。
以recvfrom系统调用为例,其内核处理流程如下:
SYSCALL_DEFINE6(recvfrom, ...)(net/socket.c)struct socketsock_recvmsg() → socket->ops->recvmsg(即inet_recvmsg)sk->sk_prot->recvmsg(即tcp_recvmsg,net/ipv4/tcp.c)sk->sk_receive_queue有数据,直接拷贝到用户态缓冲区sk_wait_data()进入阻塞int tcp_recvmsg(...) {
int copied = 0;
int target = len; // len是用户传入的期望读取量(如8192)
struct sock *sk = ...;
do {
// 遍历接收队列,拷贝所有可用数据(不超过target)
skb_queue_walk(&sk->sk_receive_queue, skb) {
int chunk = min(skb->len, target - copied); // 本次拷贝的字节数(不超过剩余需求)
memcpy(buf, skb->data, chunk); // 拷贝到用户态缓冲区
copied += chunk;
skb_pull(skb, chunk); // 从skb中移除已拷贝的数据
if (copied >= target) break; // 达到期望量,退出
}
// 关键判断:若已收到FIN包(发送方关闭),即使数据不足也退出
if (sk->sk_state == TCP_CLOSE_WAIT || sk->sk_state == TCP_CLOSED) {
break; // 无更多数据,退出循环
}
// 若未收到FIN,且数据不足,才阻塞等待
if (copied < target) {
sk_wait_data(sk, &timeo); // 阻塞等待新数据
}
} while (copied < target && !timeo_expired);
return copied; // 返回实际拷贝的字节数(如100)
}
进程阻塞的底层实现
sk_wait_data()通过以下步骤将进程挂起:
DEFINE_WAIT(wait)创建一个关联当前进程的等待项private字段指向当前进程(current)autoremove_wake_functionprepare_to_wait(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE)sock->sk_wq->wait队列TASK_RUNNING改为TASK_INTERRUPTIBLE(可中断睡眠)schedule_timeout()触发进程调度,当前进程进入睡眠// sk_wait_data核心代码(net/core/sock.c)
int sk_wait_data(struct sock *sk, long *timeo) {
DEFINE_WAIT(wait); // 初始化等待项,关联current
prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
// 检查是否有数据,若无则让出CPU
rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
finish_wait(sk_sleep(sk), &wait);
return rc;
}
此时,进程不再占用CPU,直到数据到达后被唤醒。
数据到达后的唤醒流程
当网卡收到数据并经内核处理后,数据会被放入sock的接收队列,随后触发唤醒:
ksoftirqd内核线程处理软中断tcp_v4_rcv()解析TCP包,找到对应的sock对象,调用tcp_rcv_established()tcp_queue_rcv()将数据包(sk_buff)加入sk->sk_receive_queuesk->sk_data_ready(sk, 0)(即sock_def_readable 初始化回调的时候绑定)// tcp_rcv_established中的唤醒逻辑(net/ipv4/tcp_input.c)
int tcp_rcv_established(...) {
// 数据入队
eaten = tcp_queue_rcv(sk, skb, ...);
// 触发数据就绪回调
sk->sk_data_ready(sk, 0);
}
sock_def_readable会遍历sock的等待队列,唤醒阻塞的进程:
static void sock_def_readable(struct sock *sk, int len) {
struct socket_wq *wq = rcu_dereference(sk->sk_wq);
if (wq_has_sleeper(wq)) {
// 唤醒等待队列上的进程
wake_up_interruptible_sync_poll(&wq->wait, POLL_IN | POLLPRI | ...);
}
}
wake_up_interruptible_sync_poll最终调用try_to_wake_up(),将进程状态改回TASK_RUNNING,使其重新参与CPU调度。
同步阻塞IO的低效源于三个核心问题:
不管是单线程还是多线程,在高并发场景(如10万连接)中,同步阻塞模型完全不可行——系统资源会被进程管理耗尽,或者请求完全陷入阻赛。
epoll 是 Linux 为解决高并发IO设计的多路复用机制,它让一个进程能同时管理成千上万的连接,大幅降低了进程切换开销。
epoll 的核心流程主要分三部分:
epoll_create 初始化 eventpoll 对象(红黑树 + 就绪链表 + 等待队列),返回 epfd。epoll_ctl 通过红黑树维护监控关系,并注册回调,确保 fd 就绪时自动加入就绪链表。epoll_wait 高效获取就绪链表中的事件(O (1) 复杂度),无事件时阻塞等待,避免轮询开销。下面是一个 py 写的Demo
import socket
import select
# 创建监听套接字
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8888))
server.listen(5)
server.setblocking(False) # 设置非阻塞
# 创建 epoll 对象
epoll = select.epoll()
epoll.register(server.fileno(), select.EPOLLIN) # 监听服务器套接字的读事件
connections = {} # 保存客户端连接 {文件描述符: 套接字}
try:
whileTrue:
# 等待事件发生 (阻塞,直到有事件)
events = epoll.poll()
for fileno, event in events:
# 服务器套接字描述符有读事件 (新连接)
if fileno == server.fileno():
client, addr = server.accept()
client.setblocking(False)
epoll.register(client.fileno(), select.EPOLLIN) # 注册客户端读事件
connections[client.fileno()] = client
print(f"新连接: {addr}")
# 客户端有读事件 (发来了数据)
elif event & select.EPOLLIN:
client = connections[fileno]
data = client.recv(1024)
if data:
print(f"收到 {client.getpeername()}: {data.decode()}")
client.send(f"已收到: {data}".encode()) # 回传数据
else: # 客户端断开
epoll.unregister(fileno)
client.close()
del connections[fileno]
print(f"连接断开: {client.getpeername()}")
finally:
epoll.unregister(server.fileno())
epoll.close()
server.close()
epoll的核心数据结构
调用epoll_create()时,内核创建struct eventpoll对象,作为 epoll 的核心管理结构:
struct eventpoll {
wait_queue_head_t wq; // epoll_wait 的等待队列(进程阻塞在这里)
struct list_head rdllist; // 就绪事件链表(已准备好的socket)
struct rb_root rbr; // 红黑树(管理所有注册的socket)
// 其他字段:唤醒掩码、用户态地址等
};
wq:当没有就绪事件时,调用epoll_wait的进程会阻塞在此队列rdllist:存储已就绪的事件,避免遍历整个红黑树rbr:通过红黑树高效管理注册的socket(支持O(logN)的插入/删除/查找)每个注册到 epoll 的 socket 对应一个 struct epitem(红黑树节点):
struct epitem {
struct rb_node rbn; // 红黑树节点
struct epoll_filefd ffd; // 关联的文件描述符(socket)
struct eventpoll *ep; // 所属的eventpoll对象
struct list_head rdllink; // 就绪链表的节点(用于加入rdllist)
struct list_head pwqlist; // 等待队列项链表(关联socket的等待队列)
};
首先建立好的 socket 需要注册到 epoll 对象的红黑树里面
epoll_ctl:注册socket的过程
epoll_ctl(EPOLL_CTL_ADD)用于将 socket 注册到 epoll,核心步骤如下:
struct epitem,初始化其关联的eventpoll和文件描述符ep_poll_callbackeventpoll->rbr,完成注册所有的注册过程都是在 ep_insert 核心函数完成
// ep_insert核心代码(fs/eventpoll.c)
static int ep_insert(...) {
// 分配epitem
struct epitem *epi = kmem_cache_alloc(epi_cache, GFP_KERNEL);
epi->ep = ep; // 关联eventpoll
ep_set_ffd(&epi->ffd, tfile, fd); // 关联socket的文件对象
// 设置socket等待队列的回调
struct ep_pqueue epq;
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc); // 回调设为ep_ptable_queue_proc
revents = ep_item_poll(epi, &epq.pt); // 触发回调注册
// 插入红黑树
ep_rbtree_insert(ep, epi);
}
其中 ep_ptable_queue_proc会创建一个struct eppoll_entry(等待队列项),将其回调设为ep_poll_callback,并加入socket的等待队列:
static void ep_ptable_queue_proc(...) {
struct eppoll_entry *pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback); // 回调设为ep_poll_callback
add_wait_queue(whead, &pwq->wait); // 加入socket的等待队列
list_add(&pwq->llink, &epi->pwqlist); // 关联epitem
}
此时,socket 的等待队列中不仅有ep_poll_callback,还可能有其他等待项(如阻塞IO的进程),但 epoll 通过回调机制实现了高效的事件通知。
epoll_wait:等待就绪事件
epoll_wait的逻辑是检查就绪链表,若无就绪事件则阻塞:
ep_events_available(ep)判断rdllist是否为空eventpoll->wq并阻塞对应的核心方法为 ep_poll
// ep_poll核心代码(fs/eventpoll.c)
static int ep_poll(...) {
fetch_events:
if (!ep_events_available(ep)) { // 无就绪事件
init_waitqueue_entry(&wait, current); // 等待项关联当前进程
__add_wait_queue_exclusive(&ep->wq, &wait); // 加入eventpoll的等待队列
// 阻塞等待
for (;;) {
set_current_state(TASK_INTERRUPTIBLE); // 设为可中断睡眠
if (ep_events_available(ep)) break; // 有事件则退出循环
if (!schedule_hrtimeout_range(...)) break; // 超时退出
}
__remove_wait_queue(&ep->wq, &wait); // 从等待队列移除
set_current_state(TASK_RUNNING); // 恢复运行状态
}
// 收集就绪事件并返回
return ep_send_events(ep, events, maxevents);
}
ep_send_events会遍历rdllist,将就绪的socket信息拷贝到用户态缓冲区,完成一次事件通知。
数据到达:epoll的回调与唤醒
当 socket 收到数据时,触发流程与阻塞模型不同,关键在于ep_poll_callback:
sk->sk_receive_queuesock_def_readable唤醒 socket 等待队列上的回调,包括 ep_poll_callbackep_poll_callback将epitem加入eventpoll->rdllisteventpoll->wq有等待的进程(即epoll_wait阻塞的进程),则唤醒它// ep_poll_callback核心代码(fs/eventpoll.c)
static int ep_poll_callback(...) {
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
// 将epitem加入就绪链表
list_add_tail(&epi->rdllink, &ep->rdllist);
// 唤醒阻塞在epoll_wait的进程
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
return 1;
}
这个过程的高效之处在于:数据到达时不会直接唤醒用户进程,而是先将事件加入就绪链表,等epoll_wait检查时一次性处理,减少了进程唤醒次数。
先通过 epoll_create () 创建个 “总管家” struct eventpoll,它有三个关键部分:
红黑树 rbr就绪链表 rdllist等待队列 wq用通俗的话来讲:每个客户端 socket 连接会对应一个 struct epitem,类似观察者设计模式中的 观测订阅模式一样,用 epoll_ctl (EPOLL_CTL_ADD) 注册 socket 链接,这里核心是 ep_insert 函数:会先给连接建立 epitem同时再设 ep_poll_callback 回调,最后把 epitem 插进对应的存放订阅 socket 的数据结构红黑树 rbr里面。
之后用 epoll_wait(核心是 ep_poll 函数)等活干:先看 rdllist 有没有待处理连接,有就直接拿给用户态;没有就把进程放进 wq 休息。这里对应我们上面 py 代码里面的 events = epoll.poll(),进程及用户态操作 socket 的进程
等客户端发数据了,先把数据存起来,再触发 ep_poll_callback 回调,把对应 epitem 放进 rdllist,要是 wq 里有休息的进程,就叫醒它来处理,这样不用频繁唤醒进程,效率很高。
每个 socket 注册到 epoll 实例时,会关联到该实例的红黑树 rbr,而 epoll 实例又关联着自己的等待队列 wq(存放监听它的进程)。当 epoll 为单线程或者单进程拥有,那么 wq 里面只有一个线程或者进程,当存在多个的时候,即对应的 epoll 为多个线程或者进程共享 epoll 实例。
epoll 相比同步阻塞IO的核心优势:
这些特性使 epoll 能轻松支撑百万级并发连接,成为高性能服务的首选 IO模型,前面三个我们上文中已经描述,第四个这里的水平触发和边缘触发,是指内核监控文件描述符(如 socket),当有数据可读/可写时,通知应用程序处理。两者的差异只在 通知的规则 上。
水平触发(LT,Level Trigger):默认模式,“不处理完就一直提醒”,绝大多数常规场景(如 Web 服务器、数据库连接),尤其是对编程复杂度敏感、无需极致性能的场景(比如 Nginx 默认用 LT 模式)。边缘触发(ET,Edge Trigger):高效模式,“只提醒一次,过时不候”,极致性能需求的高并发场景(如百万连接的服务器、实时通信系统),比如 Redis、高吞吐的网关服务(需开发者自己处理好数据读取逻辑,避免遗漏)。io_uring 是一个由 Linux 内核提供的高性能、异步 I/O 框架。它的设计目标是解决传统异步 I/O 接口(如 aio)的缺陷,为 存储IO 和 网络IO 提供一个真正高效、可扩展的解决方案。通过一个共享的环形缓冲区(Ring Buffer) 在内核和用户空间之间进行通信,极大地减少了系统调用的开销和内存拷贝。
对比上面的 多路复用 epoll 只解决了 就绪通知 问题,没有解决 I/O 执行 问题。 应用程序仍然需要调用 read/write 系统调用来处理数据,这在高负载下仍然是性能瓶颈。 而 Linux 原本的 异步 I/O AIO 对网络 I/O 的支持不完整且问题多,接口设计笨拙,使用复杂。而 io_uring 通过其批量和异步的提交/完成机制,从根本上解决了这个问题。
共享环形队列架构: io_uring 的核心设计是基于两个在内核态和用户态之间共享的环形队列:
提交队列(Submission Queue, SQ):用户程序向此队列提交 I/O 请求,应用程序将希望执行的 I/O 操作(如读取、写入)封装成一个 SQE(Submission Queue Entry),放入 SQ 中。完成队列(Completion Queue, CQ):内核在此队列中放置已完成的 I/O 操作结果,当内核完成一个 I/O 操作后,会生成一个 CQE(Completion Queue Entry),放入 CQ 中。应用程序从 CQ 中取回结果。
应用程序(Application)
├─ 将新请求放入提交队列的队尾
└─ 从完成队列的队头获取响应
队头(head) 队头(head)
提交队列(Submission Queue) 共享内存(Shared Memory) 完成队列(Completion Queue)
队尾(tail) 队尾(tail)
内核(Kernel)
├─ 从提交队列的队头获取任务并执行
└─ 将响应结果放入完成队列的队尾
这两个队列通过内存映射(mmap)方式实现共享,避免了传统 I/O 模型中的数据拷贝开销。
无锁设计:队列采用单生产者 - 单消费者模型:
SQ:用户程序是生产者,内核是消费者CQ:内核是生产者,用户程序是消费者这种设计使得队列访问无需加锁,仅使用内存屏障(memory barriers)进行同步,极大提高了性能。
系统调用优化,io_uring 仅提供三个核心系统调用:
// 初始化 io_uring 实例
int io_uring_setup(unsigned int entries, struct io_uring_params *params);
// 提交 I/O 请求并/或等待完成事件
int io_uring_enter(unsigned int fd, u32 to_submit, u32 min_complete, u32 flags);
// 注册资源(文件描述符、缓冲区等)
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
基本流程:
io_uring_enter 系统调用,通知内核有新的任务需要处理(或者内核通过轮询模式直接获取,实现零系统调用)。liburing 库为 io_uring 的使用提供了便捷方式:它隐藏了部分底层复杂度,并提供各类函数用于准备所有类型的 I/O 操作,以便提交执行。
//#用户进程创建 io_uring 的代码示例如下
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
//向 io_uring 提交队列提交操作的代码示例:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, client_socket, iov, 1, 0);
io_uring_sqe_set_data(sqe, user_data);
io_uring_submit(&ring);
//进程等待操作完成的代码示例:
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
//获取并处理响应结果的代码示例:
user_data = io_uring_cqe_get_data(cqe);
if (cqe->res < 0) {
// 处理错误
} else {
// 处理响应
}
io_uring_cqe_seen(&ring, cqe);
通过编写一个简单的回声服务器,使用liburing API来实现网络I/O。然后我们将了解如何最小化高速率并发工作负载所需的系统调用数量
https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io
伯克利软件发行版(Berkeley Software Distribution,简称 BSD)Unix 中经典的回声服务器代码大致如下:
client_fd = accept(listen_fd, &client_addr, &client_addrlen);
for (;;) {
numRead = read(client_fd, buf, BUF_SIZE);
if (numRead <= 0) // 遇到 EOF 或错误时退出循环
break;
if (write(client_fd, buf, numRead) != numRead)
// 处理写操作错误
}
close(client_fd);
处理每个客户端会话至少需要 5 次系统调用:accept(接收连接)、read(读数据)、write(写数据)、再次 read(检测 EOF),以及最终的close(关闭连接)。
若直接将上述逻辑迁移到 io_uring,会得到一个 “异步服务器”—— 它每次仅提交一个操作,等待该操作完成后再提交下一个。以下是基于 io_uring 的服务器伪代码(省略了基础初始化代码和错误处理逻辑):
add_accept_request(listen_socket, &client_addr, &client_addr_len);
// 见了连接一次提交
io_uring_submit(&ring);
while (1) {
int ret = io_uring_wait_cqe(&ring, &cqe);
struct request *req = (struct request *) cqe->user_data;
switch (req->type) {
case ACCEPT:
add_accept_request(listen_socket,
&client_addr, &client_addr_len);
add_read_request(cqe->res);
io_uring_submit(&ring);
break;
case READ:
if (cqe->res <= 0) {
add_close_request(req);
} else {
add_write_request(req);
}
io_uring_submit(&ring);
break;
case WRITE:
add_read_request(req->socket);
io_uring_submit(&ring);
break;
case CLOSE:
free_request(req);
break;
default:
fprintf(stderr, "Unexpected req type %d\n", req->type);
break;
}
io_uring_cqe_seen(&ring, cqe);
}
在这个 io_uring 示例中,服务器处理每个新客户端仍需至少 4 次系统调用(io_uring_submit(&ring) ->io_uring_enter )
需要说明 add_accept_request(封装了 io_uring_prep_accept 等 liburing 函数,将 accept 操作封装为提交队列项(SQE)) 本身并不会触发系统调用 ——它只是一个“准备请求”的库函数(属于 liburing 库),作用是填充 io_uring 的提交队列项(SQE)结构体,为后续提交异步 accept 操作做准备。
io_uring_prep_accept 的本质
它是 liburing 提供的辅助函数,功能是:io_uring_sqe(提交队列项)结构体;IORING_OP_ACCEPT(或 IORING_OP_ACCEPT_MULTISHOT);accept 所需的参数(如监听套接字、客户端地址结构体指针等);io_uring 的提交队列(SQ)中。这个过程完全在用户空间完成,不涉及内核态切换,因此没有系统调用开销。
真正触发系统调用的时机
只有当调用 io_uring_submit(或底层的 io_uring_enter 系统调用)时,才会将提交队列(SQ)中已准备好的所有请求(包括通过 io_uring_prep_accept 准备的 accept 请求)批量提交给内核,此时才会触发一次系统调用(io_uring_enter)。
借助 io_uring 的 “固定文件”(fixed file)新特性,将 “接收连接(accept)” 与 “读操作(read)” 串联;但实际上,我们已经能将 “读请求” 与 “新的接收连接请求” 一起提交,因此这种串联可能不会带来明显收益。
我们可以同时提交多个独立操作—— 例如,将 “写操作” 与后续的 “读操作” 合并提交。
while (1) {
int submissions = 0;
int ret = io_uring_wait_cqe(&ring, &cqe);
while (1) {
struct request *req = (struct request *) cqe->user_data;
switch (req->type) {
case ACCEPT:
add_accept_request(listen_socket,
&client_addr, &client_addr_len);
add_read_request(cqe->res);
submissions += 2;
break;
case READ:
if (cqe->res <= 0) {
add_close_request(req);
submissions += 1;
} else {
add_write_request(req);
add_read_request(req->socket);
submissions += 2;
}
break;
case WRITE:
break;
case CLOSE:
free_request(req);
break;
default:
fprintf(stderr, "Unexpected req type %d\n", req->type);
break;
}
io_uring_cqe_seen(&ring, cqe);
if (io_uring_sq_space_left(&ring) < MAX_SQE_PER_LOOP) {
break; // 提交队列已满
}
ret = io_uring_peek_cqe(&ring, &cqe);
if (ret == -EAGAIN) {
break; // 完成队列中无剩余待处理任务
}
}
if (submissions > 0) {
io_uring_submit(&ring);
}
}
这样一来,处理每个客户端请求所需的系统调用次数可减少至 3 次,这里是关键区别:传统 accept** 每次调用都会触发一次系统调用,直接进入内核处理; io_uring_prep_accept + io_uring_submit 在prep 阶段仅在用户空间准备请求,submit 阶段批量提交多个请求(如同时提交 accept、read、write 等),仅触发一次系统调用即可处理多个操作。
除了阻塞IO和epoll,Linux还支持select、poll等多路复用模型。以下是主要模型的对比:
模型 | 最大连接数 | 就绪事件查找 | 重复注册 | 内核用户拷贝 | 适用场景 |
|---|---|---|---|---|---|
select | 1024(FD_SETSIZE) | 遍历(O(N)) | 需要 | 有 | 跨平台、低并发 |
poll | 无限制 | 遍历(O(N)) | 需要 | 有 | 跨平台、中低并发 |
epoll | 无限制 | 就绪链表(O(1)) | 无需 | 可选(零拷贝) | 高并发、Linux专属 |
阻塞IO | 有限(进程数) | 无 | 无 | 有 | 低并发、简单场景 |
io_uring | 无限制(队列深度 SQ/CQ 大小) | 完成队列遍历(批量处理) | 可选(注册文件/缓冲区) | 可选(固定缓冲区零拷贝/零拷贝) | 高并发、高吞吐量、Linux专属(文件/网络混合I/O) |
select 与 poll 两者都是 Linux 早期的多路复用 IO 机制,核心作用是让单个进程管理多个连接,解决传统阻塞 IO 一连接一线程的资源浪费问题,但性能和特性有明显局限,任然需要大量的内存数据拷贝,而且 select 对管理的连接数也有限制
epoll 的优势在高并发场景下尤为明显,这也是为什么现代高性能服务(如Nginx、Redis)都采用 epoll作为IO模型。
简单介绍了 epoll ,回到我们最开始的关于Linux内核的问题,传输层的数据包在内核态和用户态如何流转的? 为何修改一些缓存区的阈值会影响性能?
关于第一个问题,小伙伴可以看我前几篇博文,这里简单介绍:
对于发送端来说,传输层的数据包会由用户态复制到内核态,然后通过网卡发送,大部分的时间消耗在用户进程内核态协议栈解析以及内存拷贝,发包需要关注的:
数据包拷贝问题,需要进行一次深拷贝(用户态到内核态),一次浅拷贝(拷贝元数据,TCP重传留痕),一次可选的拷贝(数据包超过MTU进行切包进行的深拷贝,实际发生在网络层)中断问题,数据包在发生过程中如果一直持有CPU,那么数据会顺利发送,如果超出CPU时间片,那么会触发软中断,由内核线程 ksoftirqd 继续发送,在网卡发送完数据之后会触发硬中断清空 RingBuffer。对于接收端来说,当poll函数(或类似I/O多路复用机制)触发时,内核协议栈会将待处理的sk_buff(套接字缓冲区)交给传输层。内核态会先通过协议栈逐层解析数据包(从网络层到传输层),再按序将socket缓冲区中的数据拷贝到用户态。这一过程中,有两个关键点需要关注:
中断处理机制 数据包到达时,首先由网卡触发 硬中断,硬中断完成初步处理后会唤醒 软中断,最终由内核线程ksoftirqd负责后续的数据包处理(如协议解析、缓冲区管理等),由于接收端需要持续处理外部涌入的数据包,其软中断的触发频率通常远高于发送端。数据包过滤与捕获逻辑 Netfilter(及基于其实现的iptables等)的过滤规则主要在网络层(或者网络层之前)生效,用于决定数据包是否允许进入上层协议或转发或者进行一些DNAT的配置。部分抓包工具是在设备层(接近网卡驱动的位置)通过钩子捕获数据包,因此即使数据包被iptables规则阻断,仍可能被这类工具捕获到。关于第二问题,内核为socket的接收 / 发送缓冲区设置了默认大小和最大阈值(可通过net.ipv4.tcp_rmem/tcp_wmem等参数调整),这些阈值会直接影响数据流转效率,主要原因套接字缓冲区与带宽延迟积(BDP)需要协同
缓冲区阈值与BDP不匹配:
若阈值过小(rmem_max/wmem_max远小于实际 BDP 时):当数据包涌入速度超过用户态读取速度时,内核接收缓冲区易满,导致 TCP 触发 “窗口关闭”(接收窗口为 0),发送端暂停发送,降低吞吐量,带宽利用率仅能达到理论值的 30%~50%;若阈值过大(若远大于 BDP):虽能缓冲更多突发数据,但会占用过多内核内存,可能导致系统整体内存紧张,且用户态读取时单次拷贝数据量过大,可能增加延迟。系统内存限制失衡:tcp_mem/udp_mem的min、pressure、max设置不合理,要么因内存不足导致网络连接建立失败,要么因内存过度分配引发系统 OOM,中断数据流转。
关于设计到的内核参数,小伙伴可以看看我之前的博文
[root@developer ~]# sysctl -a | grep mem
....
net.core.optmem_max = 81920 # 套接字选项缓冲区的最大大小(字节),用于存储套接字选项相关数据,超过此值会返回错误
net.core.rmem_default = 212992 # 所有协议(如TCP、UDP)的默认接收缓冲区大小(字节),新建套接字默认使用此值
net.core.rmem_max = 212992 # 所有协议的最大接收缓冲区大小(字节),应用程序可设置的接收缓冲区上限(不能超过此值)
net.core.wmem_default = 212992 # 所有协议的默认发送缓冲区大小(字节),新建套接字默认使用此值
net.core.wmem_max = 212992 # 所有协议的最大发送缓冲区大小(字节),应用程序可设置的发送缓冲区上限(不能超过此值)
net.ipv4.tcp_mem = 78777105039157554 # TCP协议的整体内存管理阈值(单位:页,1页=4KB),三个值分别为:最小内存页、压力模式阈值、最大内存页。内核会根据TCP总内存使用量动态调整行为(如触发内存回收)
net.ipv4.tcp_rmem = 40961310726291456 # TCP接收缓冲区的内存范围(字节),三个值分别为:最小大小、默认大小、最大大小。应用程序可在范围内调整,内核也可能根据拥塞控制动态调整
net.ipv4.tcp_wmem = 4096163844194304 # TCP发送缓冲区的内存范围(字节),三个值分别为:最小大小、默认大小、最大大小。作用类似tcp_rmem,用于控制发送缓冲区的动态调整范围
net.ipv4.udp_mem = 157557210078315114 # UDP协议的整体内存管理阈值(单位:页),三个值分别为:最小内存页、压力模式阈值、最大内存页。控制UDP总内存使用量,避免占用过多系统内存
net.ipv4.udp_rmem_min = 4096 # UDP接收缓冲区的最小大小(字节),应用程序设置的接收缓冲区不能小于此值
net.ipv4.udp_wmem_min = 4096 # UDP发送缓冲区的最小大小(字节),应用程序设置的发送缓冲区不能小于此值
[root@developer ~]#
Python 的 Web 框架经历了从同步到异步的演进,其底层IO模型的变化直接影响了框架的性能和并发能力。WSGI 规范基于同步阻塞IO,而 ASGI 则引入了异步IO和事件循环,适配epoll等高效内核机制。
WSGI(Web Server Gateway Interface)是Python Web框架的标准接口,定义了Web服务器与应用程序之间的交互方式。它诞生于2003年,基于同步阻塞IO模型,至今仍是许多框架(如Django、Flask)的基础。
什么是 WSGI ?: https://wsgi.readthedocs.io/en/latest/what.html
WSGI规范定义了两个核心角色:
environ(请求信息)和start_response(响应回调),返回响应体下面是一个 WSGI应用示例 的Demo
#
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
"""
@File : app.py
@Time : 2022/05/03 14:43:56
@Author : Li Ruilong
@Version : 1.0
@Contact : 1224965096@qq.com
@Desc : None
"""
# here put the import lib
import time
import cgi
def notfound_404(environ, start_response):
start_response('404 Not Found', [('Content-type', 'text/plain')])
return [b'Not Found']
# 核心控制器,用于路由注册
class PathDispatcher:
def __init__(self):
# 映射字典
self.pathmap = {}
# 核心控制器的回调
def __call__(self, environ, start_response):
# 获取路由
path = environ['PATH_INFO']
# 获取请求参数
params = cgi.FieldStorage(environ['wsgi.input'],
environ=environ)
# 获取请求方法
method = environ['REQUEST_METHOD'].lower()
environ['params'] = {key: params.getvalue(key) for key in params}
# 找到映射的函数
handler = self.pathmap.get((method, path), notfound_404)
# 返回函数
return handler(environ, start_response)
def register(self, method, path, function):
# 请求方法和路由作为K,执行函数为V
self.pathmap[method.lower(), path] = function
return function
_hello_resp = "wo jiao {name}"
def hello_world(environ, start_response):
start_response('200 OK', [('Content-type', 'text/html')])
params = environ['params']
resp = _hello_resp.format(name=params.get('name'))
yield resp.encode('utf-8')
_localtime_resp = "dang qian shjian {t}"
# 路由的回调
def localtime(environ, start_response):
start_response('200 OK', [('Content-type', 'application/xml')])
resp = _localtime_resp.format(t=time.localtime())
yield resp.encode('utf-8')
if __name__ == '__main__':
from wsgiref.simple_server import make_server
# 创建一个核心控制器,用于路由注册
dispatcher = PathDispatcher()
# 注册路由,对应的回调方法
dispatcher.register('GET', '/hello', hello_world)
dispatcher.register('GET', '/localtime', localtime)
# Launch a basic server 监听8080端口,注入核心控制器
httpd = make_server('', 8080, dispatcher)
print('Serving on port 8080...')
httpd.serve_forever()
服务器与应用的交互流程:
environ字典(包含HTTP方法、路径、 headers等)environ和start_responsestart_response设置状态码和响应头WSGI的IO模型限制
WSGI基于同步阻塞IO,其服务器(如Gunicorn、uWSGI)通常采用"预派生子进程+线程池"的模型:Gunicorn的worker模式:
sync:每个请求由一个线程处理,阻塞IO(默认)gevent:基于协程的异步模式eventlet:类似gevent,依赖协程同步模式下,每个请求会占用一个线程,直到处理完成。当请求涉及IO操作(如数据库查询、网络调用)时,线程会阻塞等待,导致资源浪费。
# Flask(WSGI框架)的同步阻塞示例
from flask import Flask
import time
app = Flask(__name__)
@app.route('/')
def index():
time.sleep(1) # 模拟IO阻塞(如数据库查询)
return "Hello, Flask"
if __name__ == '__main__':
app.run() # 内置服务器使用同步模型,单线程处理请求
在这个例子中,time.sleep(1)会阻塞整个线程,期间无法处理其他请求。若同时有10个请求,总耗时约10秒(串行处理)。
WSGI服务器的并发瓶颈
WSGI服务器的并发能力受限于线程/进程数量:
例如,Gunicorn 的 sync worker 在处理 1000 个并发请求时,需要 1000 个线程,这会导致大量的上下文切换和内存消耗,性能急剧下降。
ASGI(Asynchronous Server Gateway Interface)是Python 3.5+推出的异步Web接口,旨在解决WSGI的同步限制,支持异步IO和长连接(如WebSocket)。它兼容WSGI,并引入了事件循环和异步处理机制。
ASGI引入了三个核心角色:
与WSGI的主要区别:
async def)uvloop,封装epoll/kqueue)WebSocket)# ASGI应用示例
async def application(scope, receive, send):
# scope:连接元数据(如类型、方法、路径)
# receive:异步函数,接收事件(如请求体)
# send:异步函数,发送事件(如响应)
await send({
'type': 'http.response.start',
'status': 200,
'headers': [(b'content-type', b'text/plain')],
})
await send({
'type': 'http.response.body',
'body': b'Hello, ASGI',
})
ASGI的事件循环与IO模型
ASGI服务器(如Uvicorn、Hypercorn)基于异步事件循环,底层使用epoll(Linux)、kqueue(BSD)等高效IO多路复用机制:
下面的Demo中,await asyncio.sleep(1)会让协程让出CPU,事件循环可以同时处理其他请求。10个并发请求的总耗时约1秒(并行处理),效率远高于WSGI。
# FastAPI(ASGI框架)的异步示例
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.get("/")
async def index():
await asyncio.sleep(1) # 异步IO操作(非阻塞)
return {"message": "Hello, FastAPI"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app) # Uvicorn使用事件循环和epoll
以 Uvicorn 为例,其工作流程,当 Uvicorn 启动时,会创建一个基于 epoll 的事件循环(通常使用 uvloop 实现),epoll 事件循环负责底层的 IO 事件检测和进程唤醒,Uvicorn 的事件循环负责上层的协程调度和业务逻辑处理。整个工作流程如下:
1 初始化阶段:Uvicorn 创建事件循环(运行在用户空间),创建服务器 socket 并绑定端口,将 socket 注册到 epoll(通过系统调用),事件循环进入等待状态 2 请求处理阶段:客户端发送 HTTP 请求到服务器,网络设备接收数据包并写入 socket 缓冲区,epoll 检测到 socket 可读,将其加入就绪列表,epoll 唤醒等待的 Uvicorn 事件循环进程,事件循环从就绪列表获取 socket,触发连接回调,创建协程来处理这个 HTTP 请求 3 异步处理阶段:协程开始处理请求头、请求体,当遇到 await async_db_query() 等 IO 操作时:,协程让出控制权给事件循环,事件循环将数据库查询的文件描述符注册到 epoll,事件循环继续调度其他就绪的协程 4 IO 完成阶段:数据库查询完成,数据准备就绪,epoll 检测到数据库连接可读,唤醒事件循环,事件循环找到对应的挂起协程并恢复执行,协程继续处理数据,生成 HTTP 响应,发送响应给客户端,完成请求处理 5 循环复用阶段:连接关闭或保持(HTTP/11 keep-alive),事件循环继续等待下一个 epoll 事件,整个过程不断循环,高效处理并发请求
ASGI对epoll的封装
ASGI服务器的事件循环(如uvloop)直接封装了内核的epoll机制:
EPOLLIN事件(新连接)epoll_wait等待事件就绪(非阻塞或超时)accept获取客户端socket,注册到epoll(关注EPOLLIN)# 简化的事件循环伪代码(模拟uvloop)
import select
epoll = select.epoll()
server_socket = ... # 监听socket
epoll.register(server_socket.fileno(), select.EPOLLIN)
whileTrue:
events = epoll.poll() # 等待事件(epoll_wait)
for fileno, event in events:
if fileno == server_socket.fileno():
# 新连接
client_socket, addr = server_socket.accept()
epoll.register(client_socket.fileno(), select.EPOLLIN)
elif event & select.EPOLLIN:
# 数据可读
data = client_socket.recv(1024)
# 创建协程处理请求
loop.create_task(handle_request(client_socket, data))
ASGI相比WSGI的核心优势:
这里同样看下最开始的那个问题,Python Web 规范 ASGI/WSGI 那个适合 IO/CPU 密集型?
网络IO 密集型和 CPU 密集型是两个不同的概念, ASGI 更多的是面向 网络/IO 密集型的非阻塞处理,他不适用 CPU 密集型,如果你的请求是一个CPU 密集型,那么还是会阻赛,所以 CPU 密集型任务更适合用 WSGI(或 ASGI 配合多进程/线程池),避免异步调度开销,核心原因在于两者的调度机制与任务特性的匹配度不同。具体来说:
WSGI 是同步阻塞的规范,每个请求会占用一个工作进程/线程,直到处理完成,对于 CPU 密集型任务(如大量计算、数据处理),其阻塞特性本身不会加剧 CPU 负担,CPU 密集型任务的核心瓶颈是计算能力,所以只是无法高效复用进程/线程,在高并发场景下吞服量比较小,并不是说资源利用率低。而且WSGI 的阻塞模式在此场景下不会比 ASGI 表现更差(甚至可能因减少异步调度开销而略优),需要持续的CPU占用,所以不会发生频繁的上下文切换。ASGI 是异步非阻塞的规范,基于事件循环机制,能在单线程内高效处理大量并发的网络 IO 操作(如数据库查询、网络请求等)。其优势在于当一个请求陷入 IO 等待时,可切换到其他请求继续处理,大幅提升 IO 密集型场景的吞吐量。但对于 CPU 密集型任务,由于 Python 的 GIL(全局解释器锁)限制,单线程内的 CPU 密集型任务会阻塞事件循环,导致其他请求无法被处理,反而降低整体性能。此时,ASGI 通常需要配合多进程或线程池来处理 CPU 密集型任务,本质上还是通过并行计算绕过 GIL 限制,而非 ASGI 自身的异步特性起作用。异步不等于快,吞吐量不等于处理速度,网络IO密集型和 CUP 密集型是两种不同场景,即使通过队列等方式做了处理,解决的也只是吞吐量,和处理速度没有关系。往往看上去处理完了,会发现程序内部积累了大量的协程,吃进去了,但是消化不了。
Tomcat 是 Java生态中最流行的 Web服务器,其性能优化历程伴随着IO模型的演进。从最初的BIO(阻塞IO)到NIO(非阻塞IO),再到APR(Apache Portable Runtime)
Tomcat的核心组件包括:
其中,Connector是IO模型的核心实现者,不同的IO模型对应不同的Connector配置:
BIO:阻塞IO模型(Tomcat 7及之前默认)NIO:非阻塞IO模型(Tomcat 8及之后默认)NIO2:异步IO模型(Java NIO.2的AIO)APR:基于原生库的IO模型(最高性能,Tomcat10.0 废弃)BIO(Blocking IO)是Tomcat最早采用的IO模型,基于同步阻塞IO实现。BIO Connector的核心组件:
// BIO模型的简化流程
publicclass BioConnector {
private ServerSocket serverSocket;
private ExecutorService workerPool = Executors.newFixedThreadPool(100);
public void start(int port) throws IOException {
serverSocket = new ServerSocket(port);
// Acceptor线程:接收新连接
new Thread(() -> {
while (true) {
Socket socket = serverSocket.accept(); // 阻塞等待新连接
// 提交给Worker线程池处理
workerPool.submit(() -> handle(socket));
}
}).start();
}
private void handle(Socket socket) {
try (InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
// 读取请求(阻塞)
byte[] buffer = newbyte[1024];
in.read(buffer); // 阻塞等待数据
// 处理请求(调用Servlet)
String response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
// 发送响应(阻塞)
out.write(response.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
}
性能瓶颈 BIO模型的问题与Linux的同步阻塞IO类似:
Tomcat的BIO模型在并发量超过1000时就会出现明显的性能下降,甚至OOM(内存溢出)。
NIO(Non-blocking IO)是Tomcat 8引入的默认模型,基于Java NIO实现,底层依赖Linux的epoll(或Windows的IOCP)。
核心组件与工作原理
NIO Connector的核心组件:
// NIO模型的简化流程
publicclass NioConnector {
private ServerSocketChannel serverChannel;
private Selector selector;
private ExecutorService workerPool = Executors.newFixedThreadPool(10);
public void start(int port) throws IOException {
serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(port));
serverChannel.configureBlocking(false); // 非阻塞模式
selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT); // 注册接受事件
// Selector线程:监控IO事件
new Thread(() -> {
while (true) {
try {
selector.select(); // 阻塞等待事件(底层epoll_wait)
Set<SelectionKey> keys = selector.selectedKeys();
for (Iterator<SelectionKey> it = keys.iterator(); it.hasNext(); ) {
SelectionKey key = it.next();
it.remove();
if (key.isAcceptable()) {
// 处理新连接
accept(key);
} elseif (key.isReadable()) {
// 处理读事件
workerPool.submit(() -> read(key));
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
private void accept(SelectionKey key) throws IOException {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept(); // 非阻塞,立即返回
client.configureBlocking(false);
// 注册读事件
client.register(selector, SelectionKey.OP_READ);
}
private void read(SelectionKey key) {
try (SocketChannel channel = (SocketChannel) key.channel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer); // 非阻塞读
buffer.flip();
// 处理请求...
String response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
channel.write(ByteBuffer.wrap(response.getBytes())); // 非阻塞写
key.cancel(); // 关闭连接
} catch (IOException e) {
e.printStackTrace();
}
}
}
Java NIO 的 Selector 在 Linux 上默认通过 epoll 实现:
Selector.open() → 创建EPollSelectorImplchannel.register(selector, OP_READ) → 调用epoll_ctl(EPOLL_CTL_ADD)selector.select() → 调用epoll_wait等待事件这种封装让Java开发者无需直接操作epoll,即可享受多路复用的高效性。
性能提升
NIO模型相比BIO的优势:
NIO2(Asynchronous IO)是Java 7引入的异步IO模型,基于回调机制,无需Selector监控事件。工作原理,NIO2的核心是AsynchronousServerSocketChannel,通过回调或Future处理IO事件:
// NIO2模型的简化流程
publicclass Nio2Connector {
public void start(int port) throws IOException {
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open();
server.bind(new InetSocketAddress(port));
// 接受连接,指定回调
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
@Override
public void completed(AsynchronousSocketChannel client, Void attachment) {
server.accept(null, this); // 继续接受新连接
// 读取数据,指定回调
ByteBuffer buffer = ByteBuffer.allocate(1024);
client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer bytesRead, ByteBuffer buf) {
buf.flip();
// 处理请求...
String response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
client.write(ByteBuffer.wrap(response.getBytes()));
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
exc.printStackTrace();
}
});
}
@Override
public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}
}
有些类似上面我们讲的 python的一些事件驱动框架 FastAPI 之类基于协程异步处理,NIO2在Tomcat中并未成为主流,目前,Tomcat的NIO2 Connector使用较少,大多数场景下NIO仍是更优选择。
APR(Apache Portable Runtime)是Tomcat的高性能IO模型,基于原生C库实现,绕过JVM的IO层,直接操作操作系统内核,同时也意味着内存使用的是堆外本地内存,不归 JVM 内存模型管理,避免了数据从内核空间到 JVM 堆空间的额外一次拷贝
工作原理: APR的核心组件:
socket、epoll等系统调用需要说明:APR/Native Connector 将在 Tomcat 10.1.x 及更高版本中移除。
Deprecated. The APR/Native Connector will be removed in Tomcat 10.1.x onwards.
性能优势: 某些场景下(本地库优势/零拷贝技术) APR相比NIO的拥有极致的性能,其优势主要为:
模型 | 底层机制 | 线程数量 | 并发能力 | 适用场景 |
|---|---|---|---|---|
BIO | 同步阻塞IO | 每连接一线程 | 低 | 低并发、调试环境 |
NIO | Java NIO(epoll) | 固定线程池 | 中高 | 常规Web服务、高并发API |
NIO2 | 异步IO(AIO) | 回调线程池 | 中 | 特定异步场景 |
APR | 原生C库(epoll) | 固定线程池 | 高 | 极致性能需求、HTTPS服务 |
Tomcat 10默认仍使用NIO模型,因其在兼容性和性能之间取得了最佳平衡。
关于 Tomcat 的网络IO 模型简单介绍到这里,回到我们最开始的那个问题,对于 Java 来说 Tomcat 是什么?有了 Web 框架为什么还需要 Tomcat?
其实上面已经给出了答案,对于 Java 来说, Tomcat 是一个 Web 服务器,或者说是 java Web Servlet 容器,或者说是 Java Web 应用的运行环境,关于第二个问题,Web 框架(如 Spring MVC)和 Tomcat Web 服务器 解决的是不同层面的问题,Web 框架聚焦 “业务逻辑”,Tomcat 聚焦 “底层通信与容器管理”,我们上面讲的IO模型,就是由Tomcat 利用系统特性实现的。
© 文中涉及参考链接内容版权归原作者所有,如有侵权请告知 :)
《深入理解Linux网络: 修炼底层内功,掌握高性能原理 (张彦飞)》
《Tomcat内核设计剖析(汪建)》
https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io
https://juejin.cn/post/7476273893821988879
https://zhuanlan.zhihu.com/p/268140269
https://cloud.tencent.com/developer/article/2442120
https://tomcat.apache.org/tomcat-10.0-doc/api/org/apache/coyote/http11/Http11AprProtocol.html
https://stackoverflow.com/questions/63169865/how-to-do-multiprocessing-in-fastapi/63171013#63171013
© 2018-至今 liruilonger@gmail.com, All rights reserved. 保持署名-非商用-相同方式共享(CC BY-NC-SA 4.0)