第 25 章

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。

本章评分
4.7  / 5  (5 评分)

💬 留言讨论