I/O 的瓶颈在哪里
I/O 的瓶颈在哪里
想象一家餐厅的服务员。方案 A:每个服务员只负责一张桌子,客人举手点菜,服务员就站在旁边等,等厨师做好再端上去,全程不能离开。100 桌就需要 100 个服务员,全都在等待中度过大半时间。方案 B:一个服务员管 100 张桌子。哪桌有动静(客人喊了、菜做好了),服务员立刻去处理;没动静就去忙别的。
方案 B 就是非阻塞 I/O(Non-blocking I/O) + 事件驱动模型——Nginx、Node.js、Redis 的核心设计。理解 I/O 模型,是理解现代高性能网络服务的关键。
Level 1:建立直觉
I/O 为什么慢
CPU 执行一条指令:约 0.3 纳秒(3GHz CPU)。
读一个 SSD 随机块:约 0.1 毫秒(NVMe)= 333,000 个 CPU 周期。
发送一个网络包并等待响应:约 50 毫秒(跨国网络)= 150,000,000 个 CPU 周期。
延迟对比(以 CPU 周期为单位):
L1 cache hit: ~4 周期
L2 cache hit: ~12 周期
L3 cache hit: ~40 周期
主内存访问: ~150 周期
NVMe SSD: ~300,000 周期
网络(局域网): ~3,000,000 周期
网络(互联网): ~150,000,000 周期
在 I/O 等待期间,CPU 完全可以做几十万到几亿个其他操作——大多数程序都在浪费这段时间。
四种 I/O 模型
1. 阻塞 I/O(Blocking I/O):
char buf[1024];
int n = read(fd, buf, 1024); // 线程在这里"冻结",直到数据到来
printf("收到 %d 字节\n", n);
调用 read() 后,线程进入睡眠,什么都不能做,直到数据就绪。
2. 非阻塞 I/O(Non-blocking I/O):
// 设置 fd 为非阻塞
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
char buf[1024];
int n = read(fd, buf, 1024);
if (n == -1 && errno == EAGAIN) {
// 数据还没来,立刻返回
// 可以去做别的事,稍后再试
}
立刻返回:有数据就返回数据,没数据就返回 EAGAIN。CPU 不等待。
问题:你什么时候来检查?每毫秒轮询一次(CPU 浪费)?
3. I/O 多路复用(I/O Multiplexing):
// epoll:让内核监视多个 fd,有事件时通知你
int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN; // 关注"可读"事件
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == server_fd) {
// 新连接到来
int conn_fd = accept(server_fd, NULL, NULL);
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
} else {
// 某个连接有数据
read(events[i].data.fd, buf, sizeof(buf));
}
}
}
epoll 可以同时监视数万个 fd,有事件时才通知,效率极高。
4. 异步 I/O(Asynchronous I/O):
// io_uring(Linux 5.1+):最现代的 AIO
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
// 提交 read 请求(非阻塞,立刻返回)
struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 1024, 0);
io_uring_submit(&ring);
// 去做其他事...
// 检查完成情况
struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe); // 等待完成
printf("读了 %d 字节\n", cqe->res);
io_uring_cqe_seen(&ring, cqe);
请求提交后立刻返回,完成后通过完成队列通知。
I/O 模型的适用场景对比
阻塞 I/O:
优点:代码简单,顺序执行,容易理解
缺点:一个慢 I/O 阻塞整个线程
适用:简单脚本、CLI 工具、低并发服务
非阻塞轮询:
优点:避免阻塞
缺点:CPU 空转(busy-wait),延迟不稳定
适用:几乎不用(被 epoll 取代)
epoll / I/O 多路复用:
优点:高效处理大量并发连接,O(1) 事件通知
缺点:代码复杂(回调地狱)
适用:Nginx、Redis、高并发网络服务
io_uring / 异步 I/O:
优点:零系统调用,最低延迟,适合磁盘+网络混合
缺点:API 较新,调试困难
适用:高性能数据库、文件服务器
Level 2:原理剖析
select/poll/epoll 的演进
select(1983):最古老的 I/O 多路复用:
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 限制:最多 1024 个 fd(FD_SETSIZE)
// 每次调用都要重新传入整个 fd 集合
// 内核要遍历所有 fd 检查状态
poll(1986):突破 1024 限制,但仍然 O(n):
struct pollfd fds[N];
fds[0].fd = fd1;
fds[0].events = POLLIN;
// ...
poll(fds, N, -1);
// 内核每次都要遍历所有 N 个 fd
epoll(Linux 2.6,2002):革命性改进,O(1) 事件通知:
epoll 的关键创新:
epoll_create:在内核创建"兴趣列表"
epoll_ctl:增/改/删 fd 的注册(只做一次!)
epoll_wait:等待事件,只返回有事件的 fd(不遍历所有)
性能比较(N=10000 个 fd):
select: O(N) = 10000 次操作/事件
poll: O(N) = 10000 次操作/事件
epoll: O(1) = 与 fd 数量无关,只返回就绪的那些
epoll 是 Nginx、Redis、Node.js 所有事件循环的底层基础。
epoll 的两种触发模式:
水平触发(Level Triggered,默认):
只要 fd 有数据,每次 epoll_wait 都会通知
→ 简单,不容易漏处理
边缘触发(Edge Triggered,EPOLLET):
只在状态变化时通知一次(无数据→有数据)
→ 必须一次性把数据读完(否则会漏事件)
→ 高性能场景首选
同步 I/O vs 异步 I/O vs io_uring
io_uring 的革命性之处:
传统系统调用 I/O:
应用 → syscall → 内核 → 设备 → 内核 → 应用
每次 I/O 需要至少 2 次系统调用(提交 + 等待)
io_uring:
应用 ←→ 共享内存(提交队列 SQ + 完成队列 CQ) ←→ 内核
应用把请求写入 SQ(不需要系统调用!)
内核从 SQ 取请求,执行,写结果到 CQ
应用从 CQ 读结果(不需要系统调用!)
理想情况:零系统调用的 I/O!(SQPOLL 模式:内核线程轮询 SQ)
Facebook、Cloudflare、PostgreSQL、io_uring 的 NGINX 分支——都在使用 io_uring 获得显著性能提升。
io_uring 性能数据:
基准测试(随机 4KB 读,NVMe SSD,单线程):
同步 read(): ~85K IOPS
epoll + read(): ~120K IOPS
io_uring(默认): ~200K IOPS
io_uring(SQPOLL):~400K IOPS(近乎零系统调用)
网络请求处理(echo server):
epoll + read/write:~500K req/s
io_uring: ~800K req/s(约 60% 提升)
TCP 连接的完整生命周期
理解 I/O 瓶颈,需要了解 TCP 连接从建立到关闭的每一步:
建立连接(3次握手):
客户端 → SYN → 服务器
客户端 ← SYN+ACK ← 服务器
客户端 → ACK → 服务器
→ 需要 1 个 RTT(往返时间)
数据传输:
发送数据 → TCP 分段 → IP 封装 → 网卡发送 → 网络传输 → 对端接收 → ACK
→ 吞吐量受限于:min(带宽, 接收窗口 / RTT)
关闭连接(4次挥手):
主动关闭方发 FIN, ACK, FIN, ACK 四个包
→ TIME_WAIT 状态等待 2×MSL(约 60-120秒)
→ 短连接服务器的 TIME_WAIT 积累是常见问题
# 查看当前 TCP 连接状态
ss -s
# Total: 1234
# TCP: 890 (estab 500, closed 10, orphan 5, timewait 100)
# 统计各状态连接数
ss -tan | awk '{print $1}' | sort | uniq -c
零拷贝(Zero Copy)
传统数据路径(服务器发送文件):
磁盘 → [DMA] → 内核缓冲区 → [CPU 拷贝] → 用户缓冲区 → [CPU 拷贝] → Socket 缓冲区 → [DMA] → 网卡
↑ ↑
2 次 CPU 拷贝! 2 次上下文切换(syscall)
sendfile() 零拷贝:
// 把文件直接发送到 socket(跳过用户空间)
sendfile(socket_fd, file_fd, &offset, count);
// 数据路径:磁盘 → 内核缓冲区 → [DMA] → 网卡
// 零次 CPU 拷贝,1 次系统调用
Nginx 默认启用 sendfile,静态文件服务性能提升约 30-50%。
splice():在内核内部传递数据(两个文件描述符之间):
// 把数据从一个管道传到 socket(不经过用户空间)
splice(pipe_fd[0], NULL, socket_fd, NULL, len, SPLICE_F_MOVE);
Level 3 · 规范怎么定义的(资深)
I/O 模型与网络协议的标准定义
I/O 的行为由多层标准定义。在系统调用层面,POSIX 定义了阻塞 I/O(默认的 read()/write())、非阻塞 I/O(O_NONBLOCK 标志)和 I/O 多路复用(select()/poll())的语义。select() 的 POSIX 规范限制文件描述符数量不超过 FD_SETSIZE(通常 1024),这也是它在高并发场景下被淘汰的原因。
Linux 的 epoll(内核 2.5.44+)不是 POSIX 标准的一部分,而是 Linux 特有的扩展。epoll 使用红黑树管理注册的文件描述符,事件通知使用回调机制——epoll_wait() 只返回有事件就绪的文件描述符,时间复杂度为 O(就绪数量) 而非 O(总注册数量)。io_uring(内核 5.1+,由 Jens Axboe 设计)是 Linux 最新的异步 I/O 接口,使用共享内存的环形缓冲区(Submission Queue 和 Completion Queue)在用户态和内核态之间传递请求,避免了每次 I/O 都需要系统调用的开销。
网络 I/O 方面,TCP 的行为由 RFC 793(1981 年)及后续修订(RFC 7323 窗口缩放、RFC 5681 拥塞控制、RFC 6298 RTO 计算等)定义。TCP 的三次握手、四次挥手、滑动窗口、拥塞避免算法(Reno、Cubic、BBR)都有精确的状态机描述。Google 的 BBR(Bottleneck Bandwidth and Round-trip propagation time)拥塞控制算法在 Linux 4.9 中合入内核,它不依赖丢包作为拥塞信号,而是通过测量带宽和 RTT 来调节发送速率——在高延迟、高带宽的链路上(如跨洋网络),BBR 的吞吐量可达 Cubic 的数倍。
Level 4 · 边界与陷阱(所有人)
陷阱 1:小的 write() 导致 TCP 性能灾难(Nagle + Delayed ACK)
TCP 的 Nagle 算法会将小数据包合并后发送(等待之前的数据被 ACK 后再发送积攒的数据),而接收端的 Delayed ACK 会延迟最多 40ms 才发送 ACK。当两者叠加时,如果你连续执行两次小的 write()(如先发 HTTP header 再发 body),第二次 write 的数据可能被 Nagle 算法暂缓(等待第一次的 ACK),而接收端又延迟了 ACK——结果是 40ms 的人为延迟。这就是著名的 "Nagle-Delayed ACK 死锁"。解决方案是设置 TCP_NODELAY 关闭 Nagle 算法,或将多次写入合并为一次(如 writev() 或 sendmsg())。几乎所有高性能网络框架(如 Redis、Nginx)都会设置 TCP_NODELAY。
陷阱 2:epoll 的惊群效应(Thundering Herd)
多个进程/线程同时在同一个 listening socket 上 epoll_wait(),当一个新连接到达时,所有等待者都会被唤醒,但只有一个能成功 accept()——其余的白白醒来后发现没有工作可做,浪费了 CPU。Nginx 在早期版本中使用 accept_mutex 来缓解这一问题(一次只让一个 worker 等待),后来 Linux 4.5 引入了 EPOLLEXCLUSIVE 标志,让内核只唤醒一个等待者。SO_REUSEPORT(Linux 3.9+)提供了更优的方案——让每个 worker 绑定独立的 listening socket,内核通过哈希将连接分发到不同的 socket,完全消除惊群。
陷阱 3:io_uring 的安全攻击面
io_uring 的高性能来自于让用户态程序直接与内核共享内存和提交 I/O 请求,但这也引入了巨大的内核攻击面。2021-2023 年间,io_uring 相关的内核漏洞(CVE)数量激增,Google 的 Project Zero 和 Chrome OS 团队因此在 Android 和 ChromeOS 上完全禁用了 io_uring。Docker 默认的 seccomp 配置文件在 2023 年也禁用了 io_uring 相关的系统调用。如果你的安全要求高于性能要求(如容器化的多租户环境),应谨慎评估是否启用 io_uring。