Linux-12 IO 模型与 epoll
这是整个系列里最重要的原理篇,也是面试被问得最深的地方。所有高并发网络框架——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

进程调用 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

把 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)。
注意:
EAGAIN和EWOULDBLOCK在 Linux 上是同一个值,但 POSIX 允许它们不同,所以规范的代码要同时判断两者。
2.3 IO 多路复用

核心思想:用一个系统调用同时监听成千上万个 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 全部基于它。
注意:多路复用本质上还是同步 IO。
epoll_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);
实际几乎没人用,两个原因:
- 信号不排队(第 8 篇讲的「不可靠信号」)——大量 fd 同时就绪时,
SIGIO会被合并,你不知道到底哪些 fd 就绪了 - 信号处理函数里能做的事极少(异步信号安全的限制)
知道它存在、面试能说清为什么不用就够了。
2.5 异步 IO

进程发起 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 的硬上限
events和revents分离,不用每次重新填充入参
但核心问题没解决:每次调用仍要把整个数组拷进内核,返回后仍要 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 上限 | 1024(FD_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 -lnt 的 Recv-Q 持续接近 Send-Q、且 nstat 的 ListenOverflows 在增长 = 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:select、poll、epoll 的区别?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-Q 和 Send-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 网络配置与排查
xingliuhua