事件驱动 IO 与高性能网络模型:epoll/Reactor/io_uring

深入讲解高性能网络编程的 IO 模型演进,从阻塞/非阻塞到 epoll/kqueue 多路复用,剖析 Reactor/Proactor 事件驱动模式,以及 io_uring 内核异步 IO 的实现原理与性能选型。

高并发服务端的本质是把「等待」交给操作系统,把「计算」留给 CPU。从每线程一个连接的阻塞模型,到 epoll 驱动的 Reactor 模型,再到 io_uring 的全面异步,IO 模型的每一次进化都在提升单机并发上限。这篇文章系统梳理这条演进主线。

一、IO 模型演进

1.1 五种 IO 模型

UNIX 下的 IO 模型按「是否阻塞」与「是否异步」分为五种:

模型同步/异步阻塞/非阻塞典型应用
阻塞 IO同步阻塞传统 accept/read
非阻塞 IO同步非阻塞(轮询)手动忙等
IO 多路复用同步多路阻塞select/poll/epoll
信号驱动 IO同步非阻塞+信号少见
异步 IO异步非阻塞io_uring、Windows IOCP

一句话:多路复用是「一个人等 10 万个连接」,异步 IO 是「内核替你把数据搬好再通知你」。

1.2 演进路径

阻塞 IO(1 连接 1 线程,C10K 就崩)
   │  连接数上来后线程成本失控
   ▼
非阻塞 + 轮询(忙等浪费 CPU)
   │  O(n) 遍历全部 fd
   ▼
select / poll(O(n) 遍历,fd 上限 1024)
   │  每次调用需重建 fd_set,性能 O(n)
   ▼
epoll / kqueue(O(1) 事件通知,内核维护就绪队列)
   │  C10M 时代的基础
   ▼
io_uring(内核异步提交/收割,系统调用趋近于零)
   │  大量 IOPS 场景的终极形态

二、epoll 与 kqueue

2.1 epoll 核心 API

epoll 是 Linux 下的 IO 多路复用实现,基于红黑树 + 就绪链表:

#include <sys/epoll.h>

int epfd = epoll_create1(0);          // 创建 epoll 实例

struct epoll_event ev;
ev.events = EPOLLIN;                  // 监听可读
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);  // 注册

struct epoll_event events[1024];
while (1) {
    // 阻塞等待就绪事件,O(1) 返回就绪数
    int n = epoll_wait(epfd, events, 1024, -1);
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        // 处理该 fd 的可读/可写事件
        handle_event(fd, events[i].events);
    }
}

2.2 水平触发 vs 边缘触发

模式语义注意
LT(水平触发)只要缓冲区还有数据就持续通知简单、不易漏事件
ET(边缘触发)仅在状态变化(从无到有)时通知一次必须一次性读完,否则漏读
// 边缘触发必须配合非阻塞 + 循环读尽
// 否则再次 epoll_wait 不会通知剩余数据
ev.events = EPOLLIN | EPOLLET;   // 边缘触发
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);

// 读循环:直到 EAGAIN 才结束
while (1) {
    n = read(fd, buf, sizeof(buf));
    if (n == -1) {
        if (errno == EAGAIN) break;   // 读尽,下次等新事件
        return ERROR;
    }
    if (n == 0) break;                 // EOF
    process(buf, n);
}

2.3 epoll vs kqueue vs select/poll

特性selectpollepoll (Linux)kqueue (BSD/macOS)
复杂度O(n)O(n)O(1) 事件就绪O(1) 事件就绪
fd 上限1024无硬上限无无
内核态维护无(每次拷贝 fd_set)无红黑树注册事件队列
额外能力无无无EVFILT_TIMER、EVFILT_VNODE
适用老代码通用Linux 高并发macOS/BSD

一句话:epoll 用「注册 + 内核就绪链表」把等待复杂度降到 O(1),kqueue 在 BSD/macOS 上提供了同等的机制。

三、Reactor 模式

3.1 核心结构

Reactor 是事件驱动网络编程的经典模式:事件分发器(Reactor)+ 事件处理器(Handler)。

┌─────────────────────────────┐
│        Reactor(主线程)      │
│  epoll_wait 等待就绪事件      │
│         │                    │
│    ┌────┴─────┐ 分发          │
│    │ 事件分发   │─────────────┼──→ Handler A(读请求)
│    └────┬─────┘             └──→ Handler B(写响应)
│         │                      
│  注册新连接 / 修改关注事件      
└─────────────────────────────┘

3.2 单 Reactor vs 多 Reactor

单 Reactor 单线程:Reactor 负责 accept + IO + 业务
  缺点:业务阻塞会拖垮整个事件循环
  适用:Redis(纯内存、极短临界区)

单 Reactor 多线程:Reactor 分发 IO,业务丢线程池
  缺点:Reactor 仍是单点

主从 Reactor 多线程(主流):
MainReactor(accept)→ SubReactor 池(每个 SubReactor 一条线程管连接 IO)→ Worker 线程池(业务)
  适用:Netty、Redis 6+ 多线程、Node.js 子进程

3.3 Go 实现的简化 Reactor

// 简化版 Reactor 骨架(Linux epoll)
package main

import (
    "golang.org/x/sys/unix"
    "os"
    "syscall"
)

type Reactor struct {
    epfd int
}

func (r *Reactor) AddFD(fd int, events uint32) error {
    return unix.EpollCtl(r.epfd, unix.EPOLL_CTL_ADD, fd,
        &unix.EpollEvent{Events: events, Fd: int32(fd)})
}

func (r *Reactor) Run() error {
    events := make([]unix.EpollEvent, 128)
    for {
        n, err := unix.EpollWait(r.epfd, events, -1)
        if err == syscall.EINTR {
            continue
        }
        for i := 0; i < n; i++ {
            fd := int(events[i].Fd)
            if events[i].Events&unix.EPOLLIN != 0 {
                go handleRead(fd) // 分发到 goroutine
            }
            if events[i].Events&unix.EPOLLOUT != 0 {
                handleWrite(fd)
            }
        }
    }
}

func main() {
    r, _ := newReactor()
    go r.Run()
    select {} // 阻塞主协程
}

四、Proactor 模式

4.1 Reactor vs Proactor

维度ReactorProactor
读数据谁完成应用调用 read 自己读内核完成读,缓冲就绪再通知
回调触发时机可读(read 前)已读完成(read 后)
内核依赖epoll/select(同步多路复用)IOCP / io_uring(真异步)
编程模型事件驱动异步操作 + 完成回调
实现复杂度较低较高(需管理异步缓冲区生命周期)
Reactor 读数据:
应用 ─→ 内核通知「可读」 ─→ 应用 read() ─→ 拷贝完成

Proactor 读数据:
应用 ─→ 提交异步 read ─→ 内核拷贝完成 ─→ 回调「数据在缓冲区了」

一句话:Reactor 是「告诉我可以读了」,Proactor 是「读完告诉我」——后者的读取过程不占用应用线程。

4.2 谁在用 Proactor

  • Windows IOCP:Windows 上的真异步 IO,Java AIO / Netty(win 版)基于它;
  • Boost.Asio(proactor 风格):Linux 上用 epoll 模拟完成事件;
  • io_uring:让 Linux 也具备原生异步完成通知能力。

五、io_uring:内核异步 IO

5.1 为什么需要 io_uring

传统 read/write 每次都要进入内核、拷贝数据、返回。高 IOPS 场景下系统调用开销和 syscall 边界成为瓶颈。io_uring 用共享内存环形队列批量提交与收割,配合内核轮询甚至可以做到近乎零系统调用。

传统路径:
应用 ── syscall ──→ 内核处理 ── syscall 返回 ──→ 应用
   每次一个请求,两次上下文切换

io_uring 路径:
应用 ── 写 SQ(提交队列)──→ 内核(异步执行)
内核 ── 写 CQ(完成队列)──→ 应用(批量收割结果)
   请求与完成都在共享内存环形缓冲,无需逐次 syscall

5.2 基本用法

#include <liburing.h>

struct io_uring ring;
io_uring_queue_init(128, &ring, 0);   // 初始化环形队列

// 提交一个异步读
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, 0);   // 异步读
io_uring_submit(&ring);                      // 批量提交

// 收割完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);             // 等待一个完成
// cqe->res 为读取结果
io_uring_cqe_seen(&ring, cqe);              // 标记已消费

5.3 Go 侧封装(实验性)

// 使用 liburing 的 Go 绑定(github.com/iceber/iouring-go)示意
package main

import (
    iouring "github.com/iceber/iouring-go"
    "os"
)

func main() {
    engine, _ := iouring.New(128)
    defer engine.Close()

    f, _ := os.OpenFile("/tmp/data.bin", os.O_RDONLY, 0)
    buf := make([]byte, 4096)

    // 提交异步读,返回一个可等待的 future
    request, err := engine.Read(f, buf)
    if err != nil {
        panic(err)
    }
    // 等待结果(等价于完成回调)
    result := <-request.Result()
    if result.Err != nil {
        panic(result.Err)
    }
    _ = result.Len // 实际读取字节数
}

5.4 io_uring 的能力与限制

能力说明
异步读/写磁盘与网络均可异步提交
固定缓冲/固定文件提前注册,减少映射开销
多队列SQPOLL 模式下内核线程轮询,零 syscall
网络协议栈操作accept/connect/recv/send 支持异步
限制内核需 5.1+(网络操作需 5.6+);依赖 liburing

六、事件循环与用户态调度

6.1 事件循环的黄金法则

无论 epoll 还是 io_uring,事件循环都有一个铁律:循环里绝不能做阻塞操作。

❌ 错误:事件循环里做磁盘 IO / 数据库查询 / 慢业务
   一个慢 handler 阻塞 → 整个 epoll_wait 推迟 → 所有连接卡顿

✅ 正确:循环只做「收发 + 编解码 + 分发」
   慢操作丢给线程池 / 异步引擎 / 消息队列
# asyncio 中切忌在 async 函数里写同步阻塞调用
import asyncio

async def handle(reader, writer):
    # ❌ await 一个耗时同步函数会阻塞事件循环
    # data = heavy_sync_calc()

    # ✅ 用 run_in_executor 丢到线程池
    loop = asyncio.get_running_loop()
    data = await loop.run_in_executor(None, heavy_sync_calc)

6.2 用户态协程调度

Go 的 goroutine、Java 21 虚拟线程、Rust async/await,都是在用户态调度「轻量任务」,把系统线程数从「连接数」解耦为「CPU 核数」:

传统线程模型:10 万连接 = 10 万系统线程 → 栈内存、切换成本爆炸
协程模型:   10 万连接 = 10 万协程,跑在 GOMAXPROCS 个系统线程上
           阻塞协程自动让出 → M:N 调度器管理
// Go 网络服务天然高并发:每个连接一个 goroutine
func main() {
    ln, _ := net.Listen("tcp", ":8080")
    for {
        conn, _ := ln.Accept()
        go func(c net.Conn) {      // 协程调度,百万连接可行
            defer c.Close()
            buf := make([]byte, 1024)
            for {
                n, err := c.Read(buf)
                if err != nil { return }
                c.Write(buf[:n])
            }
        }(conn)
    }
}

七、性能基准与选型

7.1 各模型成本对比

模型每连接内存系统线程上下文切换单机并发量级
阻塞 1:1高(线程栈 MB 级)1 连接:1 线程频繁数千
多路复用 Reactor低(事件结构)核数级少数十万
协程 + Reactor极低(KB 级)核数级用户态切换百万级
io_uring 异步低 + 共享缓冲核数级趋近零百万级 + 高 IOPS

7.2 选型决策表

场景推荐模型技术栈
通用 Web 服务多 Reactor + 线程池Netty、Undertow、Tomcat
高吞吐微服务协程 + 事件驱动Go goroutine、Java 虚拟线程
海量长连接推送Reactor + 连接专用线程Netty、C/C++ epoll 服务
磁盘密集(对象存储)io_uring / SPDKRocksDB 生态、io_uring 引擎
Windows 生态Proactor/IOCPJava AIO、IOCP 服务
函数计算/边缘事件驱动异步Node.js、Cloudflare Workers

一句话:网络密集用 epoll/Reactor,磁盘密集用 io_uring,业务复杂用协程——三者可组合,不必非此即彼。

八、总结

技术解决的问题核心机制现状
多路复用(epoll)海量连接的高效等待内核就绪链表 O(1) 通知Linux 网络服务基石
Reactor事件分发与连接管理主从 Reactor + 线程池事实标准
Proactor/IOCP真异步 IO 完成内核完成 + 回调Windows 生态主导
io_uring降低 syscall 与拷贝开销共享内存环形队列新内核的演进方向
用户态协程解耦连接与线程M:N 调度Go/Java21/Rust 普及

从阻塞模型到 io_uring,主线始终清晰:把「等待」和「拷贝」尽量交给内核,把「计算」留给用户态。理解这套演进,你就能在 C10K、C10M 与高 IOPS 的不同战场中选对模型、看清瓶颈。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「network」更多文章

  1. 云原生网络:CNI 容器网络、Overlay/Underlay 与 Service Mesh 数据面
  2. SSE 与实时通信方案:Server-Sent Events、WebSocket 对比与选型实战
  3. 网络故障排查实战:tcpdump/Wireshark/ss/iperf 工具链与分层诊断方法论