目录

Linux-12 IO 模型与 epoll

前置阅读:Linux-09 用户态、内核态与系统调用Linux-11 文件系统与磁盘 IO

这是整个系列里最重要的原理篇,也是面试被问得最深的地方。所有高并发网络框架——nginx、Redis、Netty、Go netpoller——的地基都在这一篇。

本篇的分工:这里讲的是系统调用层和内核数据结构,Socket API 的用法和 Go netpoller 的实现细节见 network-20 Socket 编程与 IO 模型

1. 一次 read 分成两个阶段

这是理解全部五种 IO 模型的唯一钥匙。

以从 socket 读数据为例:

应用调用 read(fd, buf, len)
    |
    | 【阶段一】等待数据就绪
    |   网络数据还在路上,或者只到了半个包,
    |   内核要等到 socket 接收缓冲区里有数据可读
    |   <- 这个阶段可能等几微秒,也可能等几分钟
    v
    | 【阶段二】把数据从内核拷贝到用户空间
    |   数据已经在内核的 socket 缓冲区里了,
    |   copy_to_user() 把它搬到你的 buf
    |   <- 这个阶段很快,纯内存拷贝,微秒级
    v
read 返回

五种 IO 模型的差别,全部在于「这两个阶段分别阻塞不阻塞」。

模型 阶段一(等数据) 阶段二(拷贝数据)
阻塞 IO 阻塞 阻塞
非阻塞 IO 不阻塞(轮询) 阻塞
IO 多路复用 阻塞在 select/epoll 上 阻塞
信号驱动 IO 不阻塞(信号通知) 阻塞
异步 IO 不阻塞 不阻塞

只有异步 IO 的第二阶段也不阻塞——这正是 POSIX 定义里「同步」与「异步」的分界线。前四种都是同步 IO。

2. 五种 IO 模型

2.1 阻塞 IO

./pic1.png

进程调用 recvfrom,如果数据没准备好,进程就被挂起(进入 S 状态,不占 CPU),直到数据到达并被拷贝完成才返回。

// 最直白的写法
int n = read(fd, buf, sizeof(buf));   // 一直等,直到有数据
process(buf, n);

优点是极其简单,代码就是顺序逻辑。缺点是一个线程只能处理一个连接——要支持 1 万个连接就要 1 万个线程。

1 万个线程的代价:
  内存:1万 × 8MB 默认栈 = 80GB 虚拟内存(实际 RSS 小得多,但仍可观)
  调度:1万个可运行任务,CFS 调度周期被拉长(第 7 篇)
  切换:上下文切换开销急剧上升,cs 可能到几十万/s

这就是 C10K 问题——用「一连接一线程」的模型,单机根本扛不住一万个连接。

但阻塞 IO 并没有过时:连接数少、逻辑简单的场景(内部工具、脚本、连接池里的单个连接)用它最省事。Go 之所以能用「一 goroutine 一连接」的阻塞式写法,是因为 goroutine 极轻(2KB 起步栈),而且 runtime 在底层偷偷用 epoll 替你做了多路复用。

2.2 非阻塞 IO

./pic2.png

把 fd 设为非阻塞后,read 在数据没准备好时立刻返回 -1 并置 errno = EAGAIN,不再挂起进程。

// 设为非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// 轮询
while (1) {
    int n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        process(buf, n);
        break;
    }
    if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
        // 数据还没来,可以去干别的事
        do_something_else();
        continue;          // <- 但如果什么都不干就是【忙等】,白烧 CPU
    }
    if (n < 0) { /* 真的出错了 */ break; }
}

单独使用非阻塞 IO 几乎没有实用价值——纯轮询会把一个 CPU 核跑满。它的意义在于作为 IO 多路复用的必要组件(见 §4.3)。

注意:EAGAINEWOULDBLOCK 在 Linux 上是同一个值,但 POSIX 允许它们不同,所以规范的代码要同时判断两者。

2.3 IO 多路复用

./pic3.png

核心思想:用一个系统调用同时监听成千上万个 fd,哪个就绪了就处理哪个。

// 伪代码:经典的事件循环
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);   // 阻塞在这里
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        if (events[i].events & EPOLLIN) {
            read(fd, buf, sizeof(buf));      // 数据已就绪,读不会阻塞
            process(buf);
        }
    }
}

关键认知:进程仍然是被阻塞的,只是阻塞在 epoll_wait 上,而不是阻塞在某个具体的 socket 上。

阻塞 IO:            1 个线程阻塞在 1 个 fd 上   -> 需要 N 个线程处理 N 个连接
IO 多路复用:        1 个线程阻塞在 N 个 fd 上   -> 1 个线程处理 N 个连接

这是 C10K 问题的标准解法,nginx、Redis、Netty、Node.js 全部基于它。

注意:多路复用本质上还是同步 IOepoll_wait 只告诉你「fd 可读了」,真正的 read() 还是要你自己调用,数据从内核拷到用户空间的那一步仍然是阻塞的(虽然很快)。这一点是判断同步/异步的关键。

2.4 信号驱动 IO

给 fd 注册 SIGIO 信号,数据就绪时内核发信号通知进程。

signal(SIGIO, sigio_handler);
fcntl(fd, F_SETOWN, getpid());
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);

实际几乎没人用,两个原因:

  1. 信号不排队(第 8 篇讲的「不可靠信号」)——大量 fd 同时就绪时,SIGIO 会被合并,你不知道到底哪些 fd 就绪了
  2. 信号处理函数里能做的事极少(异步信号安全的限制)

知道它存在、面试能说清为什么不用就够了。

2.5 异步 IO

./pic4.png

进程发起 IO 请求后立刻返回,内核负责「等数据 + 拷贝数据」两个阶段,全部完成后才通知进程。

应用调用 aio_read() -> 立刻返回,进程去做别的事
                      v
                 内核等数据就绪
                      v
                 内核把数据拷贝到用户提供的 buf   <- 关键:内核代劳了
                      v
                 内核发信号 / 调回调,通知「数据已经在你的 buf 里了」

这才是真正的异步 IO——应用完全不参与数据搬运。

Linux 上异步 IO 的历史很尴尬

方案 状态
POSIX AIO(aio_read glibc 用用户态线程池模拟的,不是真异步,性能一般
Linux 原生 AIO(io_submit 真异步但只支持 O_DIRECT 的文件 IO,不支持 socket,限制太多
io_uring(5.1+) 真正通用的异步 IO,文件和网络都支持,现代方案

所以「Linux 下异步 IO 用得很少」这个说法在 io_uring 之前是准确的,现在正在改变——但 io_uring 要求内核 5.10+,普及需要时间。

3. 同步/异步 与 阻塞/非阻塞

这四个词是面试重灾区,因为很多资料把它们混着用。用 POSIX 的定义就不会错

POSIX 定义:
  同步 IO:IO 操作会导致请求进程被阻塞,直到该 IO 操作完成
  异步 IO:IO 操作不会导致请求进程被阻塞

关键在于「IO 操作」指的是什么——指的是真正搬数据的那一步(阶段二),不是「发起请求」这个动作。

按这个定义:
  阻塞 IO       阶段二阻塞  -> 同步
  非阻塞 IO     阶段二阻塞  -> 同步   <- 名字叫「非阻塞」但它是同步的!
  IO 多路复用   阶段二阻塞  -> 同步
  信号驱动 IO   阶段二阻塞  -> 同步
  异步 IO       阶段二不阻塞 -> 异步

「非阻塞 IO 是同步的」是最反直觉的一条,但按定义确实如此:read() 在数据没就绪时立即返回不算,一旦数据就绪,read() 把数据从内核拷到用户空间的那段时间,进程仍然是被阻塞的。

3.1 两组概念的正交关系

它们描述的是不同维度

关注点 谁的视角
同步 / 异步 数据搬运由谁完成 应用与内核的协作方式
阻塞 / 非阻塞 发起调用时进程要不要等 单个调用的返回时机
组合:
  同步阻塞     = 阻塞 IO
  同步非阻塞   = 非阻塞 IO、IO 多路复用
  异步非阻塞   = 真正的异步 IO(AIO、io_uring)
  异步阻塞     = 【不存在】异步的前提就是不阻塞

3.2 一个记得住的类比

用「取快递」来对应:

模型 类比
阻塞 IO 在快递点门口一直等,快递到了自己拿走
非阻塞 IO 每隔一分钟打电话问「到了吗」,到了自己去拿
IO 多路复用 委托一个前台(select/epoll)盯着所有快递,他喊你就去拿。但还是要自己去拿
信号驱动 IO 快递到了给你发条短信,你去拿。短信可能被合并成一条
异步 IO 快递直接送到家、拆好包装放桌上,完事给你发个「已完成」通知

「还是要自己去拿」就是同步的本质——多路复用只解决了「知道哪个快递到了」,搬运的活还是你自己干。

4. select / poll / epoll

三者都实现 IO 多路复用,但内核实现的效率差异巨大。

4.1 select:三个致命限制

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
// 典型用法
fd_set readfds;
while (1) {
    FD_ZERO(&readfds);
    for (int i = 0; i < n; i++) FD_SET(fds[i], &readfds);   // ① 每次都要重新填
    
    select(maxfd + 1, &readfds, NULL, NULL, NULL);
    
    for (int i = 0; i < n; i++) {                            // ② 每次都要全部遍历
        if (FD_ISSET(fds[i], &readfds)) {
            read(fds[i], buf, sizeof(buf));
        }
    }
}

三个问题

问题 说明
fd 数量上限 1024 FD_SETSIZE 是编译期常量,改了要重编译整个程序
每次调用都要拷贝整个 fd 集合 用户态 → 内核态,fd 越多拷贝开销越大
返回后要遍历所有 fd O(n) 找出哪些就绪;而且 fd_set 是入参出参共用,每次都要重填

FD_SETSIZE=1024 是硬伤。它是位图的大小,不是「建议值」——超过 1024 会造成栈溢出或静默的内存越界。

4.2 poll:解决了数量限制

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

struct pollfd {
    int   fd;         /* 要监听的 fd */
    short events;     /* 关心的事件(入参) */
    short revents;    /* 实际发生的事件(出参)<- 与 events 分开了 */
};

改进点

  • 用数组代替位图,没有 1024 的硬上限
  • eventsrevents 分离,不用每次重新填充入参

但核心问题没解决:每次调用仍要把整个数组拷进内核,返回后仍要 O(n) 遍历找就绪的 fd。

规律:poll 只是 select 的小修小补,量级上没有区别。10 万连接下两者都不可用。

4.3 epoll:内核维护 fd 集合

epoll 的突破在于把「fd 集合」常驻内核,不再每次传递。

int epoll_create1(int flags);                                     // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);   // 增删改 fd
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);                        // 等待就绪

内核里的两个关键数据结构

// 简化的 eventpoll 结构(内核 fs/eventpoll.c)
struct eventpoll {
    struct rb_root_cached rbr;      // 红黑树:存放【所有】被监听的 fd
    struct list_head      rdllist;  // 双向链表:存放【已就绪】的 fd
    wait_queue_head_t     wq;       // 等待队列:阻塞在 epoll_wait 的进程
};
       红黑树 rbr                     就绪链表 rdllist
   (所有监听的 fd,10 万个)        (已就绪的 fd,可能只有几百个)
   
        ●                                 +---+---+---+
      /   \                              |fd3|fd7|fd9|
    ●       ●          网卡数据到达 ------>+---+---+---+
   /\     /\         回调把 fd 挂进来           ^
  ●  ●    ●   ●                          epoll_wait 只看这个链表

工作流程

① epoll_ctl(EPOLL_CTL_ADD)
   把 fd 插入红黑树(O(log n)),并给这个 fd 注册一个【回调函数】ep_poll_callback
   
② 网卡收到数据 -> 协议栈处理完 -> 唤醒等待队列
   -> 触发 ep_poll_callback
   -> 回调把这个 fd 直接【挂进就绪链表 rdllist】
   
③ epoll_wait 只需要检查 rdllist 空不空
   非空 -> 把就绪的 fd 拷给用户,返回个数
   空   -> 进程睡眠,等回调唤醒

epoll 高效的本质是三点,缺一不可

机制 解决了什么
fd 集合常驻内核(红黑树) 不用每次拷贝整个集合
回调机制(不是轮询) 内核不需要遍历所有 fd 去检查状态
就绪链表 epoll_wait 返回的直接就是就绪的 fd,用户不用遍历

所以 epoll 的开销只和「活跃连接数」有关,与「总连接数」无关。

10 万个长连接,同一时刻只有 500 个在传数据:
  select/poll:每次要检查 10 万个 fd          -> O(100000)
  epoll:      只处理就绪链表里的 500 个       -> O(500)

这恰好契合互联网场景——大量长连接但同时活跃的只是少数(推送、IM、网关)。

4.4 三者对比

维度 select poll epoll
fd 上限 1024FD_SETSIZE 无硬上限 无硬上限
底层结构 位图 数组 红黑树 + 就绪链表
每次调用的拷贝 全部 fd 全部 fd 只在 epoll_ctl 时一次
内核查找就绪 O(n) 遍历 O(n) 遍历 O(1),回调直接挂链表
用户查找就绪 O(n) 遍历 O(n) 遍历 O(1),返回的就是就绪的
触发模式 只有 LT 只有 LT LT + ET
跨平台 几乎所有平台 大部分 Unix 仅 Linux

epoll 不是永远最优

连接数少(< 100)且几乎全部活跃  -> select/poll 可能更快
                                    (epoll 有 epoll_ctl 的额外系统调用开销)
连接数多、活跃比例低              -> epoll 碾压
需要跨平台                        -> 用 libevent/libuv 封装,
                                    它们在 Linux 用 epoll,BSD 用 kqueue,
                                    Windows 用 IOCP

5. ET 与 LT:编程模型的关键差异

epoll 独有的两种触发模式,用错会导致丢事件或空转

LT(Level Triggered,水平触发)—— 默认模式
  只要 fd 上还有数据可读,【每次】epoll_wait 都会返回它
  
ET(Edge Triggered,边缘触发)
  只在 fd 状态【从不可读变为可读】的那一刻通知一次
  之后即使数据没读完,也不再通知

5.1 具体差异举例

场景:socket 缓冲区收到 1000 字节,应用每次只 read 100 字节

LT 模式:
  epoll_wait 返回 -> read 100 字节(还剩 900)
  epoll_wait 返回 -> read 100 字节(还剩 800)    <- 继续通知
  ...一直通知到读完
  
ET 模式:
  epoll_wait 返回 -> read 100 字节(还剩 900)
  epoll_wait 阻塞  <- 【不再通知!剩下 900 字节永远读不到】
  除非又有【新数据】到达,状态再次「变化」,才会通知

所以 ET 模式必须一次把数据读干

// ✅ ET 模式的正确写法:循环读到 EAGAIN
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        process(buf, n);
        continue;                       // 继续读
    }
    if (n == 0) {                       // 对端关闭
        close(fd);
        break;
    }
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break;                      // ✅ 读干了,正常退出
        }
        if (errno == EINTR) continue;   // 被信号打断,重试
        close(fd);                      // 真的出错
        break;
    }
}

关键:EAGAIN 是「读干了」的唯一标志。

5.2 ET 必须配合非阻塞 fd

这是个硬性要求,否则会死锁。

如果 ET 模式用了【阻塞】fd:
  循环 read 直到读干 -> 最后一次 read 时缓冲区已空
  -> 阻塞 fd 的 read 会【挂住】,等待新数据
  -> 整个事件循环卡死,其他所有连接都得不到处理
  
用【非阻塞】fd:
  最后一次 read 立即返回 EAGAIN -> 正常退出循环 -> 继续处理其他 fd  ✅

LT 模式则两种 fd 都能用——因为它不需要循环读到干,读一次就可以返回事件循环,下次还会通知。

5.3 怎么选

LT ET
编程难度 简单,读一次就行 复杂,必须循环读到 EAGAIN
是否必须非阻塞 fd 不必须 必须
唤醒次数 多(数据没读完就一直通知)
容错性 ,漏读了下次还会通知 低,漏读了数据就永久丢失
谁在用 nginx、Redis、大部分应用 libevent 的部分模式、追求极致性能的场景

生产建议:默认用 LT。

理由很实在:LT 的额外唤醒开销在现代硬件上微不足道,而 ET 的编程复杂度带来的 bug 风险很高——「偶尔丢一个请求」这种问题极难排查。nginx 和 Redis 这两个性能标杆都用 LT,说明 LT 完全够用。

坑:ET 模式下写事件(EPOLLOUT)尤其容易出错。socket 可写状态几乎总是 true(缓冲区没满就可写),所以 ET 下注册 EPOLLOUT 后可能只触发一次。正确做法是:平时不注册 EPOLLOUT,只在 write 返回 EAGAIN(缓冲区满了)时才临时注册,写完立即注销。

6. 惊群问题

**多个进程/线程同时等待同一个 fd 时,一个事件到来唤醒了所有人,但只有一个能真正处理——其余全部白白唤醒又睡回去。**这就是惊群(thundering herd)。

6.1 两种惊群

① accept 惊群
   多个进程 accept 同一个 listen fd
   -> Linux 2.6 起内核已经解决:只唤醒一个进程
   
② epoll 惊群
   多个进程把【同一个 listen fd】加进各自的 epoll
   -> 新连接到来时,内核会唤醒【所有】阻塞在这个 fd 上的进程
   -> nginx 面对的是这一种

区别在于「谁在等」:直接 accept 的进程挂在 socket 的等待队列上(内核只唤醒一个);而通过 epoll 等待的进程挂在 epoll 的等待队列上,内核无法知道其他 epoll 实例也在等同一个 fd,只能全部唤醒。

6.2 三代解决方案

第一代:应用层加锁(nginx 的 accept_mutex

events {
    accept_mutex on;      # 老版本默认 on,1.11.3+ 默认 off
}

worker 之间抢一把锁,只有拿到锁的 worker 才把 listen fd 加进自己的 epoll。缺点是引入了锁竞争和延迟——没抢到锁的 worker 要等下一轮。

第二代:EPOLLEXCLUSIVE(4.5+)

ev.events = EPOLLIN | EPOLLEXCLUSIVE;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

告诉内核「这个 fd 上只唤醒一个等待者」。内核层面解决,无锁竞争。

第三代:SO_REUSEPORT(3.9+)—— 现代最优方案

int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
bind(fd, ...);       // 多个进程可以 bind 同一个端口
listen(fd, backlog);
不用 SO_REUSEPORT:              用 SO_REUSEPORT:
   一个 listen fd                   worker1 -> listen fd 1 -> 独立队列 1
   v 所有 worker 抢                 worker2 -> listen fd 2 -> 独立队列 2
 w1 w2 w3 都被唤醒                  worker3 -> listen fd 3 -> 独立队列 3
   v                                内核按【四元组 hash】直接投递
 只有一个 accept 成功                零竞争,零惊群,负载天然均衡

SO_REUSEPORT 让每个 worker 有自己独立的 listen fd 和 accept 队列,内核按连接的四元组哈希决定投给谁。既没有惊群,也不需要锁。

# nginx 里开启
http {
    server {
        listen 80 reuseport;      # <- 加这个
    }
}

生产建议:内核 3.9+ 直接用 SO_REUSEPORT,并把 accept_mutex 保持 off。

注意:SO_REUSEPORT 有个副作用——worker 重启时,它那个队列里已经排队的连接会被丢弃(因为 fd 关闭了)。所以平滑重启需要额外处理(nginx 的做法是老 worker 处理完存量再退出)。另外它与 SO_REUSEADDR 是两回事:后者只是允许 bind 处于 TIME_WAIT 的地址。

7. 现代方案:io_uring 与 Go netpoller

7.1 io_uring

epoll 的剩余问题是:每次读写仍然是一个系统调用。10 万 QPS 就是 20 万次系统调用(读+写),按 150ns 算就是 30ms 的纯开销。

io_uring共享内存的环形队列解决:

      用户态                        内核态
  +------------------+        +------------------+
  |  提交队列 SQ     |        |  内核读取并处理  |
  |  (写入请求)    | <----> |                  |
  +------------------+        +------------------+
          ^                            ^
          +--------- 共享内存 ---------+
  +------------------+        +------------------+
  |  完成队列 CQ     |        |  内核写入结果    |
  |  (读取结果)    | <----> |                  |
  +------------------+        +------------------+

批量提交:把 N 个请求写进 SQ,一次 io_uring_enter() 全部提交
轮询模式:SQPOLL 下内核线程主动轮询 SQ,【连这一次系统调用都不需要】
特性 epoll io_uring
通知机制 就绪通知(还要自己 read) 完成通知(数据已经在 buf 里)
同步/异步 同步 真异步
系统调用数 每个 IO 一次 批量提交,可到 0(SQPOLL)
支持文件 IO 不支持(普通文件总是「就绪」) 支持
内核要求 2.6+ 5.1+,5.10+ 才成熟

io_uring 是 epoll 之后最重要的进展,但普及需要时间——生产环境大量机器还在 4.x 内核上。目前主要在数据库(ScyllaDB)、存储系统、高性能代理里使用。

7.2 Go netpoller:用同步的写法拿异步的性能

Go 最巧妙的设计之一:让你写阻塞式代码,底层却是 epoll。

// 你写的代码看起来是阻塞的
func handle(conn net.Conn) {
    buf := make([]byte, 4096)
    n, err := conn.Read(buf)      // 看起来会阻塞
    // ...
}

// 每个连接一个 goroutine,写法极简
for {
    conn, _ := listener.Accept()
    go handle(conn)
}

底层实际发生的

conn.Read() 
    v
runtime 把 fd 设为【非阻塞】,调用 read()
    v
返回 EAGAIN(没数据)
    v
runtime 把这个 goroutine 挂到 netpoller 的等待列表,标记为 Gwaiting
    v
【调度器把 M(OS 线程)分配给其他 goroutine】<- 关键:线程没有被阻塞
    v
调度循环中定期调用 netpoll() -> 底层就是 epoll_wait
    v
fd 就绪 -> 找到对应的 goroutine -> 标记为 Grunnable,放回运行队列
    v
goroutine 被调度,conn.Read() 从内核视角「返回」了

所以 Go 同时拿到了两边的好处

收益
编程模型 阻塞式的顺序代码,无回调地狱、无状态机
性能 底层是 epoll,一个 OS 线程承载成千上万个 goroutine
成本 goroutine 初始栈只有 2KB(vs 线程默认 8MB)

这解释了为什么 Go 特别适合写网络服务——它把「一连接一线程」的简洁写法和「epoll 事件循环」的性能统一了。代价是你对 IO 的细节失去了控制(比如无法直接用 ET 模式)。

8. 实战:连接数上不去

现象:一个 Go 网关服务,压测时并发连接到 28000 左右就上不去了,客户端报 connection refused,服务端 CPU 只用了 40%。

① 先看是不是 fd 限制

cat /proc/$(pgrep gateway)/limits | grep -i "open files"
# Max open files            1048576   1048576   files     <- 不是这个问题

ls /proc/$(pgrep gateway)/fd | wc -l
# 28134                                                   <- 卡在这个数

② 看 accept 队列有没有溢出

ss -lnt
# State  Recv-Q Send-Q Local Address:Port
# LISTEN  512    511         0.0.0.0:8080
#         ^^^^^^ Recv-Q = 当前 accept 队列里堆积的连接数
#                ^^^^^^ Send-Q = 队列容量(backlog)
#         队列已经满了!

# 确认溢出计数在增长
nstat -az | grep -iE "ListenOverflows|ListenDrops"
# TcpExtListenOverflows           89234      0.0
# TcpExtListenDrops               89234      0.0
#                                 ^^^^^ 8.9 万次溢出 -> 大量连接被内核直接丢弃

ListenOverflows 就是「客户端 connection refused」的直接原因:三次握手完成的连接放不进 accept 队列,内核只能丢弃。

③ 找出 backlog 为什么只有 511

cat /proc/sys/net/core/somaxconn
# 4096                    <- 系统限制是 4096,不是 511

# 那 511 是哪来的?是应用自己设的
# Go 的 net.Listen 默认用 somaxconn,但这个服务显式设了 backlog

关键:实际 backlog = min(应用传的 backlog, net.core.somaxconn)。两者取小值,所以调内核参数但不改应用代码是没用的,反之亦然。

④ 修复三处配置

# ① 内核:加大 accept 队列上限和半连接队列
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.netdev_max_backlog=32768

# 持久化
cat >> /etc/sysctl.d/99-network.conf <<'EOF'
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 32768
EOF
sysctl --system
// ② 应用:不要自己设小 backlog,让它用 somaxconn
// ❌ 原来的代码
lc := net.ListenConfig{}   // 某些框架会硬编码 backlog=511

// ✅ 用默认值(Go 会读 /proc/sys/net/core/somaxconn)
ln, err := net.Listen("tcp", ":8080")

// ③ 关键:accept 循环里不要做任何耗时操作
for {
    conn, err := ln.Accept()
    if err != nil {
        continue
    }
    go handle(conn)        // ✅ 立刻交给 goroutine,accept 循环保持空转
    // ❌ 不要在这里做 TLS 握手、鉴权、日志等任何阻塞操作
}

⑤ 验证

ss -lnt
# State  Recv-Q Send-Q Local Address:Port
# LISTEN  0      65535       0.0.0.0:8080
#         ^ 队列不再堆积           ^^^^^ backlog 生效

nstat -az | grep ListenOverflows
# TcpExtListenOverflows           0          0.0     <- 归零

# 连接数
ls /proc/$(pgrep gateway)/fd | wc -l
# 98234                                              <- 突破了

结果:并发连接从 28000 提升到 98000,connection refused 归零,CPU 使用率 65%。

规律:ss -lntRecv-Q 持续接近 Send-Q、且 nstatListenOverflows 在增长 = accept 队列溢出。三个地方都要查:somaxconn、应用传的 backlog、accept 循环有没有被拖慢。

9. 一些量级感

量级
epoll_wait 一次调用(有就绪 fd) 1 ~ 3 μs
read 命中 socket 缓冲区 1 ~ 3 μs
单核 epoll 事件循环的处理能力 5 万 ~ 20 万 事件/s
单机长连接数(内存充足、8 核) 50 万 ~ 100 万
单个 TCP 连接的内核内存开销 4 ~ 10 KB
goroutine 初始栈 2 KB
OS 线程默认栈 8 MB(虚拟)
select 的 fd 上限 1024(硬编码)

用法举例:100 万长连接 × 每连接 8KB 内核内存 = 8GB,加上应用层每连接的读写缓冲(假设各 4KB)又是 8GB——所以「单机百万连接」的机器至少要 32GB 内存,这是个绕不开的硬约束。

10. 面试题

Q:五种 IO 模型的区别是什么?

关键在于一次 IO 操作分两个阶段:① 等待数据就绪;② 把数据从内核拷贝到用户空间。五种模型的差别全在于这两个阶段分别阻塞不阻塞。阻塞 IO:两个阶段都阻塞。非阻塞 IO:阶段一立即返回 EAGAIN(需要轮询),阶段二阻塞。IO 多路复用:阻塞在 select/epoll 上而不是某个具体 fd 上,阶段二仍阻塞。信号驱动 IO:用 SIGIO 通知,阶段二阻塞。异步 IO:两个阶段都不阻塞,内核把数据拷贝完才通知你。所以只有异步 IO 是真异步,前四种按 POSIX 定义都是同步 IO。

Q:为什么说「非阻塞 IO」也是同步 IO?

因为 POSIX 定义里的「IO 操作」指的是真正搬运数据的那一步(阶段二),不是「发起请求」这个动作。非阻塞 IO 在数据未就绪时立即返回 EAGAIN,这一点确实「非阻塞」;但一旦数据就绪,read() 把数据从内核缓冲区拷贝到用户 buf 的这段时间,进程仍然是被阻塞的、必须等它完成。而真正的异步 IO 里,这个拷贝动作由内核代劳,应用完全不参与。所以判断同步/异步的标准是「数据搬运由谁完成」,判断阻塞/非阻塞的标准是「发起调用时要不要等」——两组概念是正交的,但不存在「异步阻塞」,因为异步的前提就是不阻塞。

Q:selectpollepoll 的区别?epoll 为什么高效?

select 有三个问题:fd 上限 1024(FD_SETSIZE 是编译期常量)、每次调用要把整个 fd 集合从用户态拷进内核、返回后要 O(n) 遍历找就绪的 fd。poll 用数组替代位图去掉了数量限制,events/revents 分离免去了重复填充,但拷贝和遍历两个核心问题没解决epoll 高效靠三点:① fd 集合常驻内核(红黑树),只在 epoll_ctl 时传一次,不用每次拷贝;② 回调机制——注册 fd 时挂一个回调,数据到达时协议栈直接把 fd 挂进就绪链表,内核不需要遍历所有 fd 去检查;③ 就绪链表——epoll_wait 返回的直接就是就绪的 fd,用户不用遍历。所以 epoll 的开销只与活跃连接数有关,与总连接数无关,这恰好契合「十万长连接但同时活跃只有几百」的互联网场景。

Q:epoll 的 ET 和 LT 有什么区别?为什么 ET 必须用非阻塞 fd?

LT(水平触发,默认):只要 fd 上还有数据可读,每次 epoll_wait 都会返回它。ET(边缘触发):只在状态「从不可读变为可读」的那一刻通知一次,之后即使数据没读完也不再通知——所以 ET 下必须循环 read 直到返回 EAGAIN,否则剩余数据永久丢失。ET 必须配非阻塞 fd 的原因是:循环读到最后一次时缓冲区已空,如果是阻塞 fd,read挂住等新数据,导致整个事件循环卡死、其他所有连接得不到处理;非阻塞 fd 会立即返回 EAGAIN 让你正常退出循环。生产建议默认用 LT——nginx 和 Redis 都用 LT,说明够用,而 ET 的编程复杂度带来的「偶尔丢请求」这类 bug 极难排查。

Q:什么是惊群问题?怎么解决?

多个进程/线程同时等待同一个 fd,一个事件到来时唤醒了所有人,但只有一个能真正处理,其余全部白白唤醒又睡回去,浪费大量上下文切换。要区分两种:accept 惊群(多进程直接 accept 同一个 listen fd)Linux 2.6 起内核已解决,只唤醒一个;epoll 惊群(多进程把同一个 listen fd 加进各自的 epoll)内核无法知道其他 epoll 实例也在等,只能全部唤醒——nginx 面对的是这一种。三代解法:① 应用层加锁(nginx 的 accept_mutex),有锁竞争和延迟;② EPOLLEXCLUSIVE(4.5+),内核层面只唤醒一个;③ SO_REUSEPORT(3.9+,现代最优)——让每个 worker 拥有独立的 listen fd 和 accept 队列,内核按四元组哈希直接投递,零竞争零惊群且负载天然均衡。生产上直接用 SO_REUSEPORT 并把 accept_mutex 保持 off。

Q:Go 的 conn.Read() 看起来是阻塞的,为什么 Go 服务能支撑十万连接?

因为 Go runtime 在底层用 epoll 做了替换。conn.Read() 时 runtime 实际把 fd 设为非阻塞并调用 read,返回 EAGAIN不是阻塞 OS 线程,而是把当前 goroutine 挂到 netpoller 的等待列表、标记为 Gwaiting,然后调度器立刻把这个 OS 线程(M)分配给其他 goroutine。调度循环中定期调用 netpoll()(底层就是 epoll_wait),fd 就绪时找到对应的 goroutine 标记为可运行、放回队列。所以阻塞的只是goroutine(成本 2KB 栈),OS 线程从未被阻塞。这样 Go 同时拿到了「阻塞式顺序代码」的简洁和「epoll 事件循环」的性能,代价是你对 IO 细节失去控制(比如无法直接用 ET 模式)。

Q:io_uring 相比 epoll 解决了什么问题?

epoll 的剩余问题是每次读写仍然是一个独立的系统调用——10 万 QPS 就是 20 万次系统调用,按 150ns 算是 30ms 的纯开销;而且 epoll 是就绪通知(告诉你可读了,还要自己 read),本质是同步 IO;此外它对普通文件无效(普通文件总是「就绪」)。io_uring共享内存的环形队列(提交队列 SQ + 完成队列 CQ)解决:批量把 N 个请求写进 SQ,一次 io_uring_enter() 全部提交;开启 SQPOLL 模式后内核线程主动轮询 SQ,连这一次系统调用都不需要。而且它是完成通知——通知你时数据已经在你的 buf 里了,是真正的异步 IO,文件和网络都支持。代价是要求内核 5.1+(5.10+ 才成熟),生产普及需要时间。

Q:并发连接数上不去、客户端报 connection refused,怎么排查?

先用 ss -lnt 看监听 socket 的 Recv-QSend-Q——Recv-Q 是当前 accept 队列里堆积的连接数,Send-Q 是队列容量。如果 Recv-Q 持续接近 Send-Q,再用 nstat -az | grep ListenOverflows 确认溢出计数在增长,那就是 accept 队列溢出:三次握手已完成的连接放不进队列,内核只能丢弃,客户端就看到 connection refused。要查三个地方:① 内核的 net.core.somaxconn;② 应用 listen() 传的 backlog——实际生效值是 min(应用backlog, somaxconn),只调内核参数不改应用代码没用;③ accept 循环有没有被拖慢——如果在 accept 之后同步做 TLS 握手、鉴权、日志,取连接的速度跟不上入队速度,队列照样会满,正确做法是立刻交给独立的 goroutine/线程处理。


上一篇:Linux-11 文件系统与磁盘 IO | 下一篇:Linux-13 网络配置与排查