Linux网络优化之从Linux内核epoll/io_uring 到Python(ASGI/WSGI)及Java Tomcat(BIO/NIO)网络IO模型认知
对每个人而言,真正的职责只有一个:找到自我。然后在心中坚守其一生,全心全意,永不停息。所有其它的路都是不完整的,是人的逃避方式,是对大众理想的懦弱回归,是随波逐流,是对内心的恐惧 ——赫尔曼·黑塞《德米安》
写在前面
- 一直想简单整理一下这部分内容,但是因为知识的广度不够,同时时间关系一直没整理,开发的项目大部分是后端系统
- 从最开始刚入行的疑问,对于
Java来说Tomcat是什么?有了Web框架为什么还需要Tomcat? - 在到后来做一些
AI项目,思考Python Web规范ASGI/WSGI那个适合IO/CPU密集型 - 以及后来研究
Linux,传输层的数据包在内核态和用户态如何流转的,为何修改一些缓存区的阈值会影响性能。 - 这些问题一直模糊不清,今天和小伙伴围绕这些问题聊聊网络IO模型,浅尝辄止,没有深入太多
- 本篇博客是Linux 网络性能调优系列之一,理解不足小伙伴帮忙指正
对每个人而言,真正的职责只有一个:找到自我。然后在心中坚守其一生,全心全意,永不停息。所有其它的路都是不完整的,是人的逃避方式,是对大众理想的懦弱回归,是随波逐流,是对内心的恐惧 ——赫尔曼·黑塞《德米安》
技术的接触往往是先学会如何用,然后才慢慢了解起原理,但是为了由浅及深的讲解,本文反过来,先介绍基本原理从 Linux内核 出发,解析阻塞IO与epoll、io_uring的本质,再延伸至高级语言框架的IO模型设计,所以对于前面讲到的问题,最少有个大概认知才适合阅读本文。
网络IO模型 是所有后端服务的基础,它决定了程序如何处理网络请求、利用系统资源以及支撑并发的能力。从Linux内核的底层实现到Python的WSGI/ASGI规范,再到Java Tomcat的线程模型,每一层抽象都蕴含着对IO效率的极致追求。
一、为什么网络IO模型如此重要?
在后端开发中,我们不用深入网络 IO 模型也能写出可运行的代码,核心是 Web 服务器( Tomcat、Nginx、Uvicorn等)已经封装了底层网络IO 逻辑,帮我们处理了 “如何高效监听连接、接收数据、调度进程” 这些复杂问题,让开发者直接使用Web框架聚焦业务逻辑。
但是我们可能会面临这样的问题:
- 为什么同样的硬件配置,有些服务能轻松支撑10万并发,而有些却在几千连接下就崩溃?
- 为什么同样的代码,同样的web框架,使用了不同的Web服务器,高并发下,有些会阻赛一个请求处理中无法处理下一个,有些会都吞下去把自己撑死。
答案的核心在于网络IO模型——它决定了程序如何与操作系统协作处理网络数据,直接影响CPU利用率、内存消耗和响应延迟的请求的吞吐量。
以一个简单的Web请求为例,从用户发起起到到服务端返回响应,数据需要经历:
- 网卡接收数据(硬件中断+软中断)
- 内核协议栈处理(TCP/IP解析)
- 用户态程序读取数据(数据拷贝+系统调用)
- 业务逻辑处理(如数据库查询)
- 数据返回(用户态→内核态→网卡)
每一个环节的效率都与IO模型密切相关。低效的模型会让CPU在进程上下文切换、等待IO中浪费大量时间;而高效的模型能让CPU专注于业务处理,最大化资源利用率。我们先从最基础的内核网络IO开始,这是一切的基石
二、Linux内核IO模型:阻塞与多路复用、异步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 等
socket在内核中的数据结构
在用户态,我们通过 socket() 函数创建的是一个整数句柄,但内核中实际构建了一套复杂的对象体系。这些结构的设计直接影响了后续IO操作的效率。
核心数据结构关系
1 | // 简化的socket内核结构关系 |
结构体描述:
struct socket:用户态与内核态交互的桥梁,关联文件对象和核心套接字struct sock:包含TCP/UDP等协议的核心状态,是IO操作的实际载体proto_ops与proto:封装了协议相关的操作方法(多态设计,支持不同协议)
socket 创建的源码流程
下面是一个py 写的TCP 客户端,AF_INET:表示 ipv4,SOCK_STREAM: tcp传输协议
1 | from socket import socket, AF_INET, SOCK_STREAM |
当调用socket(AF_INET, SOCK_STREAM, 0)时,内核执行以下步骤:
- 系统调用入口:
SYSCALL_DEFINE3(socket, ...)(net/socket.c) - 创建socket对象:
sock_create()→__sock_create()- 调用
sock_alloc()分配struct socket - 根据协议族(如AF_INET)获取
net_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
1 | // inet_create关键代码(net/ipv4/af_inet.c) |
这些初始化工作为后续的connect、recv等操作奠定了基础,尤其是sk_data_ready回调,将在数据到达时触发进程唤醒。
同步阻塞IO:进程等待的低效模型
同步阻塞IO(BIO)是最直观的IO模型:用户进程调用recv后,如果数据未到达,就进入阻塞状态,直到数据就绪才被唤醒。这种模型实现简单,但在高并发场景下性能极差。
下面是一个 python 写的BIO Demo
1 | from socketserver import BaseRequestHandler, TCPServer |
同步阻塞IO模型也分为 单线程和多线程阻塞IO模型,上面的 Demo 是单线程,同一时间只能处理一个客户端连接,新连接会被放入监听队列等待,直到当前连接处理完成。处理连接时(handle 方法执行期间),服务器会阻塞在 recv 调用上(等待客户端发送数据),期间无法接受新连接。
同步阻塞的核心流程
以recvfrom系统调用为例,其内核处理流程如下:
- 系统调用陷入内核态:
SYSCALL_DEFINE6(recvfrom, ...)(net/socket.c) - 查找socket对象:通过文件描述符找到对应的
struct socket - 调用协议接收方法:
sock_recvmsg()→socket->ops->recvmsg(即inet_recvmsg)- 最终调用
sk->sk_prot->recvmsg(即tcp_recvmsg,net/ipv4/tcp.c)
- 最终调用
- 检查接收队列:
- 若
sk->sk_receive_queue有数据,直接拷贝到用户态缓冲区 - 若无数据,调用
sk_wait_data()进入阻塞
- 若
1 | int tcp_recvmsg(...) { |
进程阻塞的底层实现
sk_wait_data()通过以下步骤将进程挂起:
- 定义等待队列项:
DEFINE_WAIT(wait)创建一个关联当前进程的等待项- 等待项的
private字段指向当前进程(current) - 回调函数设为
autoremove_wake_function
- 等待项的
- 加入等待队列:
prepare_to_wait(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE)- 将等待项加入
sock->sk_wq->wait队列 - 进程状态从
TASK_RUNNING改为TASK_INTERRUPTIBLE(可中断睡眠)
- 将等待项加入
- 让出CPU:
schedule_timeout()触发进程调度,当前进程进入睡眠
1 | // sk_wait_data核心代码(net/core/sock.c) |
此时,进程不再占用CPU,直到数据到达后被唤醒。
数据到达后的唤醒流程
当网卡收到数据并经内核处理后,数据会被放入sock的接收队列,随后触发唤醒:
- 软中断处理:网卡通过DMA将数据写入内存后触发硬中断,硬中断唤醒
ksoftirqd内核线程处理软中断 - 协议栈处理:
tcp_v4_rcv()解析TCP包,找到对应的sock对象,调用tcp_rcv_established() - 数据入队:
tcp_queue_rcv()将数据包(sk_buff)加入sk->sk_receive_queue - 触发回调:调用
sk->sk_data_ready(sk, 0)(即sock_def_readable初始化回调的时候绑定)
1 | // tcp_rcv_established中的唤醒逻辑(net/ipv4/tcp_input.c) |
sock_def_readable会遍历sock的等待队列,唤醒阻塞的进程:
1 | static void sock_def_readable(struct sock *sk, int len) { |
wake_up_interruptible_sync_poll最终调用try_to_wake_up(),将进程状态改回TASK_RUNNING,使其重新参与CPU调度。
同步阻塞IO的性能瓶颈
同步阻塞IO的低效源于三个核心问题:
- 进程上下文切换开销:阻塞时放弃CPU一次切换,唤醒时获取CPU又一次切换,每次切换耗时3-5μs
- 资源浪费:一个连接占用一个进程(或线程),高并发下需要大量进程,每个进程占用1-8MB内存
- CPU利用率低:进程大部分时间在等待IO,而非处理业务逻辑
不管是单线程还是多线程,在高并发场景(如10万连接)中,同步阻塞模型完全不可行——系统资源会被进程管理耗尽,或者请求完全陷入阻赛。
多路复用IO:epoll的高效实现
epoll 是 Linux 为解决高并发IO设计的多路复用机制,它让一个进程能同时管理成千上万的连接,大幅降低了进程切换开销。
epoll 的核心流程主要分三部分:
epoll_create初始化eventpoll对象(红黑树 + 就绪链表 + 等待队列),返回 epfd。epoll_ctl通过红黑树维护监控关系,并注册回调,确保 fd 就绪时自动加入就绪链表。epoll_wait高效获取就绪链表中的事件(O (1) 复杂度),无事件时阻塞等待,避免轮询开销。
下面是一个 py 写的Demo
1 | import socket |
epoll的核心数据结构
调用epoll_create()时,内核创建struct eventpoll对象,作为 epoll 的核心管理结构:
1 | struct eventpoll { |
wq:当没有就绪事件时,调用epoll_wait的进程会阻塞在此队列rdllist:存储已就绪的事件,避免遍历整个红黑树rbr:通过红黑树高效管理注册的socket(支持O(logN)的插入/删除/查找)
每个注册到 epoll 的 socket 对应一个 struct epitem(红黑树节点):
1 | struct epitem { |
epoll 的核心流程
首先建立好的 socket 需要注册到 epoll 对象的红黑树里面
epoll_ctl:注册socket的过程
epoll_ctl(EPOLL_CTL_ADD)用于将 socket 注册到 epoll,核心步骤如下:
- 创建epitem:为 socket 分配
struct epitem,初始化其关联的eventpoll和文件描述符 - 设置回调:为 socket 的等待队列添加一个等待项,回调函数设为
ep_poll_callback - 插入红黑树:将epitem插入
eventpoll->rbr,完成注册
所有的注册过程都是在 ep_insert 核心函数完成
1 | // ep_insert核心代码(fs/eventpoll.c) |
其中 ep_ptable_queue_proc会创建一个struct eppoll_entry(等待队列项),将其回调设为ep_poll_callback,并加入socket的等待队列:
1 | static void ep_ptable_queue_proc(...) { |
此时,socket 的等待队列中不仅有ep_poll_callback,还可能有其他等待项(如阻塞IO的进程),但 epoll 通过回调机制实现了高效的事件通知。
epoll_wait:等待就绪事件
epoll_wait的逻辑是检查就绪链表,若无就绪事件则阻塞:
- 检查就绪事件:
ep_events_available(ep)判断rdllist是否为空- 若不为空,直接收集事件并返回给用户态
- 若为空,将进程加入
eventpoll->wq并阻塞
对应的核心方法为 ep_poll
1 | // ep_poll核心代码(fs/eventpoll.c) |
ep_send_events会遍历rdllist,将就绪的socket信息拷贝到用户态缓冲区,完成一次事件通知。
数据到达:epoll的回调与唤醒
当 socket 收到数据时,触发流程与阻塞模型不同,关键在于ep_poll_callback:
- 数据入队:和同阻塞模型一样,数据被放入
sk->sk_receive_queue - 触发回调:
sock_def_readable唤醒 socket 等待队列上的回调,包括ep_poll_callback - 加入就绪链表:
ep_poll_callback将epitem加入eventpoll->rdllist - 唤醒进程:若
eventpoll->wq有等待的进程(即epoll_wait阻塞的进程),则唤醒它
1 | // ep_poll_callback核心代码(fs/eventpoll.c) |
这个过程的高效之处在于:数据到达时不会直接唤醒用户进程,而是先将事件加入就绪链表,等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的优势:多路复用为什么快
epoll 相比同步阻塞IO的核心优势:
- 多路复用:一个进程管理多个连接,减少进程数量和切换开销,极⼤程度地减少了⽆⽤的进程上下⽂切换。
- 事件驱动:仅在事件就绪时处理,避免无效等待,让进程更专注地处理⽹络请求。
- 高效查找:通过就绪链表(O(1))获取就绪事件,无需遍历所有连接
- 灵活触发:支持水平触发(LT)和边缘触发(ET),适应不同场景
这些特性使 epoll 能轻松支撑百万级并发连接,成为高性能服务的首选 IO模型,前面三个我们上文中已经描述,第四个这里的水平触发和边缘触发,是指内核监控文件描述符(如 socket),当有数据可读/可写时,通知应用程序处理。两者的差异只在 通知的规则 上。
水平触发(LT,Level Trigger):默认模式,“不处理完就一直提醒”,绝大多数常规场景(如 Web 服务器、数据库连接),尤其是对编程复杂度敏感、无需极致性能的场景(比如 Nginx 默认用 LT 模式)。边缘触发(ET,Edge Trigger):高效模式,“只提醒一次,过时不候”,极致性能需求的高并发场景(如百万连接的服务器、实时通信系统),比如 Redis、高吞吐的网关服务(需开发者自己处理好数据读取逻辑,避免遗漏)。
异步IO: io_uring 统一异步 I/O 框架
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 中取回结果。
1 |
|
这两个队列通过内存映射(mmap)方式实现共享,避免了传统 I/O 模型中的数据拷贝开销。
无锁设计:队列采用单生产者 - 单消费者模型:
SQ:用户程序是生产者,内核是消费者CQ:内核是生产者,用户程序是消费者
这种设计使得队列访问无需加锁,仅使用内存屏障(memory barriers)进行同步,极大提高了性能。
系统调用优化,io_uring 仅提供三个核心系统调用:
1 | // 初始化 io_uring 实例 |
基本流程:
- 应用将 SQE 放入 SQ。
- 应用通过一次
io_uring_enter系统调用,通知内核有新的任务需要处理(或者内核通过轮询模式直接获取,实现零系统调用)。 - 内核从 SQ 中取出 SQE 并执行 I/O 操作。
- 操作完成后,内核将 CQE 放入 CQ。
- 应用从 CQ 中取出 CQE,确认操作完成状态。
liburing 库为 io_uring 的使用提供了便捷方式:它隐藏了部分底层复杂度,并提供各类函数用于准备所有类型的 I/O 操作,以便提交执行。
1 | //#用户进程创建 io_uring 的代码示例如下 |
通过编写一个简单的回声服务器,使用liburing API来实现网络I/O。然后我们将了解如何最小化高速率并发工作负载所需的系统调用数量
https://developers.redhat.com/articles/2023/04/12/why-you-should-use-iouring-network-io
伯克利软件发行版(Berkeley Software Distribution,简称 BSD)Unix 中经典的回声服务器代码大致如下:
1 | client_fd = accept(listen_fd, &client_addr, &client_addrlen); |
处理每个客户端会话至少需要 5 次系统调用:accept(接收连接)、read(读数据)、write(写数据)、再次 read(检测 EOF),以及最终的close(关闭连接)。
若直接将上述逻辑迁移到 io_uring,会得到一个 “异步服务器”—— 它每次仅提交一个操作,等待该操作完成后再提交下一个。以下是基于 io_uring 的服务器伪代码(省略了基础初始化代码和错误处理逻辑):
1 | add_accept_request(listen_socket, &client_addr, &client_addr_len); |
在这个 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所需的参数(如监听套接字、客户端地址结构体指针等); - 将该 SQE 加入
io_uring的提交队列(SQ)中。
- 初始化一个
这个过程完全在用户空间完成,不涉及内核态切换,因此没有系统调用开销。
真正触发系统调用的时机
只有当调用 io_uring_submit(或底层的 io_uring_enter 系统调用)时,才会将提交队列(SQ)中已准备好的所有请求(包括通过 io_uring_prep_accept 准备的 accept 请求)批量提交给内核,此时才会触发一次系统调用(io_uring_enter)。
借助 io_uring 的 “固定文件”(fixed file)新特性,将 “接收连接(accept)” 与 “读操作(read)” 串联;但实际上,我们已经能将 “读请求” 与 “新的接收连接请求” 一起提交,因此这种串联可能不会带来明显收益。
我们可以同时提交多个独立操作—— 例如,将 “写操作” 与后续的 “读操作” 合并提交。
1 | while (1) { |
这样一来,处理每个客户端请求所需的系统调用次数可减少至 3 次,这里是关键区别:传统 accept** 每次调用都会触发一次系统调用,直接进入内核处理; io_uring_prep_accept + io_uring_submit 在prep 阶段仅在用户空间准备请求,submit 阶段批量提交多个请求(如同时提交 accept、read、write 等),仅触发一次系统调用即可处理多个操作。
内核IO模型对比
除了阻塞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,中断数据流转。
关于设计到的内核参数,小伙伴可以看看我之前的博文
1 | [root@developer ~]# sysctl -a | grep mem |
三、Python Web框架:从WSGI到ASGI的IO模型演进
Python 的 Web 框架经历了从同步到异步的演进,其底层IO模型的变化直接影响了框架的性能和并发能力。WSGI 规范基于同步阻塞IO,而 ASGI 则引入了异步IO和事件循环,适配epoll等高效内核机制。
WSGI:同步阻塞的时代
WSGI(Web Server Gateway Interface)是Python Web框架的标准接口,定义了Web服务器与应用程序之间的交互方式。它诞生于2003年,基于同步阻塞IO模型,至今仍是许多框架(如Django、Flask)的基础。
什么是 WSGI ?: https://wsgi.readthedocs.io/en/latest/what.html
WSGI规范定义了两个核心角色:
- 服务器(Server):接收HTTP请求,将其转换为WSGI环境,调用应用程序
- 应用(Application):一个可调用对象(函数/类),接收
environ(请求信息)和start_response(响应回调),返回响应体
下面是一个 WSGI应用示例 的Demo
1 | # |
服务器与应用的交互流程:
- 服务器接收请求,构建
environ字典(包含HTTP方法、路径、 headers等) - 服务器调用应用程序,传入
environ和start_response - 应用程序处理业务逻辑,调用
start_response设置状态码和响应头 - 应用程序返回响应体(迭代器),服务器将其发送给客户端
WSGI的IO模型限制
WSGI基于同步阻塞IO,其服务器(如Gunicorn、uWSGI)通常采用”预派生子进程+线程池”的模型:Gunicorn的worker模式:
sync:每个请求由一个线程处理,阻塞IO(默认)gevent:基于协程的异步模式eventlet:类似gevent,依赖协程
同步模式下,每个请求会占用一个线程,直到处理完成。当请求涉及IO操作(如数据库查询、网络调用)时,线程会阻塞等待,导致资源浪费。
1 | # Flask(WSGI框架)的同步阻塞示例 |
在这个例子中,time.sleep(1)会阻塞整个线程,期间无法处理其他请求。若同时有10个请求,总耗时约10秒(串行处理)。
WSGI服务器的并发瓶颈
WSGI服务器的并发能力受限于线程/进程数量:
- 进程/线程数量有限(如Gunicorn默认1-4个worker,每个worker 10-20个线程)
- 每个线程/进程占用内存(约几MB),数量过多会导致内存耗尽
- IO阻塞时线程/进程闲置,CPU利用率低
例如,Gunicorn 的 sync worker 在处理 1000 个并发请求时,需要 1000 个线程,这会导致大量的上下文切换和内存消耗,性能急剧下降。
ASGI:异步非阻塞的革新
ASGI(Asynchronous Server Gateway Interface)是Python 3.5+推出的异步Web接口,旨在解决WSGI的同步限制,支持异步IO和长连接(如WebSocket)。它兼容WSGI,并引入了事件循环和异步处理机制。
ASGI引入了三个核心角色:
- 服务器(Server):接收请求,管理连接生命周期,驱动事件循环
- 应用(Application):异步可调用对象,处理请求并返回响应
- 中间件(Middleware):位于服务器和应用之间,处理请求/响应的转换
与WSGI的主要区别:
- 支持异步函数(
async def) - 基于事件循环(如
uvloop,封装epoll/kqueue) - 支持双向通信(如
WebSocket) - 分阶段处理请求(连接建立、请求接收、响应发送)
1 | # ASGI应用示例 |
ASGI的事件循环与IO模型
ASGI服务器(如Uvicorn、Hypercorn)基于异步事件循环,底层使用epoll(Linux)、kqueue(BSD)等高效IO多路复用机制:
- 事件循环:单线程(或多线程)中的无限循环,处理IO事件和任务调度
- 协程(Coroutine):轻量级”线程”,由事件循环调度,IO操作时主动让出CPU
- 非阻塞IO:所有IO操作(如网络、文件)均为非阻塞,通过回调或Future通知完成
下面的Demo中,await asyncio.sleep(1)会让协程让出CPU,事件循环可以同时处理其他请求。10个并发请求的总耗时约1秒(并行处理),效率远高于WSGI。
1 | # FastAPI(ASGI框架)的异步示例 |
以 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机制:
- 注册事件:服务器启动时,将监听socket注册到epoll,关注
EPOLLIN事件(新连接) - 等待事件:调用
epoll_wait等待事件就绪(非阻塞或超时) - 处理事件:
- 新连接事件:调用
accept获取客户端socket,注册到epoll(关注EPOLLIN) - 数据可读事件:读取请求数据,创建协程处理
- 数据可写事件:发送响应数据
- 新连接事件:调用
1 | # 简化的事件循环伪代码(模拟uvloop) |
ASGI相比WSGI的核心优势:
- 更高的并发量:单个事件循环线程可处理数万连接(基于epoll)
- 更低的资源消耗:协程比线程/进程轻量(内存占用约KB级)
- 更好的响应延迟:IO操作时不阻塞线程,资源利用率更高
- 支持长连接:原生支持WebSocket、HTTP/2等双向通信协议
这里同样看下最开始的那个问题,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 密集型是两种不同场景,即使通过队列等方式做了处理,解决的也只是吞吐量,和处理速度没有关系。往往看上去处理完了,会发现程序内部积累了大量的协程,吃进去了,但是消化不了。
四、Java Tomcat:从BIO到NIO的线程模型演进
Tomcat 是 Java生态中最流行的 Web服务器,其性能优化历程伴随着IO模型的演进。从最初的BIO(阻塞IO)到NIO(非阻塞IO),再到APR(Apache Portable Runtime)
Tomcat的核心组件包括:
- Connector:处理网络连接,负责接收请求和发送响应
- Engine:处理请求的核心引擎,包含Host、Context等容器
- Container:管理Servlet的生命周期,处理业务逻辑
其中,Connector是IO模型的核心实现者,不同的IO模型对应不同的Connector配置:
BIO:阻塞IO模型(Tomcat 7及之前默认)NIO:非阻塞IO模型(Tomcat 8及之后默认)NIO2:异步IO模型(Java NIO.2的AIO)APR:基于原生库的IO模型(最高性能,Tomcat10.0 废弃)
BIO模型:同步阻塞的局限性
BIO(Blocking IO)是Tomcat最早采用的IO模型,基于同步阻塞IO实现。BIO Connector的核心组件:
- Acceptor线程:监听端口,接收新连接,将Socket交给Worker线程
- Worker线程池:处理连接的读写和Servlet调用,每个连接占用一个线程
1 | // BIO模型的简化流程 |
性能瓶颈 BIO模型的问题与Linux的同步阻塞IO类似:
- 每个连接占用一个线程,高并发下需要大量线程(如10万连接需10万线程)
- 线程切换开销大(JVM线程切换约1-2μs/次)
- IO阻塞时线程闲置,CPU利用率低
Tomcat的BIO模型在并发量超过1000时就会出现明显的性能下降,甚至OOM(内存溢出)。
NIO模型:非阻塞与多路复用
NIO(Non-blocking IO)是Tomcat 8引入的默认模型,基于Java NIO实现,底层依赖Linux的epoll(或Windows的IOCP)。
核心组件与工作原理
NIO Connector的核心组件:
- Acceptor线程:接收新连接,注册到Selector
- Selector线程:多路复用器,监控注册的Channel(Socket)的IO事件
- Worker线程池:处理就绪事件(读写)和业务逻辑
1 | // NIO模型的简化流程 |
NIO对epoll的封装
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的优势:
- 单Selector线程可处理数万连接(基于epoll)
- 线程数量大幅减少(Worker线程池大小通常为CPU核心数*2)
- IO操作非阻塞,线程利用率高
NIO2(AIO)模型:异步IO的尝试
NIO2(Asynchronous IO)是Java 7引入的异步IO模型,基于回调机制,无需Selector监控事件。工作原理,NIO2的核心是AsynchronousServerSocketChannel,通过回调或Future处理IO事件:
1 | // NIO2模型的简化流程 |
有些类似上面我们讲的 python的一些事件驱动框架 FastAPI 之类基于协程异步处理,NIO2在Tomcat中并未成为主流,目前,Tomcat的NIO2 Connector使用较少,大多数场景下NIO仍是更优选择。
APR模型:原生库的极致性能
APR(Apache Portable Runtime)是Tomcat的高性能IO模型,基于原生C库实现,绕过JVM的IO层,直接操作操作系统内核,同时也意味着内存使用的是堆外本地内存,不归 JVM 内存模型管理,避免了数据从内核空间到 JVM 堆空间的额外一次拷贝
工作原理: APR的核心组件:
- 原生Socket:使用C语言的
socket、epoll等系统调用 - 内存池:高效的内存管理,减少JVM GC开销
- OpenSSL集成:原生支持HTTPS,性能优于Java实现
需要说明:APR/Native Connector 将在 Tomcat 10.1.x 及更高版本中移除。
Deprecated.
The APR/Native Connector will be removed in Tomcat 10.1.x onwards.
性能优势: 某些场景下(本地库优势/零拷贝技术) APR相比NIO的拥有极致的性能,其优势主要为:
- 减少JVM与内核的交互开销(原生库直接调用系统调用)
- 更高效的内存管理(避免JVM堆外内存拷贝)
- HTTPS场景下性能提升显著(原生OpenSSL)
Tomcat线程模型对比
| 模型 | 底层机制 | 线程数量 | 并发能力 | 适用场景 |
|---|---|---|---|---|
| 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)
Linux网络优化之从Linux内核epoll/io_uring 到Python(ASGI/WSGI)及Java Tomcat(BIO/NIO)网络IO模型认知
1.Linux网络调优之内核网络栈发包收包认知
2.Linux 性能调优之 OOM Killer 的认知与观测
3.为什么进程的物理内存占用(RSS)不停增长? 利用 BPF 跟踪、统计 Linux 缺页异常
4.如何使用 BPF 监控 Linux 用户态小内存分配:Linux 内存调优之 BPF 分析用户态小内存分配
5.Linux 内存调优之 BPF 分析用户态 mmap 大内存分配
6.如何使用 BPF 分析 Linux 内存泄漏,Linux 性能调优之 BPF 分析内核态、用户态内存泄漏
7.认识 Linux 内存构成:Linux 内存调优之页表、TLB、缺页异常、大页认知
8.Linux 内存调优之如何限制进程、系统级别内存资源

