首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Linux网络优化之内核epoll/io_uring 到Python(ASGI/WSGI) Java(BIO/NIO) 模型认知

Linux网络优化之内核epoll/io_uring 到Python(ASGI/WSGI) Java(BIO/NIO) 模型认知

作者头像
山河已无恙
发布2025-11-24 13:51:23
发布2025-11-24 13:51:23
70
举报
文章被收录于专栏:山河已无恙山河已无恙

写在前面


  • 一直想简单整理一下这部分内容,但是因为知识的广度不够,同时时间关系一直没整理,开发的项目大部分是后端系统
  • 从最开始刚入行的疑问,对于 Java 来说 Tomcat 是什么?有了 Web 框架为什么还需要 Tomcat
  • 在到后来做一些 AI 项目,思考 Python Web 规范 ASGI/WSGI 那个适合 IO/CPU 密集型
  • 以及后来研究 Linux,传输层的数据包在内核态和用户态如何流转的,为何修改一些缓存区的阈值会影响性能
  • 这些问题一直模糊不清,今天和小伙伴围绕这些问题聊聊网络IO模型,浅尝辄止,没有深入太多
  • 本篇博客是Linux 网络性能调优系列之一,理解不足小伙伴帮忙指正

对每个人而言,真正的职责只有一个:找到自我。然后在心中坚守其一生,全心全意,永不停息。所有其它的路都是不完整的,是人的逃避方式,是对大众理想的懦弱回归,是随波逐流,是对内心的恐惧 ——赫尔曼·黑塞《德米安》


技术的接触往往是先学会如何用,然后才慢慢了解起原理,但是为了由浅及深的讲解,本文反过来,先介绍基本原理从 Linux内核 出发,解析阻塞IOepollio_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 等,上面讲的 ASGI/WSGI web 规范是框架与服务器之间的契约。

socket在内核中的数据结构

在用户态,我们通过 socket() 函数创建的是一个整数句柄,但内核中实际构建了一套复杂的对象体系。这些结构的设计直接影响了后续IO操作的效率。

核心数据结构关系

代码语言:javascript
复制
// 简化的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_opsproto:封装了协议相关的操作方法(多态设计,支持不同协议)

socket 创建的源码流程

下面是一个py 写的TCP 客户端,AF_INET:表示 ipv4,SOCK_STREAM: tcp传输协议

代码语言:javascript
复制
>>> 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)时,内核执行以下步骤:

  1. 系统调用入口SYSCALL_DEFINE3(socket, ...)net/socket.c
  2. 创建socket对象sock_create()__sock_create()
    • 调用sock_alloc()分配struct socket
    • 根据协议族(如AF_INET)获取net_proto_family(协议族操作集)
  3. 协议族初始化:调用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的协议处理函数)
  4. 初始化回调sock_init_data()设置sk->sk_data_ready = sock_def_readable
代码语言:javascript
复制
// 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等回调
}

这些初始化工作为后续的connectrecv等操作奠定了基础,尤其是sk_data_ready回调,将在数据到达时触发进程唤醒。

同步阻塞IO:进程等待的低效模型

同步阻塞IO(BIO)是最直观的IO模型:用户进程调用recv后,如果数据未到达,就进入阻塞状态,直到数据就绪才被唤醒。这种模型实现简单,但在高并发场景下性能极差。

下面是一个 python 写的BIO Demo

代码语言:javascript
复制
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系统调用为例,其内核处理流程如下:

  1. 系统调用陷入内核态SYSCALL_DEFINE6(recvfrom, ...)net/socket.c
  2. 查找socket对象:通过文件描述符找到对应的struct socket
  3. 调用协议接收方法sock_recvmsg()socket->ops->recvmsg(即inet_recvmsg
    • 最终调用sk->sk_prot->recvmsg(即tcp_recvmsgnet/ipv4/tcp.c
  4. 检查接收队列
    • sk->sk_receive_queue有数据,直接拷贝到用户态缓冲区
    • 若无数据,调用sk_wait_data()进入阻塞
代码语言:javascript
复制
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()通过以下步骤将进程挂起:

  1. 定义等待队列项DEFINE_WAIT(wait)创建一个关联当前进程的等待项
    • 等待项的private字段指向当前进程(current
    • 回调函数设为autoremove_wake_function
  2. 加入等待队列prepare_to_wait(sk_sleep(sk),&wait,TASK_INTERRUPTIBLE)
    • 将等待项加入sock->sk_wq->wait队列
    • 进程状态从TASK_RUNNING改为TASK_INTERRUPTIBLE(可中断睡眠)
  3. 让出CPUschedule_timeout()触发进程调度,当前进程进入睡眠
代码语言:javascript
复制
// 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的接收队列,随后触发唤醒:

  1. 软中断处理:网卡通过DMA将数据写入内存后触发硬中断,硬中断唤醒ksoftirqd内核线程处理软中断
  2. 协议栈处理tcp_v4_rcv()解析TCP包,找到对应的sock对象,调用tcp_rcv_established()
  3. 数据入队tcp_queue_rcv()将数据包(sk_buff)加入sk->sk_receive_queue
  4. 触发回调:调用sk->sk_data_ready(sk, 0)(即sock_def_readable 初始化回调的时候绑定)
代码语言:javascript
复制
// 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的等待队列,唤醒阻塞的进程:

代码语言:javascript
复制
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的性能瓶颈

同步阻塞IO的低效源于三个核心问题:

  1. 进程上下文切换开销:阻塞时放弃CPU一次切换,唤醒时获取CPU又一次切换,每次切换耗时3-5μs
  2. 资源浪费:一个连接占用一个进程(或线程),高并发下需要大量进程,每个进程占用1-8MB内存
  3. CPU利用率低:进程大部分时间在等待IO,而非处理业务逻辑

不管是单线程还是多线程,在高并发场景(如10万连接)中,同步阻塞模型完全不可行——系统资源会被进程管理耗尽,或者请求完全陷入阻赛。

多路复用IO:epoll的高效实现

epoll 是 Linux 为解决高并发IO设计的多路复用机制,它让一个进程能同时管理成千上万的连接,大幅降低了进程切换开销。

epoll 的核心流程主要分三部分:

  • epoll_create 初始化 eventpoll 对象(红黑树 + 就绪链表 + 等待队列),返回 epfd。
  • epoll_ctl 通过红黑树维护监控关系,并注册回调,确保 fd 就绪时自动加入就绪链表。
  • epoll_wait 高效获取就绪链表中的事件(O (1) 复杂度),无事件时阻塞等待,避免轮询开销。

下面是一个 py 写的Demo

代码语言:javascript
复制
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 的核心管理结构:

代码语言:javascript
复制
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)的插入/删除/查找)

每个注册到 epollsocket 对应一个 struct epitem(红黑树节点):

代码语言:javascript
复制
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的等待队列)
};
epoll 的核心流程

首先建立好的 socket 需要注册到 epoll 对象的红黑树里面

epoll_ctl:注册socket的过程

epoll_ctl(EPOLL_CTL_ADD)用于将 socket 注册到 epoll,核心步骤如下:

  1. 创建epitem:为 socket 分配 struct epitem,初始化其关联的eventpoll和文件描述符
  2. 设置回调:为 socket 的等待队列添加一个等待项,回调函数设为ep_poll_callback
  3. 插入红黑树:将epitem插入eventpoll->rbr,完成注册

所有的注册过程都是在 ep_insert 核心函数完成

代码语言:javascript
复制
// 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的等待队列:

代码语言:javascript
复制
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的逻辑是检查就绪链表,若无就绪事件则阻塞:

  1. 检查就绪事件ep_events_available(ep)判断rdllist是否为空
    • 若不为空,直接收集事件并返回给用户态
    • 若为空,将进程加入eventpoll->wq并阻塞

对应的核心方法为 ep_poll

代码语言:javascript
复制
// 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

  1. 数据入队:和同阻塞模型一样,数据被放入sk->sk_receive_queue
  2. 触发回调sock_def_readable唤醒 socket 等待队列上的回调,包括 ep_poll_callback
  3. 加入就绪链表ep_poll_callbackepitem加入eventpoll->rdllist
  4. 唤醒进程:若eventpoll->wq有等待的进程(即epoll_wait阻塞的进程),则唤醒它
代码语言:javascript
复制
// 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的优势:多路复用为什么快

epoll 相比同步阻塞IO的核心优势:

  1. 多路复用:一个进程管理多个连接,减少进程数量和切换开销,极⼤程度地减少了⽆⽤的进程上下⽂切换。
  2. 事件驱动:仅在事件就绪时处理,避免无效等待,让进程更专注地处理⽹络请求。
  3. 高效查找:通过就绪链表(O(1))获取就绪事件,无需遍历所有连接
  4. 灵活触发:支持水平触发(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 中取回结果。
代码语言:javascript
复制

应用程序(Application)
  ├─ 将新请求放入提交队列的队尾
  └─ 从完成队列的队头获取响应
       队头(head)                                             队头(head)
提交队列(Submission Queue)   共享内存(Shared Memory)     完成队列(Completion Queue)
       队尾(tail)                                             队尾(tail)

内核(Kernel)
  ├─ 从提交队列的队头获取任务并执行
  └─ 将响应结果放入完成队列的队尾

这两个队列通过内存映射(mmap)方式实现共享,避免了传统 I/O 模型中的数据拷贝开销

无锁设计:队列采用单生产者 - 单消费者模型:

  • SQ:用户程序是生产者,内核是消费者
  • CQ:内核是生产者,用户程序是消费者

这种设计使得队列访问无需加锁,仅使用内存屏障(memory barriers)进行同步,极大提高了性能。

系统调用优化,io_uring 仅提供三个核心系统调用:

代码语言:javascript
复制
// 初始化 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);

基本流程:

  1. 应用将 SQE 放入 SQ。
  2. 应用通过一次 io_uring_enter 系统调用,通知内核有新的任务需要处理(或者内核通过轮询模式直接获取,实现零系统调用)。
  3. 内核从 SQ 中取出 SQE 并执行 I/O 操作。
  4. 操作完成后,内核将 CQE 放入 CQ。
  5. 应用从 CQ 中取出 CQE,确认操作完成状态。

liburing 库为 io_uring 的使用提供了便捷方式:它隐藏了部分底层复杂度,并提供各类函数用于准备所有类型的 I/O 操作,以便提交执行。

代码语言:javascript
复制
//#用户进程创建 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 中经典的回声服务器代码大致如下:

代码语言:javascript
复制
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 的服务器伪代码(省略了基础初始化代码和错误处理逻辑):

代码语言:javascript
复制
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 操作做准备。

  1. 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)” 串联;但实际上,我们已经能将 “读请求” 与 “新的接收连接请求” 一起提交,因此这种串联可能不会带来明显收益。

我们可以同时提交多个独立操作—— 例如,将 “写操作” 与后续的 “读操作” 合并提交。

代码语言:javascript
复制
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_submitprep 阶段仅在用户空间准备请求,submit 阶段批量提交多个请求(如同时提交 acceptreadwrite 等),仅触发一次系统调用即可处理多个操作。

内核IO模型对比

除了阻塞IOepoll,Linux还支持select、poll等多路复用模型。以下是主要模型的对比:

模型

最大连接数

就绪事件查找

重复注册

内核用户拷贝

适用场景

select

1024(FD_SETSIZE)

遍历(O(N))

需要

跨平台、低并发

poll

无限制

遍历(O(N))

需要

跨平台、中低并发

epoll

无限制

就绪链表(O(1))

无需

可选(零拷贝)

高并发、Linux专属

阻塞IO

有限(进程数)

低并发、简单场景

io_uring

无限制(队列深度 SQ/CQ 大小)

完成队列遍历(批量处理)

可选(注册文件/缓冲区)

可选(固定缓冲区零拷贝/零拷贝)

高并发、高吞吐量、Linux专属(文件/网络混合I/O)

selectpoll 两者都是 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,中断数据流转。

关于设计到的内核参数,小伙伴可以看看我之前的博文

代码语言:javascript
复制
[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框架:从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

代码语言:javascript
复制
# 
#!/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()

服务器与应用的交互流程:

  1. 服务器接收请求,构建environ字典(包含HTTP方法、路径、 headers等)
  2. 服务器调用应用程序,传入environstart_response
  3. 应用程序处理业务逻辑,调用start_response设置状态码和响应头
  4. 应用程序返回响应体(迭代器),服务器将其发送给客户端

WSGI的IO模型限制

WSGI基于同步阻塞IO,其服务器(如Gunicorn、uWSGI)通常采用"预派生子进程+线程池"的模型:Gunicorn的worker模式

  • sync:每个请求由一个线程处理,阻塞IO(默认)
  • gevent:基于协程的异步模式
  • eventlet:类似gevent,依赖协程

同步模式下,每个请求会占用一个线程,直到处理完成。当请求涉及IO操作(如数据库查询、网络调用)时,线程会阻塞等待,导致资源浪费。

代码语言:javascript
复制
# 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默认1-4个worker,每个worker 10-20个线程)
  • 每个线程/进程占用内存(约几MB),数量过多会导致内存耗尽
  • IO阻塞时线程/进程闲置,CPU利用率低

例如,Gunicornsync 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
  • 分阶段处理请求(连接建立、请求接收、响应发送)
代码语言:javascript
复制
# 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多路复用机制:

  • 事件循环:单线程(或多线程)中的无限循环,处理IO事件和任务调度
  • 协程(Coroutine):轻量级"线程",由事件循环调度,IO操作时主动让出CPU
  • 非阻塞IO:所有IO操作(如网络、文件)均为非阻塞,通过回调或Future通知完成

下面的Demo中,await asyncio.sleep(1)会让协程让出CPU,事件循环可以同时处理其他请求。10个并发请求的总耗时约1秒(并行处理),效率远高于WSGI。

代码语言:javascript
复制
# 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机制:

  1. 注册事件:服务器启动时,将监听socket注册到epoll,关注EPOLLIN事件(新连接)
  2. 等待事件:调用epoll_wait等待事件就绪(非阻塞或超时)
  3. 处理事件
    • 新连接事件:调用accept获取客户端socket,注册到epoll(关注EPOLLIN
    • 数据可读事件:读取请求数据,创建协程处理
    • 数据可写事件:发送响应数据
代码语言:javascript
复制
# 简化的事件循环伪代码(模拟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的核心优势:

  1. 更高的并发量:单个事件循环线程可处理数万连接(基于epoll)
  2. 更低的资源消耗:协程比线程/进程轻量(内存占用约KB级)
  3. 更好的响应延迟:IO操作时不阻塞线程,资源利用率更高
  4. 支持长连接:原生支持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调用,每个连接占用一个线程
代码语言:javascript
复制
// 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类似:

  • 每个连接占用一个线程,高并发下需要大量线程(如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线程池:处理就绪事件(读写)和业务逻辑
代码语言:javascript
复制
// 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();
        }
    }
}
NIO对epoll的封装

Java NIO 的 Selector 在 Linux 上默认通过 epoll 实现:

  • Selector.open() → 创建EPollSelectorImpl
  • channel.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事件:

代码语言:javascript
复制
// 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模型:原生库的极致性能

APR(Apache Portable Runtime)是Tomcat的高性能IO模型,基于原生C库实现,绕过JVM的IO层,直接操作操作系统内核,同时也意味着内存使用的是堆外本地内存,不归 JVM 内存模型管理,避免了数据从内核空间到 JVM 堆空间的额外一次拷贝

工作原理: APR的核心组件:

  • 原生Socket:使用C语言的socketepoll等系统调用
  • 内存池:高效的内存管理,减少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)

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-11-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 山河已无恙 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 写在前面
  • 一、为什么网络IO模型如此重要?
  • 二、Linux内核IO模型:阻塞与多路复用、异步IO的底层逻辑
    • socket在内核中的数据结构
    • 同步阻塞IO:进程等待的低效模型
      • 同步阻塞的核心流程
      • 同步阻塞IO的性能瓶颈
    • 多路复用IO:epoll的高效实现
      • epoll 的核心流程
      • epoll的优势:多路复用为什么快
    • 异步IO: io_uring 统一异步 I/O 框架
    • 内核IO模型对比
  • 三、Python Web框架:从WSGI到ASGI的IO模型演进
    • WSGI:同步阻塞的时代
    • ASGI:异步非阻塞的革新
  • 四、Java Tomcat:从BIO到NIO的线程模型演进
    • BIO模型:同步阻塞的局限性
    • NIO模型:非阻塞与多路复用
      • NIO对epoll的封装
    • NIO2(AIO)模型:异步IO的尝试
    • APR模型:原生库的极致性能
    • Tomcat线程模型对比
  • 博文部分内容参考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档