Skip to content

I/O 模型 ​

#操作系统 · #I/O · #epoll · #io_uring · #多路复用 · #异步I/O

I/O 模型是后端开发的核心基础。理解阻塞I/O、非阻塞I/O、多路复用和异步I/O的区别,是写出高性能服务的前提。从 select/poll 到 epoll 再到 io_uring,Linux I/O 一直在向更低延迟、更高吞吐演进。


五种 I/O 模型 ​

mermaid
flowchart LR
    subgraph Blocking["1. 阻塞 I/O"]
        B1["recvfrom()"] --> B2["等待数据"]
        B2 --> B3["数据就绪"]
        B3 --> B4["复制到用户态"]
        B4 --> B5["返回"]
    end

    subgraph NonBlocking["2. 非阻塞 I/O"]
        N1["recvfrom()"] --> N2["EWOULDBLOCK"]
        N2 --> N1
        N1 --> N3["数据就绪"]
        N3 --> N4["复制到用户态"]
        N4 --> N5["返回"]
    end

    subgraph Multiplexing["3. I/O 多路复用"]
        M1["select/poll/epoll"] --> M2["阻塞等待"]
        M2 --> M3["fd 就绪"]
        M3 --> M4["recvfrom()"]
        M4 --> M5["复制+返回"]
    end

    subgraph AIO["5. 异步 I/O"]
        A1["aio_read()"] --> A2["立即返回"]
        A1 -.-> A3["内核复制完成"]
        A3 -.-> A4["信号/回调通知"]
    end

对比 ​

模型发起等待复制示例
阻塞 I/O阻塞阻塞阻塞read() 默认
非阻塞 I/O立即返回轮询阻塞fcntl O_NONBLOCK
I/O 多路复用阻塞select/poll/epoll阻塞Nginx/Redis
信号驱动 I/O立即返回信号通知阻塞SIGIO
异步 I/O立即返回内核完成立即返回io_uring

select / poll / epoll ​

select ​

c
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

// 缺点:
// 1. fd_set 大小固定(默认 1024)
// 2. 每次都要把整个 fd_set 从用户态拷贝到内核态
// 3. 内核 O(n) 遍历所有 fd,找到就绪的
// 4. 返回后用户态还要 O(n) 遍历

poll ​

c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int   fd;       // 文件描述符
    short events;   // 关注的事件
    short revents;  // 返回的事件
};

// 改进:没有 fd 数量限制
// 问题:仍需 O(n) 遍历 + 拷贝

epoll ​

c
// 三部曲:epoll_create → epoll_ctl → epoll_wait

int epoll_create(int size);  // size 已忽略,创建一个 epoll 实例

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD / MOD / DEL

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

struct epoll_event {
    uint32_t events;   // EPOLLIN / EPOLLOUT / EPOLLET ...
    epoll_data_t data; // 用户数据(通常存 fd)
};
mermaid
flowchart TB
    subgraph epoll["epoll 机制"]
        direction TB
        E1["epoll_create()"] --> E2["创建 eventpoll 对象<br/>(红黑树 + 就绪链表)"]
        E3["epoll_ctl(ADD)"] --> E4["fd 加入红黑树<br/>注册回调到设备驱动"]
        E5["设备数据到达"] --> E6["回调: 将 fd 加入就绪链表"]
        E7["epoll_wait()"] --> E8["检查就绪链表<br/>有数据 → 返回<br/>无数据 → 睡眠"]
    end

对比 ​

维度selectpollepoll
fd 上限1024 (FD_SETSIZE)无上限无上限
内核数据结构数组链表红黑树 + 就绪链表
扫描方式O(n) 遍历所有 fdO(n) 遍历所有 fdO(1) 只返回就绪 fd
fd 拷贝每次全部拷贝每次全部拷贝仅注册时拷贝(内存映射)
触发模式水平水平水平 + 边缘
适用场景fd < 100fd < 1000fd >= 1000 ✅

水平触发(LT)vs 边缘触发(ET) ​

数据到达 socket,缓冲区有 2KB 数据:

LT(Level Triggered — 默认):
  读了 1KB → epoll_wait 仍返回 → "还有数据没读"
  必须读完所有数据才不再通知
  → 编程简单,但可能频繁唤醒

ET(Edge Triggered):
  数据到达时通知一次 → 读了 1KB → 不再通知
  必须循环读直到 EAGAIN
  → 高性能,但程序必须非阻塞 + 循环读
c
// ET 模式标准写法
for (;;) {
    nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            // 必须循环读,直到 EAGAIN
            while (1) {
                ssize_t n = read(fd, buf, sizeof(buf));
                if (n == -1 && errno == EAGAIN) break; // 读完了
                if (n <= 0) break; // 出错或关闭
                // 处理数据
            }
        }
    }
}

EPOLLONESHOT 与多线程 accept ​

EPOLLONESHOT — 保证一个 fd 只被一个线程处理 ​

在多线程 epoll 模型中,如果多个线程同时 epoll_wait 同一个 epoll 实例,同一个 fd 的事件可能被多个线程同时收到(尤其是 LT 模式)。EPOLLONESHOT 解决这个问题:

c
// 注册时加上 EPOLLONESHOT
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// 效果:fd 触发一次事件后,自动从 epoll 中"禁用"
// 直到用 EPOLL_CTL_MOD 重新激活

工作流程:

mermaid
sequenceDiagram
    participant T1 as Thread 1
    participant T2 as Thread 2
    participant EP as epoll
    participant FD as fd (client)

    FD->>EP: 数据到达
    EP->>T1: epoll_wait 返回 fd 就绪
    Note over EP,FD: fd 自动从 epoll 中禁用<br/>(ONESHOT 生效)
    FD->>EP: 更多数据到达
    EP--xT2: T2 的 epoll_wait 不会收到此 fd
    T1->>T1: 处理完数据
    T1->>EP: epoll_ctl(MOD) 重新激活 fd
    Note over EP,FD: fd 重新可被触发

为什么需要 EPOLLONESHOT:

场景不用 ONESHOT用 ONESHOT
多线程 LT同一 fd 可能被多个线程同时唤醒 → 竞争保证只有一个线程处理
多线程 ET虽然只通知一次,但如果处理期间有新数据,可能再次通知另一个线程彻底避免并发处理同一 fd
状态机模型需要额外加锁保护 fd 状态天然串行化,无需锁

代价:每次处理完必须 epoll_ctl(MOD) 重新激活,多一次系统调用。

多线程 accept — SO_REUSEPORT vs accept_mutex ​

高并发服务器的 accept 模型演进:

方案 1: 单线程 accept + 分发
  主线程 accept → 将 fd 分发给 worker 线程
  问题: accept 成为瓶颈(单线程处理所有新连接)

方案 2: 多线程共享 listen fd + accept_mutex
  多个线程/进程 epoll_wait 同一个 listen fd
  用 mutex 保证同一时刻只有一个线程 accept
  问题: 锁竞争、惊群(虽然 Linux 4.5+ 已优化)

方案 3: SO_REUSEPORT (Linux 3.9+)
  每个线程/进程绑定自己的 listen socket(相同端口)
  内核负责将新连接分配到不同 socket
  → 无锁、无惊群、内核级负载均衡
c
// SO_REUSEPORT 用法
int sock = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
bind(sock, ...);
listen(sock, backlog);
// 多个线程/进程都这样做,绑定同一个端口

各方案对比:

方案吞吐延迟复杂度适用
单线程 accept + 分发中低简单连接率不高
共享 listen fd + EPOLLONESHOT中高中中需要精确控制
SO_REUSEPORT高低简单推荐(Nginx 1.11.3+, Go net.Listen)

Go 的做法:

go
// Go 1.11+ 默认使用 SO_REUSEPORT(如果可用)
// net.Listen 内部会设置 SO_REUSEPORT
ln, _ := net.Listen("tcp", ":8080")
// 多个 goroutine 可以同时 Accept,Go runtime 内部处理分发

Go netpoller — 同步编程异步化 ​

Go runtime 将 epoll/kqueue/IOCP 封装为 netpoller,这是 Go 高并发网络能力的核心基础设施。它让 goroutine 以同步阻塞语义写 I/O 代码,底层却是真正的异步执行——这是 Go 与其他语言网络编程模型最大的差异点。

与调度器的深度集成 ​

mermaid
sequenceDiagram
    participant G as "Goroutine"
    participant S as "调度器 (P/M/G)"
    participant NP as "Netpoller (epoll)"
    participant OS as "内核"

    G->>OS: conn.Read() → syscall
    OS-->>G: EAGAIN (fd 没数据)
    G->>S: gopark() — 将 goroutine 挂到 netpoll 等待队列
    Note over G,S: goroutine 让出 P<br/>不占用任何线程!

    loop 每轮调度
        S->>NP: netpoll(0) — 非阻塞检查就绪 fd
        NP->>OS: epoll_wait(timeout=0)
        OS-->>NP: 就绪 fd 列表
        NP-->>S: goroutine 列表
        S->>G: goready() — 恢复 goroutine
    end

    G->>OS: conn.Read() → 数据就绪,成功返回

关键设计决策 ​

mermaid
flowchart TB
    subgraph Design["Go netpoller 设计哲学"]
        Q1["为什么不每连接一个线程?"]
        A1["线程栈开销 ~1MB<br/>10K 连接 = 10GB 栈内存<br/>上下文切换 O(μs→ms)"]

        Q2["为什么不用回调(callback)?"]
        A2["回调地狱:代码碎片化<br/>错误处理复杂<br/>生命周期管理困难"]

        Q3["Go 的方案"]
        A3["goroutine 栈动态增长 (2KB→)<br/>10K goroutine ≈ 20MB<br/>调度器切换 O(ns)<br/>同步代码风格 + 异步执行"]
    end

    Q1 --> A1
    Q2 --> A2
    Q3 --> A3

核心魔法:gopark() 后 goroutine 从 P 上解绑,线程可以去执行其他 goroutine——连接不用等数据,数据来了也不用等线程。这种"M:N 调度+netpoller"的组合让 Go 能用极小内存片(2KB 初始栈)支撑百万并发连接。


io_uring — 下一代异步 I/O ​

设计动机 ​

io_uring 的诞生背景是 epoll 的两个根本局限:(1) 每次 I/O 至少两次系统调用(epoll_wait + read/write),高频 I/O 场景陷入内核开销大;(2) epoll 只解决网络 I/O 的就绪通知,对磁盘文件 I/O 仍是同步阻塞——而现代 NVMe SSD 的延迟已低至 10μs,系统调用开销变成了瓶颈。

io_uring 的核心创新是共享环形缓冲区——用户态和内核态通过两块 mmap 共享内存通信,避免数据拷贝和系统调用:

mermaid
flowchart TB
    subgraph Userspace["用户态"]
        App["应用"]
    end
    subgraph SharedMemory["mmap 共享内存 (无拷贝!)"]
        subgraph SQ["Submission Queue (SQ)"]
            SQ1["SQE 0: read(fd=5, buf=0xA, len=4096)"]
            SQ2["SQE 1: write(fd=8, buf=0xB, len=1024)"]
            SQ3["SQE 2: fsync(fd=5)"]
        end
        subgraph CQ["Completion Queue (CQ)"]
            CQ1["CQE 0: read 完成, ret=4096 ✅"]
            CQ2["CQE 1: write 完成, ret=1024 ✅"]
            CQ3["CQE 2: fsync 完成, ret=0 ✅"]
        end
    end
    subgraph Kernel["内核态"]
        KernelWorker["内核工作线程<br/>处理 SQ 中的请求<br/>完成后写入 CQ"]
    end

    App -->|"写入 SQE (无锁)"| SQ
    KernelWorker -->|"写入 CQE"| CQ
    SQ --> KernelWorker
    CQ -->|"读取 CQE"| App

完整生命周期 ​

c
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);  // 1. 创建 io_uring (SQ 大小 256)

// 2. 获取一个空的 SQE(Submission Queue Entry)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

// 3. 准备请求——这里还没有系统调用!
io_uring_prep_read(sqe, fd, buf, 4096, 0);  // 读 4KB

// 4. 提交——一次系统调用提交所有准备好的 SQE
//    可以连续准备多个 SQE,一次性提交(批量)
io_uring_submit(&ring);

// 5. 等待完成
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int bytes_read = cqe->res;      // 结果
io_uring_cqe_seen(&ring, cqe);  // 标记 CQE 已消耗
```c

Go 中的 io_uring 使用 ​

go
// 使用 github.com/iceber/iouring-go 库(Go 1.20+ 也可用 go:linkname)
package main

import (
    "os"
    "unsafe"

    iouring "github.com/iceber/iouring-go"
)

func main() {
    // 1. 创建 io_uring
    ring, _ := iouring.New(256)
    defer ring.Close()

    // 2. 打开文件
    f, _ := os.OpenFile("test.dat", os.O_RDWR|os.O_CREATE, 0644)
    defer f.Close()

    buf := make([]byte, 4096)

    // 3. 准备读请求(无系统调用)
    sqe := ring.GetSQE()
    sqe.PrepRead(int(f.Fd()), uintptr(unsafe.Pointer(&buf[0])), 4096, 0)

    // 4. 提交所有准备好的请求(一次系统调用)
    ring.Submit()

    // 5. 等待完成
    cqe := ring.WaitCQE(0) // 超时 0 = 无限等待
    n := cqe.Res()
    cqe.Seen() // 标记已消费
    _ = n
}
go
// 批量 I/O 的性能优势 — 一次提交 N 个读写
func batchIO(ring *iouring.Ring, fds []int, bufs [][]byte) {
    for i, fd := range fds {
        sqe := ring.GetSQE()
        sqe.PrepRead(fd, uintptr(unsafe.Pointer(&bufs[i][0])), len(bufs[i]), 0)
    }
    // 一次 Submit() → 一次系统调用 → 提交 N 个 I/O 请求
    ring.Submit()

    for range fds {
        cqe := ring.WaitCQE(0)
        _ = cqe.Res()
        cqe.Seen()
    }
}
// vs epoll: N 次 poll+read = 2N 次系统调用
// io_uring: 1 次 submit + N 次 wait = 1+N 次 → 节省 N-1 次系统调用

性能对比 — io_uring vs epoll vs AIO ​

text
测试环境: NVMe SSD, 4KB 随机读, 单线程

┌──────────┬──────────┬──────────┬───────────────────┐
│ 方案      │ IOPS      │ 系统调用/次│ 说明              │
├──────────┼──────────┼──────────┼───────────────────┤
│ epoll+非阻塞│ 300K     │ 2        │ epoll_wait + read │
│ AIO 线程池 │ 250K     │ 1        │ 线程切换开销大     │
│ io_uring  │ 1.6M     │ 1        │ 共享环形缓冲区     │
│ io_uring  │           │          │                   │
│ (SQPOLL)  │ 2.0M+    │ 0        │ 内核线程持续轮询   │
└──────────┴──────────┴──────────┴───────────────────┘

SQPOLL 模式: 内核创建专用线程持续轮询 SQ,完全消除 submit 系统调用。
代价: 该线程 100% 占一个 CPU 核。适合专用 I/O 密集服务器。

io_uring vs epoll 本质区别 ​

维度epollio_uring
I/O 模式I/O 就绪通知(还要自己 read/write)I/O 完成通知(OS 已替你完成)
单次 I/O 系统调用≥ 2 次(epoll_wait + read/write)可批量 submit,一次完成多个 I/O
文件 I/O❌ 不支持异步文件 I/O✅ 原生支持
通信机制用户态→内核态拷贝 fd_setmmap 共享环形缓冲区(零拷贝)
批量操作❌✅ 一次 submit 提交多个 SQE
Fixed Buffers❌✅ 预注册缓冲区,避免每 I/O kmalloc
链接操作❌✅ IOSQE_IO_LINK:前一个完成后才执行下一个
内核版本2.65.1+ (推荐 5.15+ 稳定版)

实际性能差距:在 NVMe SSD 随机 4KB 读场景,io_uring 单线程可达 ~1.6M IOPS,而 epoll+read 的 AIO 模拟方案(线程池)仅 ~300K IOPS。差距不在于磁盘,而在于系统调用和内存拷贝。


I/O 模型选择决策 ​

需要处理大量并发连接?
├── QPS < 1000, 连接数 < 100 → 阻塞 I/O + 多线程即可
├── QPS 1K-10K, 连接数 100-10K → epoll + 非阻塞(Nginx/Redis 模式)
├── QPS > 10K, 连接数 > 10K → epoll ET + 多线程 Reactor
└── 追求极致性能(5.1+ 内核) → io_uring

需要异步文件 I/O?
├── io_uring 是最佳选择
└── 或用线程池模拟(传统方案)

写 Go?
→ 不需要关心,netpoller 自动处理

生产环境中的阻塞传播链 ​

很多 I/O 故障并不是单点慢,而是一整条链路逐级积压。最典型的链路是:

mermaid
flowchart LR
    NIC["网卡/RX Queue"] --> SOFTIRQ["softirq / backlog"]
    SOFTIRQ --> SOCKET["socket 接收缓冲区"]
    SOCKET --> EPOLL["epoll 就绪队列"]
    EPOLL --> APP["事件循环 / worker"]
    APP --> DOWN["下游 RPC / MySQL / Redis"]
    DOWN --> APP

当下游变慢时,现象通常不是“只有下游 RT 变高”这么简单,而是:

  1. 应用层处理变慢,read() / write() 频率下降
  2. socket 缓冲区逐渐堆积,Recv-Q / Send-Q 上升
  3. epoll 不断返回同一批“可读但处理不动”的 fd
  4. worker / goroutine 被下游调用占住,事件循环吞吐下降
  5. 用户看到的是连接超时、RT 抖动、偶发 502/504、CPU 并不一定高

一个典型案例:client -> nginx -> app -> mysql ​

mermaid
sequenceDiagram
    participant C as Client
    participant N as Nginx
    participant A as App
    participant M as MySQL

    C->>N: HTTP 请求
    N->>A: 反向代理转发
    A->>M: SQL 查询
    Note over M: 慢 SQL / 锁等待 / 磁盘抖动
    M-->>A: 返回变慢
    Note over A: worker 被占住,处理新请求能力下降
    A-->>N: 响应超时或变慢
    Note over N: upstream 超时,连接堆积
    N-->>C: 504 / RT 飙升

这类故障里,nginx、应用、MySQL 都可能“看起来有问题”,但根因常常在最下游。I/O 模型决定的是系统在下游变慢时如何表现:

  • 阻塞线程池模型:线程数持续上升,最终打满线程或 fd
  • epoll + 非阻塞模型:fd 不一定多,但事件循环吞吐下降,队列堆积更明显
  • Go netpoller:goroutine 数量快速增长,线程数不一定同步增长,但 goroutine dump 会看到大量阻塞在网络/数据库等待

epoll 实战易错点 ​

1. ET 模式下没有读到 EAGAIN ​

这是最经典的线上 bug 之一。

c
// 错误:ET 模式只读一次
read(fd, buf, sizeof(buf));

// 正确:循环读到 EAGAIN
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n == -1 && errno == EAGAIN) break;
    if (n <= 0) break;
}

如果只读一次,剩余数据还在 socket buffer 里,但 ET 不会再次提醒,表现出来像“连接卡死”“客户端已经发了但服务端不处理”。

2. 只关注可读,不处理可写背压 ​

很多服务只注册 EPOLLIN,但真实系统里写阻塞同样常见:

  • 对端接收慢,send() 返回 EAGAIN
  • 应用没有缓存待发送数据,也没有注册 EPOLLOUT
  • 最后表现为响应卡住、连接长时间不释放

3. 惊群与负载不均 ​

多个 worker 同时等待同一个 listen fd,如果没有 EPOLLEXCLUSIVE、SO_REUSEPORT 或应用层 accept 分流,容易出现:

  • 一个新连接唤醒多个线程
  • 大量无效唤醒,CPU 上升
  • 某些 worker 很忙,另一些几乎空闲

4. 把“非阻塞”误当成“不会阻塞线程” ​

非阻塞 fd 只意味着内核态不会一直睡在这次 I/O 调用上,不代表整个业务线程不会被别的环节阻塞。典型误区:

  • 网络是非阻塞的,但业务代码里做了同步磁盘 I/O
  • epoll_wait 很快返回,但 worker 后面阻塞在数据库连接池
  • 表面上看是网络模型问题,实际上是应用处理链的问题

线上排障 Runbook ​

先判断堵在哪一层 ​

现象优先怀疑先看什么
连接建立慢listen backlog / accept 慢ss -lnt, somaxconn, tcp_max_syn_backlog
连接已建立但请求超时应用处理慢 / 下游卡住ss -ant, top, 线程栈 / goroutine dump
CPU 高且系统态高softirq / epoll 空转 / 惊群top, mpstat, /proc/softirqs
CPU 不高但 RT 很高下游依赖慢、连接池堵塞慢日志、连接池指标、下游 RT
丢包明显网卡 / backlog / socket buffer 满ethtool -S, netstat -s, ss -m

常用命令 ​

bash
ss -ant                     # 看连接状态、Recv-Q、Send-Q
ss -lnt                     # 看监听队列
netstat -s                  # 看TCP统计
cat /proc/net/softnet_stat  # 看软中断丢包、backlog情况
cat /proc/softirqs          # 看NET_RX/NET_TX是否偏斜
strace -tt -p <pid>         # 看线程卡在哪些系统调用
perf top                    # 看CPU热点是否在协议栈/拷贝/锁竞争

一个排障顺序 ​

text
1. 先看连接有没有堆积(ss / Recv-Q / Send-Q)
2. 再看应用线程或 goroutine 卡在哪(线程栈 / pprof)
3. 再看下游(MySQL / Redis / RPC)是否慢
4. 最后回头确认是否是网卡、backlog、rmem/wmem 等内核参数问题

这个顺序的核心是:不要一看到 epoll / io_uring / 非阻塞 就把问题归因到 I/O 模型本身。线上绝大多数故障,真正的问题出在“应用没消费、下游消费不动、或者容量没配够”。


I/O 工程实践:短读短写、背压与缓冲区语义 ​

0. 一眼看懂:I/O 问题常常不是模型选错,而是语义没处理对 ​

mermaid
flowchart LR
    A["fd 就绪"] --> B["read / write"]
    B --> C["协议拆包 / 组包"]
    C --> D["应用处理 / 下游调用"]
    D --> E["写回与背压"]
现象常见根因第一反应不该是什么
ET 模式偶发卡死没读到 EAGAIN先怪 epoll 不稳定
响应慢但连接还活着写背压、短写、发送队列堆积先怪 CPU 不够
内存上涨应用缓冲区和待发送队列累积先怪 GC
协议偶发错包把一次 read() 当成一条消息先怪网络粘包“玄学”

1. I/O 模型之外,真正决定线上表现的是“有没有把数据消费完” ​

很多 I/O 线上问题,并不是模型选错,而是应用没有正确处理:

  • 短读(short read)
  • 短写(short write)
  • EAGAIN
  • socket buffer 背压
  • 应用层消息边界

也就是说,epoll / io_uring / 非阻塞本身只是框架,真正的稳定性来自:你是否正确理解了内核缓冲区与应用消费之间的语义。

2. 短读短写是正常现象,不是异常现象 ​

text
read(fd, buf, 4096)  != 一定返回 4096
write(fd, buf, 4096) != 一定写出 4096

原因可能包括:

  • 当前缓冲区里就这么多数据
  • 对端接收窗口有限
  • socket send buffer 已接近满
  • 非阻塞 fd 提前返回
  • 信号打断

这意味着工程代码必须天然支持:

  • 循环读到协议层足够
  • 循环写到数据真正发完
  • 维护发送缓冲和偏移量
  • 对 EAGAIN 做重试和事件注册

3. 应用层协议边界不能依赖一次 read ​

TCP 是字节流,不保留消息边界。线上最常见的 bug 之一是:

  • 业务以为一次 read() 就是一条完整消息
  • 实际可能半包、粘包、拆包都出现

所以正确做法通常是:

  • 固定长度头 + body
  • length-prefix
  • 分隔符协议
  • 应用层 frame decoder

否则 I/O 模型再高级,也只是更快地读错数据边界。

4. 写路径上的背压通常比读路径更难处理 ​

很多程序只关心“可读”,忽略“写不出去”时怎么办。真实线上更棘手的常常是:

  • 对端慢
  • 应用持续产生响应
  • 本地发送队列越来越大
  • 内存上涨
  • 最后不是 CPU 打满,而是延迟和连接数一起恶化
mermaid
flowchart LR
    A["下游/客户端接收慢"] --> B["send buffer 逐渐变满"]
    B --> C["write/send 返回 EAGAIN 或短写"]
    C --> D["应用侧待发送队列堆积"]
    D --> E["内存上涨 / RT 上升 / 连接超时"]

5. 非阻塞不等于不排队 ​

非阻塞 I/O 只是让这一次系统调用不长期睡住当前线程,但并不意味着:

  • 请求不会排队
  • 数据不会堆积
  • worker 不会被后续逻辑占住

所以很多系统虽然用了 epoll,依然会出现:

  • Recv-Q / Send-Q 增长
  • 事件循环吞吐下降
  • 下游慢导致上游连接大量堆积

6. 一个常见排障顺序 ​

现象先怀疑
连接建立了但响应很慢写背压、下游阻塞
CPU 不高但 RT 高排队、连接池、socket buffer 堆积
ET 模式偶发卡死没读到 EAGAIN
内存上涨应用发送队列、未消费 buffer
丢包/超时backlog、rmem/wmem、对端处理慢

7. 实战原则 ​

text
先保证读写语义正确,
再谈 epoll / io_uring 的性能收益;
否则模型越先进,问题只会放大得越快。

8. epoll 完整链路性能分析——从网卡到用户态的每一步延迟 ​

一个 HTTP 请求从网卡到达到用户态代码处理完毕,经历了多少步?每步耗时多少?

═══════════════════════════════════════════════════════════════
完整链路: 网卡 → 内核 → epoll → 用户态 → 响应
═══════════════════════════════════════════════════════════════

┌─────────────────────────────────────────────────────────────┐
│ 步骤 1: 网卡接收 (NIC → Ring Buffer)                         │
│   网卡收到以太网帧 → DMA 写入 Ring Buffer → 触发硬中断        │
│   延迟: ~1-5μs (取决于网卡和 DMA 速度)                       │
│   瓶颈: Ring Buffer 满了 → 丢包 (rx_missed_errors)           │
├─────────────────────────────────────────────────────────────┤
│ 步骤 2: 硬中断 → 软中断 (NAPI)                               │
│   硬中断处理: 关闭中断 + 调度 NAPI softirq                    │
│   软中断处理: 从 Ring Buffer 取 sk_buff → 协议栈处理          │
│   延迟: ~5-20μs                                              │
│   瓶颈: softirq 处理不过来 → /proc/net/softnet_stat 溢出     │
├─────────────────────────────────────────────────────────────┤
│ 步骤 3: TCP/IP 协议栈处理                                     │
│   IP 层: 校验 → 路由查找 → 分片重组                           │
│   TCP 层: 序号检查 → ACK → 数据放入 socket recv buffer        │
│   延迟: ~5-15μs                                              │
│   瓶颈: recv buffer 满了 → TCP 窗口为 0 → 对端停止发送       │
├─────────────────────────────────────────────────────────────┤
│ 步骤 4: epoll 回调 → 就绪链表                                 │
│   TCP 数据到达 → 触发 socket 的 wait queue 回调               │
│   回调函数: ep_poll_callback() → 将 fd 加入 rdllist          │
│   延迟: ~0.1-1μs (几乎零开销)                                │
│   瓶颈: 无(O(1) 操作)                                      │
├─────────────────────────────────────────────────────────────┤
│ 步骤 5: epoll_wait 返回                                       │
│   用户线程从 epoll_wait 唤醒 → 拷贝就绪事件到用户空间         │
│   延迟: ~1-5μs (系统调用 + 上下文切换)                        │
│   瓶颈: 如果用户线程在其他 CPU 上 → 跨核唤醒 ~10μs           │
├─────────────────────────────────────────────────────────────┤
│ 步骤 6: 用户态 read() 系统调用                                │
│   从 socket recv buffer 拷贝数据到用户空间                    │
│   延迟: ~1-3μs (小数据) / ~10-50μs (大数据,取决于大小)      │
│   瓶颈: 数据量大时的内存拷贝                                  │
├─────────────────────────────────────────────────────────────┤
│ 步骤 7: 业务逻辑处理                                          │
│   解析 HTTP → 路由 → 处理 → 生成响应                         │
│   延迟: 取决于业务 (1μs ~ 100ms)                             │
│   瓶颈: 数据库查询、RPC 调用、计算密集                        │
├─────────────────────────────────────────────────────────────┤
│ 步骤 8: write() + 发送                                        │
│   用户态 → socket send buffer → TCP 分段 → IP → 网卡发送     │
│   延迟: ~5-20μs                                              │
│   瓶颈: send buffer 满了 → write 阻塞 / EAGAIN               │
└─────────────────────────────────────────────────────────────┘

总延迟(不含业务逻辑):
  最优情况: ~20-50μs (本机 loopback,小数据)
  典型情况: ~50-200μs (同机房,1KB 数据)
  跨机房:   ~1-10ms (网络 RTT 主导)

经典案例: 为什么 Nginx 能处理 100 万并发连接?

Nginx 的 epoll 模型:
  - 单进程 + 非阻塞 I/O + epoll ET 模式
  - 每个 worker 进程绑定一个 CPU 核
  - 事件循环: epoll_wait → 处理所有就绪事件 → 再 epoll_wait

性能关键点:
  1. 零拷贝 sendfile():
     传统: read(file) → 用户空间 → write(socket)  (2 次拷贝)
     sendfile: 内核直接 file → socket buffer       (0 次用户态拷贝)
     → 静态文件服务吞吐提升 30-50%

  2. 连接复用 (keep-alive):
     每次 TCP 握手 = 1.5 RTT = ~1ms (同机房)
     100 万 QPS × 1ms = 1000 秒的握手时间 → 不可能
     keep-alive: 复用连接 → 握手开销分摊到数百次请求

  3. accept_mutex → SO_REUSEPORT:
     旧方案: 多 worker 竞争 accept → 惊群效应
     新方案: SO_REUSEPORT → 内核级负载均衡 → 无锁

  4. 内存池 (ngx_pool_t):
     每个连接一个内存池 → 连接关闭时整体释放
     → 避免频繁 malloc/free → 减少碎片和系统调用

100 万连接的资源消耗:
  每个空闲连接: ~3.2KB (TCP socket + epoll entry + Nginx 连接结构)
  100 万连接: ~3.2GB 内存
  epoll 红黑树: 100 万节点 × ~64B = ~64MB
  就绪链表: 通常只有几百到几千个活跃连接

  瓶颈不在 epoll,而在:
    - 内存 (3.2GB 只是连接本身,加上 buffer 可能 10GB+)
    - 文件描述符限制 (ulimit -n)
    - 端口耗尽 (如果是反向代理,upstream 连接也占端口)

经典案例: Go net/http 的 epoll 封装——为什么 goroutine-per-connection 不慢?

Go 的网络模型:
  表面: 每个连接一个 goroutine,同步 Read/Write
  底层: runtime 的 netpoller 封装了 epoll

  goroutine 调用 conn.Read():
    1. 尝试非阻塞 read → 如果有数据 → 直接返回
    2. 没有数据 → 将 goroutine 挂到 fd 的等待队列 → gopark
    3. runtime 的 sysmon 或 findrunnable 调用 epoll_wait
    4. fd 就绪 → 从等待队列取出 goroutine → goready → 放入 P 的 runq
    5. goroutine 被调度执行 → Read 返回数据

  为什么不慢?
    - goroutine 切换 ~100ns (vs 线程切换 ~1-5μs)
    - goroutine 栈初始只有 2KB (vs 线程栈 1-8MB)
    - 100 万 goroutine 只需 ~2GB 栈空间
    - epoll_wait 由 runtime 统一调用,不是每个 goroutine 各自调用

  vs Nginx 的差异:
    Nginx: 手动管理状态机(非阻塞 + 回调)→ 代码复杂但极致性能
    Go:    同步代码风格 + runtime 自动管理 → 代码简单,性能略低 10-20%

    对于大多数业务服务: Go 的模型更合适(开发效率 >> 极致性能)
    对于纯代理/网关: Nginx 更合适(CPU 密集的协议处理少)

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记