Theory · OS · I/O Models & epoll
同步/异步 × 阻塞/非阻塞 · 五种 I/O 模型 · select/poll/epoll 演化 · Reactor —— 高并发网络服务的全部地基
"同步/异步"说的是谁等数据(要不要拷贝),"阻塞/非阻塞"说的是等就绪时能不能让出——两把不同的尺子
select(位图 1024)→ poll(数组)→ epoll(内核代管 + 就绪链表):把 O(n) 的遍历变成 O(1) 的派送
netpoller 把 epoll 包成"goroutine 阻塞"的假象——每个连接一个 goroutine 的写法,底下是事件驱动
Why I/O Models Matter
// 最直觉的服务器:一个连接一个线程 while (1) { conn = accept(listen_fd); pthread_create(&tid, NULL, handle, conn); } void* handle(void* p) { char buf[4096]; n = read(conn, buf, 4096); // ← 卡在这 // ... 处理并回包 } // 1 万个长连接同时在线: // 线程栈 8MB × 10000 = 80 GB(预留) // 而且其中 9900 个线程 —— 什么都没在读。
等待的成本就是一整个线程:要同时等 N 个连接,就得有 N 个线程。连接数和线程数被死死绑在一起,于是"并发量"的天花板由线程成本而非业务需求决定——这就是 1999 年被命名的 C10K 问题。
从"一个线程等一个连接"换成"一个线程等一批连接":让内核帮你盯住一万个 fd,谁有数据了再叫你。线程数从此只跟 CPU 核数走,与连接数解耦——这是整个高并发网络编程的地基。
多路复用只解决"等"。数据来了以后还得搬(内核缓冲区 → 用户缓冲区),以及搬完以后谁来处理。前一个问题引出了 LT/ET 与零拷贝,后一个引出了 Reactor 三种形态——本篇要走的正是这两条支线。
先校准四个词(阻塞/非阻塞 × 同步/异步)→ 再给五种模型定位 → 然后走一遍 select → poll → epoll 的演化因果 → 接着是 LT/ET 与 Reactor → 最后落到 C10K 调优与 Go 的 netpoller。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 文件描述符 fd | 内核给"一个打开的对象"(文件、socket)编的整数号,程序用它指代这个对象 |
| 阻塞 / 非阻塞 | 阻塞 = 拿不到就挂起等;非阻塞 = 拿不到也立刻返回,用返回值告诉你"还没好" |
| 同步 / 异步 | 同步 = 数据的搬运由你自己调 read/write 完成;异步 = 内核搬完了再通知你 |
| 就绪 ready | 描述符的内核缓冲区进入可用状态:可读(有数据/已关闭)、可写(有空位) |
| I/O 多路复用 | 一个线程同时盯住一批 fd,谁就绪就处理谁的机制(select/poll/epoll) |
| 事件循环 | "等事件 → 分发 → 回调 → 再等"的死循环,事件驱动程序的主骨架 |
| Reactor 反应器 | 把事件循环 + 分发 + 回调打包成的一套编程范式(Nginx/Netty/Redis 都用它) |
| 回调 callback | 预先登记好的函数,事件发生时由框架调用,而不是你自己按序调用 |
| LT / ET | 水平触发 = 只要有数据反复通知;边沿触发 = 只在状态变化那一下通知一次 |
| EAGAIN | 非阻塞 I/O 的"现在不行"返回码(等同 EWOULDBLOCK),ET 模式靠它判断读干净了 |
先读这两篇再回来,本 deck 默认你已经知道它们:
进程 · 线程 · 协程 → 线程的成本与上下文切换
操作系统总览 → 系统调用、中断、内核态与用户态
一次网络 I/O 永远只有两步:① 等就绪(数据到没到)② 拷数据(内核缓冲区 ↔ 用户缓冲区)。本篇讲的所有概念、机制、模式,差异只发生在这两步的"谁来做"和"怎么通知"上——判别任何 I/O 模型,问这两步就够了。
演化是有因果的,不要背结论:select/poll 的病根是"内核没有记忆"(每次都要重新递交、重新扫描),epoll 的全部设计都是冲着这四个字去的——让内核长期记住你的关注列表,就绪时主动上报。
Blocking vs Non-blocking · Sync vs Async
开场那个"一万个线程白等"的问题,第一步是把概念说清楚——到底什么叫阻塞、什么叫异步。这两把尺子量的正是心智模型里的两个阶段。
| 尺子 | 量什么阶段 | 选项 |
|---|---|---|
| 阻塞 / 非阻塞 | 等就绪阶段(数据到没到) | 阻塞:挂着等 · 非阻塞:立刻返回,没就绪给 EWOULDBLOCK |
| 同步 / 异步 | 拷数据阶段(就绪后的读写谁做) | 同步:用户线程自己 read/write · 异步:内核做完全程再通知你 |
① "多路复用是非阻塞所以是异步"——错。epoll_wait 返回后还是要用户自己 read(同步拷贝),真正的异步是内核把数据搬到你的 buffer 再通知(AIO/io_uring)。② "非阻塞 = 不等待"——对,但代价是得配合多路复用或轮询,否则空转。
一个线程等一批 fd(select/poll/epoll_wait 阻塞在"等待"上),谁就绪就处理谁——阻塞在收集器上,而不是阻塞在每个连接上。线程数与连接数从此解耦,这是 C10K 的钥匙。
"可读" = 接收缓冲区有数据(或连接关闭);"可写" = 发送缓冲区有空位。就绪是内核缓冲区的状态,不是数据到达网络的事件——这决定了 LT/ET 的行为差异(第 7 页)。
The Five I/O Models
select · fd_set
每次调用把 fd_set 位图(默认上限 1024 个 fd)从用户态拷进内核,内核线性扫描所有 fd 检查就绪并修改位图,返回后用户再遍历一遍找出就绪的 fd。FD_ZERO/FD_SET/FD_ISSET 的仪式感代码就是这么来的。
① 1024 上限(FD_SETSIZE 编译期定死);② 每次调用全量拷贝 fd 集合进内核;③ 内核 O(n) 线性扫描,连接多时纯浪费;④ 返回的是"集合被改过",用户还要 O(n) 遍历找就绪者,且每次调用后集合被破坏要重置。
可移植性(POSIX 标准到处有)+ fd 数量小时性能可接受。fd 少 + 跨平台 的场景 select 仍是合理选择——说清适用边界比全盘否定高一档。
// 典型 select 仪式(注意重复劳动) fd_set rfds; FD_ZERO(&rfds); FD_SET(listen_fd, &rfds); // 每轮重建 FD_SET(conn_fd, &rfds); while (1) { fd_set tmp = rfds; // 位图会被破坏,需备份 int n = select(maxfd+1, &tmp, NULL, NULL, NULL); for (fd = 0; fd <= maxfd; fd++) // O(n) 遍历 if (FD_ISSET(fd, &tmp)) handle(fd); // 就绪的处理 }
poll · pollfd[]
fd_set 位图换成 pollfd 数组(events/revents 分离):① 无 1024 硬上限(上限=内存与 RLIMIT_NOFILE);② events/revents 分离后集合不被破坏,不用每轮重建;③ 关注事件与上报事件分开,语义更清晰(POLLIN/POLLOUT/POLLERR…)。
每次仍全量拷贝 pollfd 数组进内核、内核仍 O(n) 扫描、返回后用户仍要自己遍历找 revents 非零者——复杂度账本与 select 同级。1 万连接时依然是"为 100 个就绪者烧 1 万份开销"。
poll 是 select 的"工程修补版":解了数量与重建问题,没解"内核无记忆"的根本矛盾。根本解法只有一条:让内核长期持有关注集合 + 就绪时主动上报——这就是 epoll 的设计(且只在 Linux 有;BSD/macOS 对应 kqueue,Windows 对应 IOCP)。
// pollfd:events 与 revents 分离 struct pollfd { int fd; short events; // 关注什么(用户填) short revents; // 发生了什么(内核填) }; // 每轮循环无需重建数组 while (1) { int n = poll(fds, nfds, -1); for (i = 0; i < nfds; i++) if (fds[i].revents & POLLIN) handle(fds[i].fd); // 仍要 O(n) 全量遍历 }
epoll · Red-Black Tree + Ready List
Level-Triggered vs Edge-Triggered
LT(默认):只要缓冲区还有数据,每次 epoll_wait 都上报——"电平"= 持续状态。ET:只在状态变化的那一下(空→非空)上报一次——"边沿"= 变化事件。没读完?不会再说。
一次通知必须把数据读干净:循环 read 直到返回 EAGAIN/EWOULDBLOCK。所以 ET 必须配非阻塞 fd——阻塞 fd 在缓冲区读空后会挂死整个事件循环。写侧同理:注册可写后立刻会通知(缓冲区有空位),容易空转,要么写完立刻取消注册,要么 EPOLLONESHOT。
LT:简单安全,每次只读一部分也没事,配合 Reactor 代码好写(Redis/Go 用 LT 或其变体)。ET:减少 epoll_wait 唤醒次数、事件量小,高吞吐场景(Nginx 用 ET)配代码复杂度与踩坑风险。性能差异在常态负载下并不悬殊——正确性陷阱才是 ET 的主要成本。
// ET 模式的标准读法(必须能默写) for { n := read(conn, buf, len(buf)) if n == len(buf) { continue // 可能还有,继续读 } if n > 0 { handle(buf[:n]); break } if err == EAGAIN { break // 读干净了,等下次通知 } if err == ECONNRESET { close(conn); break } } // fd 必须是非阻塞——否则读空即挂死
epoll_ctl(MOD) 重新武装——多线程 Reactor 的标配:保证一个 fd 同一时刻只被一个线程处理,避免竞态。
Reactor · Proactor
事件循环(epoll_wait 等事件)+ 分发器(按 fd/事件类型路由)+ 处理器(回调函数)。本质是把"谁就绪处理谁"的循环做成框架——同步 I/O 多路复用的架构化表达。
① 单 Reactor 单线程:一个线程包揽全部——Redis 6.0 前的经典形态(简单、无锁,但算重活会卡全服);② 单 Reactor 多线程:Reactor 线程收发,业务丢线程池——收发仍是瓶颈;③ 主从 Reactor 多线程:mainReactor 只管 accept,subReactor 池各管一批连接的读写——Nginx / Netty / Muduo 的形态,生产标准答案。
Reactor 通知"可以读写了你来"(同步);Proactor 通知"读写已完成"(内核搬完数据)——真异步(IOCP/io_uring)的范式。Linux 生态 Reactor 是主流(epoll 同步语义),Windows IOCP 天生 Proactor。
| 形态 | 谁干什么 | 代表 | 痛点 |
|---|---|---|---|
| 单 Reactor 单线程 | 一线程全包 | Redis(<6.0) | 重活卡全服;多核浪费 |
| 单 Reactor 多线程 | 收发单线程 + 业务线程池 | 多数教学框架 | 收发仍单点 |
| 主从 Reactor | accept 与读写分离,多 subReactor | Nginx / Netty | 实现复杂度最高 |
| Proactor | 内核完成读写后通知 | IOCP / io_uring | Linux 生态以 Reactor 为主 |
C10K · 1M Connections
1 万连接起线程/进程(BIO)= 1 万个内核线程的调度与栈内存(8MB 预留 ×1 万 = 80GB!)——不可行。答案:线程数与连接数解耦(多路复用 + Reactor),线程只随 CPU 核数走,连接随便涨。
每连接的成本:内核 socket 结构 + 收发缓冲(各默认几 KB,可调)+ 应用对象 + fd 表项。百万连接 ≈ 内核侧数 GB + 应用侧数 GB——瓶颈从"线程"变成"内存与 fd 上限":ulimit -n、fs.file-max、net.ipv4.ip_local_port_range(客户端方向)。
① fd 上限(ulimit -n → 百万级 + fs.file-max);② 收发缓冲调小(tcp_rmem/wmem min 默认值);③ 关闭慢启动干涉、开启 tcp_nodelay 按需;④ 文件描述符传递/复用;⑤ 客户端模拟注意本地端口四元组限制。
| 资源 | BIO(1 连接 1 线程) | epoll + Reactor |
|---|---|---|
| 线程/栈 | 8MB × 连接数(预留) | 线程数 = 核数量级 |
| 调度压力 | 万级线程上下文切换 | 事件驱动,几乎无切换 |
| 等待成本 | 每线程阻塞一个连接 | 阻塞在 epoll_wait 一次 |
| 瓶颈 | 线程与内存(不可行) | fd 上限 + 内核缓冲内存 |
Go netpoller
M:N 模型里 M(内核线程)贵而少:如果 goroutine 的网络读真的阻塞在 M 上,千连接就把 M 耗尽。netpoller 的目标:让用户写阻塞式代码,runtime 保证不阻塞 M。
① fd 初始化时注册进 epoll(非阻塞 fd + LT 语义);② goroutine 读时数据未就绪 → runtime 把它挂到该 fd 的等待列表(pollDesc)并 gopark(M 继续跑别的 G);③ 数据到达 → 内核回调路径上 epoll_wait 返回该 fd → netpoll 循环找到等待的 G → goready 重新排队;④ G 恢复后从用户视角"read 返回了"。
epoll_wait 跑在哪?调度循环里复用:M 找不到 runnable G 时进入 netpoll(带最近定时器的超时),等事件的同时不浪费线程。写阻塞同理(发送缓冲满 → park → 可写唤醒)。文件 I/O 没有 netpoller(真阻塞 syscall,P 解绑兜底)——网络与文件的差异待遇是常考题。
| 阶段 | 内核/OS | Go runtime |
|---|---|---|
| 监听 | epoll 注册(非阻塞 LT) | pollDesc 挂 G |
| 等待 | epoll_wait(带超时) | gopark,M 转去跑别的 G |
| 就绪 | 事件返回 | goready → 队列 |
| 拷贝 | read(同步,用户态发起) | runtime 内联执行 |
Cheat Sheet
| 阻塞 / 非阻塞 | 量等就绪阶段:挂着等 vs 立刻返回 EAGAIN |
| 同步 / 异步 | 量拷数据阶段:用户自己 read vs 内核搬完再通知 |
| select | 位图,1024 上限,每轮重建 + 全量拷贝 + O(n) 扫描 |
| poll | 数组,无上限、不用重建;拷贝与扫描照旧 |
| epoll | 红黑树长期代管 + 回调挂就绪链表;每轮 O(就绪数) |
| LT(默认) | 只要还有数据每次都报;读一半也没事,代码好写 |
| ET | 只在变化那一下报一次;必须非阻塞 + 循环读到 EAGAIN |
| ET 配套 | 写事件易空转 → 写完即取消注册或 EPOLLONESHOT |
| 单 Reactor 单线程 | Redis(<6.0):无锁,重活卡全服 |
| 单 Reactor 多线程 | 业务丢线程池,收发仍单点 |
| 主从 Reactor | Nginx / Netty:accept 与读写分离,生产标准答案 |
top 的 sys/si、pprof 火焰图、缓冲区水位Interview QA · Part 1
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。
select 用 fd_set 位图(1024 上限、每次重建、IN/OUT 共用一位);poll 用 pollfd 数组(无硬上限、events/revents 分离不用重建)。但两者都全量拷贝 + O(n) 扫描 + 用户遍历——复杂度同级。
红黑树让内核长期持有关注集合(免重复拷贝,ctl 一次);设备就绪时回调把 fd 挂就绪链表(免全量扫描);epoll_wait 只取就绪链表(返回量 = 就绪数,用户免遍历)。快在"开销与就绪数成正比,与总连接数无关"。
没有。epoll_wait 返回事件时仍是内核→用户拷贝,只是只拷就绪的少量事件;网络数据本身照常经 read/write 拷贝。"epoll+mmap 零拷贝"是流传最广的讹传——能主动辟谣是加分项。
LT 每轮上报所有未处理完的就绪(状态),ET 只在变化时报一次(事件)。ET 的坑:必须非阻塞 fd + 循环读到 EAGAIN,否则丢事件或挂死;写事件易空转,需写完即取消注册或 ONESHOT。
不一定。fd 少(几十个)或"几乎全部活跃"时,红黑树维护 + 回调的开销可能反超线性扫描——select 在小而活跃的场景反而轻。epoll 的甜区:连接多、活跃占比低(长连接网关、IM、推送)。
主流是每核一个 epoll 一个线程(无锁、cache 友好):accept 用 SO_REUSEPORT 内核分桶,连接按落点归属各 Reactor。共享一个 epoll 多线程 wait 会引入唤醒竞态(要用 EPOLLONESHOT 补),复杂且不划算。
Interview QA · Part 2
同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。
阻塞/非阻塞量"等就绪"阶段(挂起 vs 立即返回);同步/异步量"搬数据"阶段(用户自己做 vs 内核做完通知)。IO 多路复用 = 同步(自己 read),AIO/io_uring = 异步(内核搬完)。
前四种(BIO/NIO/多路复用/信号驱动)的数据搬运都由用户线程 read 完成——同步;AIO 的等待+搬运全由内核完成,完成后再通知。判别问题一句话:"read 是谁调的?"
Reactor 通知"可以做了"(就绪事件,用户做 I/O)——epoll/Redis/Netty/Nginx;Proactor 通知"做完了"(完成事件,内核已搬运)——Windows IOCP、Linux io_uring。Linux 生态因 epoll 同步语义以 Reactor 为主。
内存操作 + epoll 事件循环(单 Reactor 单线程)= 无锁无切换。瓶颈在网络读写(协议解析/拷贝),6.0 加 I/O 线程分担读写解析,命令执行仍单线程——保持无锁简单性的同时补多核短板。
M 没阻塞:runtime 把 G 挂到 fd 的等待列表并 gopark,M 立刻跑别的 G;数据到达经 epoll 事件 → goready 排队。fd 本质是非阻塞 + LT,阻塞语义是 netpoller 造的假象。
三看:① top 看 sys/si 占比(软中断分摊?ksoftirqd 单核打满?);② pprof/火焰图看 epoll_wait 占比与 handler 热点(回调里有重活?);③ conntrack/fd/缓冲区水位。典型根因:事件循环里混入同步 I/O(慢查询、rpc 直调)——把重活移出 Reactor 线程。
Related & References
零拷贝 →(就绪之后的拷贝怎么省)
文件系统与 Page Cache →(read/write 的数据路径)
操作系统总览 →(软中断与 epoll 回调的内核语境)
进程线程协程 →(线程数与连接数解耦的前提)
GMP 调度模型 →(netpoller 与调度循环的协作)
Redis 线程模型 →(单 Reactor 到 I/O 线程的演化)
TCP 连接管理 →(就绪事件的协议层源头)
参考来源(本 deck 结论可溯源至下列一手材料)
| man 2 select / poll / epoll_ctl / epoll_wait | 三套 API 语义、LT/ET、ONESHOT 的一手定义 |
| OSTEP ch.40(Interrupt-based approaches)+ TLPI ch.63 | I/O 模型分类与事件驱动框架 |
| CSAPP ch.12.2(基于 I/O 多路复用的并发) | select 事件循环的教学基准 |
| kernel fs/eventpoll.c(本机内核源码) | 红黑树、rdllist、ep_poll_callback 回调路径 |
| Go src/runtime/netpoll.go / netpoll_epoll.go | pollDesc、gopark/goready、netpollBreak |
| xiaolincoding.com《图解系统》selete_poll_epoll / reactor | 三件套对比与 Reactor 三形态的中文叙述 |