Theory · OS · I/O Models & epoll

I/O 模型与 epoll

同步/异步 × 阻塞/非阻塞 · 五种 I/O 模型 · select/poll/epoll 演化 · Reactor —— 高并发网络服务的全部地基

概念校准

"同步/异步"说的是谁等数据(要不要拷贝),"阻塞/非阻塞"说的是等就绪时能不能让出——两把不同的尺子

演化主线

select(位图 1024)→ poll(数组)→ epoll(内核代管 + 就绪链表):把 O(n) 的遍历变成 O(1) 的派送

Go 现实

netpoller 把 epoll 包成"goroutine 阻塞"的假象——每个连接一个 goroutine 的写法,底下是事件驱动

定位:OS 系列第十一篇,网络 I/O 主角。本篇是后端面试 I/O 方向的天花板考题集合:概念辨析 → 机制演化 → 模式设计 → Go 落地。

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 个线程 —— 什么都没在读。
关键观察:长连接的绝大多数时间没有数据。线程却在为一个"可能永远不会到来的数据"付出完整的成本——内存、调度、上下文切换。这笔账不是慢,是根本不可行

没有 I/O 多路复用会怎样

等待的成本就是一整个线程:要同时等 N 个连接,就得有 N 个线程。连接数和线程数被死死绑在一起,于是"并发量"的天花板由线程成本而非业务需求决定——这就是 1999 年被命名的 C10K 问题

它换掉了什么(一句话)

从"一个线程等一个连接"换成"一个线程等一批连接":让内核帮你盯住一万个 fd,谁有数据了再叫你。线程数从此只跟 CPU 核数走,与连接数解耦——这是整个高并发网络编程的地基。

为什么光有它还不够

多路复用只解决""。数据来了以后还得(内核缓冲区 → 用户缓冲区),以及搬完以后谁来处理。前一个问题引出了 LT/ET 与零拷贝,后一个引出了 Reactor 三种形态——本篇要走的正是这两条支线。

本 deck 的路线

校准四个词(阻塞/非阻塞 × 同步/异步)→ 再给五种模型定位 → 然后走一遍 select → poll → epoll 的演化因果 → 接着是 LT/ETReactor → 最后落到 C10K 调优Go 的 netpoller

动机页:用"1 万长连接 = 80GB 线程栈"这个能立刻算出来的数字开场,讲清"等待成本 = 一整个线程"这个痛点,再给出多路复用换掉了什么与本篇路线。

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 的全部设计都是冲着这四个字去的——让内核长期记住你的关注列表,就绪时主动上报

阅读提示:术语不用背,忘了回来查这一页;真正要背的只有第 2 页那个数字账和最后那张速查表。
前置页:十个术语先定义再使用。最小心智模型"等就绪 + 拷数据两步"统一了后面所有模型与机制;提前点出 select/poll 的共同病根"内核没有记忆"。

Blocking vs Non-blocking · Sync vs Async

先校准四个词:两把尺子量两个阶段

开场那个"一万个线程白等"的问题,第一步是把概念说清楚——到底什么叫阻塞、什么叫异步。这两把尺子量的正是心智模型里的两个阶段。

尺子量什么阶段选项
阻塞 / 非阻塞等就绪阶段(数据到没到)阻塞:挂着等 · 非阻塞:立刻返回,没就绪给 EWOULDBLOCK
同步 / 异步拷数据阶段(就绪后的读写谁做)同步:用户线程自己 read/write · 异步:内核做完全程再通知你
正交性(一句话理清):阻塞与否描述"等待方式",同步与否描述"由谁完成数据搬运"——可以自由组合:阻塞同步(默认 read)、非阻塞同步(轮询 read)、非阻塞 + 多路复用(主流)、异步(AIO/io_uring)。

最容易答错的两点

① "多路复用是非阻塞所以是异步"——。epoll_wait 返回后还是要用户自己 read(同步拷贝),真正的异步是内核把数据搬到你的 buffer 再通知(AIO/io_uring)。② "非阻塞 = 不等待"——对,但代价是得配合多路复用或轮询,否则空转。

IO 多路复用的精确定位

一个线程等一批 fd(select/poll/epoll_wait 阻塞在"等待"上),谁就绪就处理谁——阻塞在收集器上,而不是阻塞在每个连接上。线程数与连接数从此解耦,这是 C10K 的钥匙。

fd 就绪的语义

"可读" = 接收缓冲区有数据(或连接关闭);"可写" = 发送缓冲区有空位。就绪是内核缓冲区的状态,不是数据到达网络的事件——这决定了 LT/ET 的行为差异(第 7 页)。

概念页先纠错再立正:两把尺子(等待方式/搬运主体)+ 正交组合。"epoll 不是异步"是最高频的混淆点。就绪=内核缓冲区状态,为 LT/ET 语义做铺垫。

The Five I/O Models

五种 I/O 模型总览:等待方式 × 搬运主体

五种 I/O 模型的流程对比 五行对比:阻塞 I/O 用户线程在等待与拷贝两个阶段都挂起;非阻塞 I/O 反复询问直到就绪再自己拷贝;多路复用阻塞在 select 或 epoll 上等多个描述符,就绪后自己拷贝;信号驱动 I/O 内核就绪后发信号,用户线程自己拷贝;异步 I/O 内核完成等待与拷贝全部工作后通知用户线程,只有它才算真正的异步。 模型 等待阶段 拷贝阶段 定性 ① 阻塞 I/O BIO 挂着等(内核挂起线程) 自己 read(阻塞) 同步阻塞 ② 非阻塞 I/O 轮询:没就绪立刻返回 EWOULDBLOCK 就绪后自己 read 同步非阻塞 ③ I/O 多路复用 ★ 阻塞在 select/poll/epoll 上管一批 fd 就绪后自己 read 同步(主流方案) ④ 信号驱动 SIGIO 注册 SIGIO,就绪时内核发信号 自己 read 同步(少用) ⑤ 异步 I/O AIO 内核接管:等就绪 + 搬数据 内核拷进用户 buffer 真异步 关键判别:只有 ⑤ 的拷贝阶段也是内核完成的——前四种数据搬运都要用户线程自己做(read 系统调用),所以都是"同步" Linux 的现代答案:io_uring(5.1+)把 ⑤ 做到生产可用——提交队列/完成队列共享环形缓冲,提交 I/O 甚至可以零系统调用 ③ 的定位:"等待阶段"的批量化改造——一个线程等一万连接,而不是一万线程各等一个连接
五模型的判别标准只有一条:"拷贝阶段谁做"。①②③④ 都是同步,⑤ 才是异步。io_uring 是现代 AIO 的正解(Linux 原生 AIO 名存实亡)。③ 的本质 = 等待阶段批量化。

select · fd_set

select:多路复用的起点,四个硬伤

工作方式

每次调用把 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);     // 就绪的处理
}
复杂度账本:1 万连接、100 就绪:select 拷 1 万个 fd + 内核扫 1 万 + 用户扫 1 万,只为找出 100 个——99% 的开销花在"没就绪"的连接上。epoll 的革命就是把这笔账清零(第 6 页)。
面试金句:"select 的病根:内核不记得你的关注列表——每次都从零递交、从零扫描。epoll 的答案:让内核记住(红黑树),就绪主动上名单(回调)。"
四硬伤按"拷贝/扫描/上限/重建"四连击讲。右侧代码展示重复劳动(重建+备份+遍历)。复杂度账本给出 select→epoll 的动机量化。

poll · pollfd[]

poll:突破 1024,但三个硬伤原样保留

改进点

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) 全量遍历
}
演化一张表(背这张就够):select → poll 解决"上限 + 重建";poll → epoll 解决"拷贝 + 扫描"(内核代管 + 就绪链表)。每一步演化只砍一个痛点,讲清因果比背结论高一档。
面试金句:"poll 把'每次重建关注集合'修掉了,但'内核没有记忆'这个病根还在——病根不除,拷贝与扫描的税一分不少。"
poll 的历史角色:修补而非革命。"内核无记忆"是贯穿 select/poll 的病根术语,用它把 epoll 的设计引出来。kqueue/IOCP 一句带过显示广度。

epoll · Red-Black Tree + Ready List

epoll:内核代管 + 回调 + 就绪链表

epoll 的内核结构与事件流 结构图:epoll_create 创建 eventpoll 对象,内核维护一棵红黑树保存用户关注的描述符,epoll_ctl 增删改;网卡数据到达触发内核回调,把就绪描述符挂到就绪链表;epoll_wait 只检查就绪链表,非空则拷贝就绪事件给用户并唤醒阻塞线程。 USER SPACE epoll_create1() → epfd epoll_ctl(epfd, ADD/MOD/DEL, fd) epoll_wait(epfd, events, max) KERNEL SPACE · eventpoll 对象 红黑树:长期记住全部关注 fd epoll_ctl 的 ADD/MOD/DEL 增删改节点 O(log n) 定位 · 只在 ctl 时进内核一次 → "内核有记忆":select/poll 的病根被拔掉 就绪链表 rdllist fd 就绪时,设备驱动回调把它挂上 epoll_wait 只看这条链表 → 有就绪才拷贝返回,无扫描 网卡收包 硬中断 → 软中断 ep_poll_callback 挂链 仅在注册时进内核一次 只拷就绪事件 wait 挂起 辟谣:epoll 没有"用 mmap 共享内存"——事件仍经 epoll_wait 拷贝给用户,但只拷"就绪的少数"· 读写数据本身照常走 read/write 拷贝(那是零拷贝篇的话题) 复杂度对比:select/poll 每轮 O(n) 拷贝 + O(n) 扫描;epoll 注册一次 O(log n),每轮只处理 O(就绪数)
三件套 + 两个内核结构(红黑树/就绪链表)+ 回调链路。"内核有记忆"对应 select 病根。辟谣框专治"epoll 用 mmap 零拷贝"的讹传——这是高级面试的鉴别题。

Level-Triggered vs Edge-Triggered

LT 与 ET:电平触发与边沿触发的取舍

语义(就绪是"状态"还是"事件")

LT(默认):只要缓冲区还有数据,每次 epoll_wait 都上报——"电平"= 持续状态。ET:只在状态变化的那一下(空→非空)上报一次——"边沿"= 变化事件。没读完?不会再说。

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 必须是非阻塞——否则读空即挂死
EPOLLONESHOT:事件只报一次,处理完要 epoll_ctl(MOD) 重新武装——多线程 Reactor 的标配:保证一个 fd 同一时刻只被一个线程处理,避免竞态。
面试金句:"LT 对'状态'负责,ET 对'变化'负责——ET 少打扰但把读干净的义务转嫁给了应用,义务不履行就是丢数据或死循环。"
LT/ET 的本质是"状态 vs 事件"语义。ET 三件套(非阻塞 + 读到 EAGAIN + ONESHOT 线程安全)是必背代码。Redis LT / Nginx ET 的选型事实给取舍背书。

Reactor · Proactor

Reactor:事件驱动的标准编程范式

Reactor 的三件套

事件循环(epoll_wait 等事件)+ 分发器(按 fd/事件类型路由)+ 处理器(回调函数)。本质是把"谁就绪处理谁"的循环做成框架——同步 I/O 多路复用的架构化表达。

三种经典形态

单 Reactor 单线程:一个线程包揽全部——Redis 6.0 前的经典形态(简单、无锁,但算重活会卡全服);② 单 Reactor 多线程:Reactor 线程收发,业务丢线程池——收发仍是瓶颈;③ 主从 Reactor 多线程:mainReactor 只管 accept,subReactor 池各管一批连接的读写——Nginx / Netty / Muduo 的形态,生产标准答案。

Proactor 一句话

Reactor 通知"可以读写了你来"(同步);Proactor 通知"读写已完成"(内核搬完数据)——真异步(IOCP/io_uring)的范式。Linux 生态 Reactor 是主流(epoll 同步语义),Windows IOCP 天生 Proactor。

形态谁干什么代表痛点
单 Reactor 单线程一线程全包Redis(<6.0)重活卡全服;多核浪费
单 Reactor 多线程收发单线程 + 业务线程池多数教学框架收发仍单点
主从 Reactoraccept 与读写分离,多 subReactorNginx / Netty实现复杂度最高
Proactor内核完成读写后通知IOCP / io_uringLinux 生态以 Reactor 为主
Redis 6.0 的 I/O 线程(版本敏感):命令执行仍单线程,但网络读写分给 I/O 线程组——"单 Reactor 单线程"升级为"单 Reactor + I/O 多线程",命令执行保持无锁简单性。
面试金句:"Reactor = 事件循环 + 回调分发;主从形态解决的是 accept 与 I/O、I/O 与业务的三层隔离——Redis/Nginx/Netty 只是三种切割方案。"
Reactor 三件套 + 三形态表格。Redis 6.0 I/O 线程是版本敏感细节(命令执行仍单线程)。Proactor 与上页"同步/异步"概念闭环。

C10K · 1M Connections

C10K 到百万连接:内存账与系统调优

C10K 问题的答案

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 的数字直觉:goroutine 初始 2KB——百万连接各占一个 goroutine 也只要 GB 级(还有 GC 管理),这就是"M:N + netpoller"在 C10K 语境下的红利。
面试金句:"C10K 的本质是等待成本 per 连接:BIO 的等待成本是一整个线程,多路复用把成本压到'一条链表节点'——内存账算完,答案自明。"
C10K 页把"等待成本"量化成内存账:8MB×1 万 vs 线程数=核数。百万连接的瓶颈转移(线程→内存/fd)+ 调优清单是运维向加分项。

Go netpoller

Go netpoller:把 epoll 包装成阻塞语义

为什么需要 netpoller

M:N 模型里 M(内核线程)贵而少:如果 goroutine 的网络读真的阻塞在 M 上,千连接就把 M 耗尽。netpoller 的目标:让用户写阻塞式代码,runtime 保证不阻塞 M

工作机制(一次 Read 的旅程)

① 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 解绑兜底)——网络与文件的差异待遇是常考题。

阶段内核/OSGo runtime
监听epoll 注册(非阻塞 LT)pollDesc 挂 G
等待epoll_wait(带超时)gopark,M 转去跑别的 G
就绪事件返回goready → 队列
拷贝read(同步,用户态发起)runtime 内联执行
面试金句:"netpoller = epoll 的事件 + 调度器的队列:把'fd 就绪'翻译成'goroutine 变为 runnable'——阻塞语义是假象,事件驱动是本体。"
深挖链接:netpoll 与 GMP 的完整协作(sysmon、netpollBreak、GOPARK 状态机)见 GMP deckOS 总览 的 Go 视角页。
一次 Read 的四阶段表是本页核心(LT + 非阻塞 + pollDesc + goready)。"网络有 netpoller、文件没有"的差异待遇是 Go 岗经典追问。epoll_wait 复用调度循环是机制级亮点。

Cheat Sheet

一页带走:两把尺子、三件套、LT/ET、模式

① 两把尺子(判别一切 I/O 问题)

阻塞 / 非阻塞等就绪阶段:挂着等 vs 立刻返回 EAGAIN
同步 / 异步拷数据阶段:用户自己 read vs 内核搬完再通知

② 五模型:一句判别

问"read 是谁调的?"
· 阻塞 / 非阻塞 / 多路复用 / 信号驱动 → 都得用户自己 read = 同步
· 只有 AIO(Linux 上是 io_uring)内核连搬带等 = 真异步

③ select / poll / epoll 对照

select位图,1024 上限,每轮重建 + 全量拷贝 + O(n) 扫描
poll数组,无上限、不用重建;拷贝与扫描照旧
epoll红黑树长期代管 + 回调挂就绪链表;每轮 O(就绪数)
病根一句:select/poll 的病根是"内核没有记忆",epoll 的全部设计就是这四个字的解药。

④ LT vs ET

LT(默认)只要还有数据每次都报;读一半也没事,代码好写
ET只在变化那一下报一次;必须非阻塞 + 循环读到 EAGAIN
ET 配套写事件易空转 → 写完即取消注册或 EPOLLONESHOT

⑤ Reactor 三形态

单 Reactor 单线程Redis(<6.0):无锁,重活卡全服
单 Reactor 多线程业务丢线程池,收发仍单点
主从 ReactorNginx / Netty:accept 与读写分离,生产标准答案

⑥ C10K 与排查

等待成本 per 连接:BIO = 一整个线程;多路复用 = 一条链表节点。
· 百万连接瓶颈 → fd 上限(ulimit -n / fs.file-max)+ 收发缓冲内存
· 延迟抖动三看 → top 的 sys/si、pprof 火焰图、缓冲区水位
· 铁律:Reactor 线程里禁止放同步重活(慢查询、RPC 直调)
一句话背下来:I/O 只有等就绪拷数据两步;多路复用把"等"的成本从一整个线程压成一个链表节点。
速查页:六块按使用顺序排——两把尺子、五模型判别、三件套对照、LT/ET、Reactor 三形态、C10K 与排查。回看时只看这一页。

Interview QA · Part 1

高频追问:select / poll / epoll

先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。

1 · select 和 poll 的区别?

位图 vs 数组

select 用 fd_set 位图(1024 上限、每次重建、IN/OUT 共用一位);poll 用 pollfd 数组(无硬上限、events/revents 分离不用重建)。但两者都全量拷贝 + O(n) 扫描 + 用户遍历——复杂度同级。

2 · epoll 为什么快?三个机制分别解决什么?

红黑树/回调/就绪链表

红黑树让内核长期持有关注集合(免重复拷贝,ctl 一次);设备就绪时回调把 fd 挂就绪链表(免全量扫描);epoll_wait 只取就绪链表(返回量 = 就绪数,用户免遍历)。快在"开销与就绪数成正比,与总连接数无关"。

3 · epoll 用 mmap 减少拷贝吗?

辟谣

没有。epoll_wait 返回事件时仍是内核→用户拷贝,只是只拷就绪的少量事件;网络数据本身照常经 read/write 拷贝。"epoll+mmap 零拷贝"是流传最广的讹传——能主动辟谣是加分项。

4 · LT 和 ET 的区别?ET 有什么坑?

状态 vs 事件

LT 每轮上报所有未处理完的就绪(状态),ET 只在变化时报一次(事件)。ET 的坑:必须非阻塞 fd + 循环读到 EAGAIN,否则丢事件或挂死;写事件易空转,需写完即取消注册或 ONESHOT。

5 · epoll 一定比 select/poll 快吗?

看活跃比

不一定。fd 少(几十个)或"几乎全部活跃"时,红黑树维护 + 回调的开销可能反超线性扫描——select 在小而活跃的场景反而轻。epoll 的甜区:连接多、活跃占比低(长连接网关、IM、推送)。

6 · 一 epoll 一线程好,还是多线程共享 epoll 好?

so_reuseport

主流是每核一个 epoll 一个线程(无锁、cache 友好):accept 用 SO_REUSEPORT 内核分桶,连接按落点归属各 Reactor。共享一个 epoll 多线程 wait 会引入唤醒竞态(要用 EPOLLONESHOT 补),复杂且不划算。

六题覆盖 select/poll/epoll 主线:区别、三机制、mmap 辟谣、LT/ET、适用边界、线程模型。第 3、5 题是"反常识加分题"。

Interview QA · Part 2

高频追问:模型与工程实践

同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。

7 · 阻塞/非阻塞、同步/异步到底怎么区分?

两把尺子

阻塞/非阻塞量"等就绪"阶段(挂起 vs 立即返回);同步/异步量"搬数据"阶段(用户自己做 vs 内核做完通知)。IO 多路复用 = 同步(自己 read),AIO/io_uring = 异步(内核搬完)。

8 · 五种 I/O 模型为什么只有 AIO 是异步?

搬运主体

前四种(BIO/NIO/多路复用/信号驱动)的数据搬运都由用户线程 read 完成——同步;AIO 的等待+搬运全由内核完成,完成后再通知。判别问题一句话:"read 是谁调的?"

9 · Reactor 和 Proactor 的区别?各在哪见得到?

通知时机

Reactor 通知"可以做了"(就绪事件,用户做 I/O)——epoll/Redis/Netty/Nginx;Proactor 通知"做完了"(完成事件,内核已搬运)——Windows IOCP、Linux io_uring。Linux 生态因 epoll 同步语义以 Reactor 为主。

10 · Redis 为什么快?为什么 6.0 又加了 I/O 线程?

单线程事件循环

内存操作 + epoll 事件循环(单 Reactor 单线程)= 无锁无切换。瓶颈在网络读写(协议解析/拷贝),6.0 加 I/O 线程分担读写解析,命令执行仍单线程——保持无锁简单性的同时补多核短板。

11 · Go 里 goroutine 读 socket "阻塞"了,M 在干什么?

gopark/netpoll

M 没阻塞:runtime 把 G 挂到 fd 的等待列表并 gopark,M 立刻跑别的 G;数据到达经 epoll 事件 → goready 排队。fd 本质是非阻塞 + LT,阻塞语义是 netpoller 造的假象。

12 · 线上百连接网关延迟抖动,怀疑 I/O 模型问题怎么查?

cpu/sys/软中断

三看:① top 看 sys/si 占比(软中断分摊?ksoftirqd 单核打满?);② pprof/火焰图看 epoll_wait 占比与 handler 热点(回调里有重活?);③ conntrack/fd/缓冲区水位。典型根因:事件循环里混入同步 I/O(慢查询、rpc 直调)——把重活移出 Reactor 线程。

工程组六题:两把尺子、异步判别、Reactor/Proactor、Redis 6.0、Go netpoller、抖动排查。第 12 题的"Reactor 线程禁放重活"是实战铁律。

Related & References

相关知识点与参考

OS 系列(本分类)

零拷贝 →(就绪之后的拷贝怎么省)
文件系统与 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.63I/O 模型分类与事件驱动框架
CSAPP ch.12.2(基于 I/O 多路复用的并发)select 事件循环的教学基准
kernel fs/eventpoll.c(本机内核源码)红黑树、rdllist、ep_poll_callback 回调路径
Go src/runtime/netpoll.go / netpoll_epoll.gopollDesc、gopark/goready、netpollBreak
xiaolincoding.com《图解系统》selete_poll_epoll / reactor三件套对比与 Reactor 三形态的中文叙述
收尾:man 2 系列是语义正源,eventpoll.c 是机制正源,netpoll.go 是 Go 侧正源。总页数 13。下一篇:零拷贝——就绪之后的"搬运"优化。