高并发服务端的本质是把「等待」交给操作系统,把「计算」留给 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
| 特性 | select | poll | epoll (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
| 维度 | Reactor | Proactor |
|---|---|---|
| 读数据谁完成 | 应用调用 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 / SPDK | RocksDB 生态、io_uring 引擎 |
| Windows 生态 | Proactor/IOCP | Java 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 的不同战场中选对模型、看清瓶颈。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。