想象你开了一家生意火爆的餐厅。开业第一天,你请了八个服务员,每人只盯一张桌子,顾客一举手,服务员立刻小跑过去——这叫“一对一专属服务”。可你很快发现:大部分时候服务员都在闲站,因为顾客点完菜后要等很久才会再需要服务。一个服务员一天真正干活的时长不到两成,人力全浪费了。 你开始琢磨,怎么让一个服务员同时照看很多桌?第一版方案:服务员每隔几分钟绕场一圈,挨桌问“您需要什么吗”,不管有没有需求都问一遍——这叫“巡场询问”,客人一多,光问一圈就要半小时。第二版方案:你在每张桌上装一个呼叫铃,顾客按铃,前台总机把桌号记下来,空闲服务员照单服务,没按铃的桌子根本不用跑。这套“按铃呼叫”系统,就是网络世界里 IO 模型不断进化的缩影。 而 epoll 与 kqueue,正是 Linux 和 macOS 上两套最出色的“餐厅呼叫系统”。Nginx、Redis、Netty 都在用它们扛住百万并发。这篇文章,我们就从“挨桌询问”讲到“按铃呼叫”,再讲到“自助下单”,把高性能并发的底座彻底看明白。

岗位关联

对 AI 从业者:大模型推理、流式输出、高并发 API 网关背后全靠这套 IO 底座。搞懂 epoll/kqueue,你才能真正看懂“高并发 AI 服务”的性能天花板在哪里,而不是只会调参。

目录
  1. 可视化演示
  2. IO演进之路:从一对一陪聊到总机接线
  3. select与poll:挨桌询问的笨办法
  4. epoll核心机制:按铃呼叫的聪明总机
  5. LT与ET触发:两种响铃方式
  6. kqueue与跨平台:隔壁品牌的呼叫系统
  7. 惊群与Reactor:一群服务员抢一单
  8. io_uring与未来:自助下单的新时代
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

对比展示广度优先搜索与深度优先搜索的节点访问顺序,动态呈现队列与栈的驱动力差异及遍历路径的构建过程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

广度优先搜索 BFS 队列驱动逐层扩展访问节点 先访问邻居再访问下一层 Q→A→B,C→D,E,F,G 层次遍历顺序

IO演进之路:从一对一陪聊到总机接线

开餐厅的第一天,你最朴素的想法是:一个服务员盯一桌,顾客一招手就过去,全程只服务这一桌。这在网络世界里就是阻塞 IO:线程发起一次 read 之后,就一直挂在那里等数据返回,在这期间它无法服务任何其他连接。这种模式写代码最简单,但代价极大——一万人同时来吃饭,你就得雇一万个服务员(线程),服务器内存和上下文切换开销直接爆炸。

后来你改进了一下:服务员不再死盯一桌,而是每隔几秒就去问一次“您需要什么吗”,没有需求就转身去下一桌。这就是非阻塞 IO:线程每次 read 立即返回,有数据就处理,没数据就去做别的事,过一会儿再回来问。听起来不错,可仔细一想,服务员的大量时间还是耗在“来回跑着问”上,CPU 也被空轮询白白烧掉了——这其实就是忙等待。

真正的转折点,是你给每张桌子装上了呼叫铃。顾客需要服务时按一下铃,前台总机把桌号记下来,服务员不用巡场,只盯着名单等派单。这套模式在 IO 世界里叫多路复用:一个线程可以同时监视成千上万个连接,只处理真正就绪的那些。在这之后还有两个变体——信号驱动 IO 像顾客按完铃先准备菜,准备好了再正式通知总机;异步 IO 则像顾客直接写好小票投进厨房,全程不用服务员参与。把这五阶段的总时间写成一个公式,能直观看出差距:

从一对一陪聊到总机接线,每一次升级的本质都是“减少服务员的无效等待”。而 epoll 正是这套总机系统最成熟的形态——接下来我们看看它之前的两位前任有多笨拙。

Ttotal=∑i=1nti\displaystyle T_{total} = \sum_{i=1}^{n} t_i
这个公式表示:在阻塞模型下,处理 n 个请求的总时间等于每个请求处理时间依次相加——因为一个服务员同一时刻只能服务一桌,所有请求必须排队。
  1. T_total完成全部 n 个请求所花费的总时间,阻塞模型下的核心指标。
    Ttotal\displaystyle T_{total}
  2. 求和符号从 i=1 一直加到 i=n,把 n 个请求各自的耗时全部累加起来,体现“串行排队”的本质。
    ∑i=1n\displaystyle \sum_{i=1}^{n}
  3. t_i 与下标 i第 i 个请求单独消耗的时间。每个请求都完整占用服务员的全部注意力,谁也插不了队。
    ti\displaystyle t_i

select与poll:挨桌询问的笨办法

第一代“呼叫系统”其实名不副实,它本质上是巡场询问制:总机每隔一段时间派一个服务员绕场一周,挨桌问“需要服务吗”,把举手的桌号记下来,然后挨个服务。在网络世界里,这套巡场系统就是 select。它的工作方式是:把你想监视的所有文件描述符(fd)塞进三个位图集合(读、写、异常),交给内核去检查一遍,内核返回后你再自己逐个遍历,看哪些 fd 就绪了。

select 有三宗罪。第一,数量上限:位图集合的大小固定为 FD_SETSIZE,在 Linux 上默认是 1024,也就是说一个 select 最多只能监视 1024 个连接——大餐厅根本不够用;第二,来回搬运:每次调用 select,都要把整个集合从用户态拷到内核态,检查完再拷回来,上万连接时光是拷贝就是巨大开销;第三,盲目遍历:无论只有 1 桌按铃还是 999 桌按铃,服务员都得把 1000 桌全部问一遍才能确定名单,即 O(n) 的复杂度。

poll 是 select 的小升级版:它用链表数组替代了位图,取消了 1024 的上限,但从根本上说,它依然要遍历全部 fd 才知道谁就绪,依然要反复拷贝。它的复杂度同样是:

巡场询问制在 20 桌的小饭馆尚可应付,可一旦变成 2 万桌的巨无霸餐厅,光巡场就累死服务员了。老板痛定思痛,终于引入了真正的“按铃呼叫”系统——这就是 epoll。

Tselect=O(n),n=fd 数量\displaystyle T_{select} = O(n), \quad n = \text{fd 数量}
这个公式说明 select/poll 的开销与连接总数线性相关:连接越多,每次“巡场询问”的时间就越长。哪怕只有两个连接真正就绪,也要把成千上万个连接统统检查一遍。
  1. T_select一次 select/poll 调用从开始到返回所花的时间成本。
    Tselect\displaystyle T_{select}
  2. O(n) 记号表示耗时随 n 线性增长——n 翻一倍,巡场时间也翻一倍,这是算法复杂度的“线性级”。
    O(n)\displaystyle O(n)
  3. n 的含义当前监视的文件描述符总数,也就是餐厅里的总桌数,跟实际有几桌按铃没有任何关系。
    n=fd 数量\displaystyle n = \text{fd 数量}

epoll核心机制:按铃呼叫的聪明总机

epoll 的革命性在于:彻底抛弃巡场,改为事件驱动。老板在每张桌上装一个铃铛,顾客需要服务就按铃,内核作为总机把桌号记进“就绪名单”,服务员(应用程序)只需要在名单前等待,名单上有谁就服务谁——没按铃的桌子,哪怕有一万桌,也完全不需要理会。

epoll 对外只暴露三个接口,对应餐厅里的三件事。epoll_create:在入口安装一个空总机(创建 eventpoll 实例);epoll_ctl:给某张桌装铃铛(注册 fd)或拆铃铛(注销 fd),这个操作在 Linux 源码里通过红黑树完成——红黑树让“找到某张桌”的耗时稳定在 O(log n),无论桌子数量多大都能快速增删查;epoll_wait:服务员在总机前等待,一旦有人按铃,内核把对应桌子挂到就绪链表上,epoll_wait 直接把这条链表上的桌号返回给服务员,不再需要遍历全部注册的连接。

这套设计的精妙之处在于空间换时间:红黑树管“金库登记册”(所有注册的 fd),就绪链表管“今日呼叫名单”(真正就绪的 fd),两者通过同一个节点结构关联。当某个 fd 就绪时,内核用 O(1) 的指针操作把它挂进链表;当连接关闭时,红黑树快速删除。于是从复杂度上看,差距一目了然:

按铃呼叫系统让服务员的工作效率直线上升。但铃铛本身还有一个讲究——响铃的方式,直接决定了服务员的工作策略。这就是 LT 与 ET。

Tselect=O(n),Tepoll=O(k),k≪n\displaystyle T_{select} = O(n), \quad T_{epoll} = O(k), \quad k \ll n
k 是实际就绪的连接数(按铃的桌数),n 是全部连接数(总桌数)。epoll 的每次等待只付出与“就绪数量”成正比的代价,而不是与“连接总数”成正比——这就是它能扛百万连接的核心原因。
  1. T_select = O(n)select 的代价由连接总量 n 决定,巡场一圈,付出全量成本。
    Tselect=O(n)\displaystyle T_{select} = O(n)
  2. T_epoll = O(k)epoll 的代价由就绪数量 k 决定,名单上有多少桌就服务多少桌。
    Tepoll=O(k)\displaystyle T_{epoll} = O(k)
  3. k 远小于 n在真实的高并发场景下,几万连接里同时活跃的往往只有几十个,k 比 n 小几个数量级。
    k≪n\displaystyle k \ll n

LT与ET触发:两种响铃方式

同样是按铃呼叫,铃铛可以设置两种响法。LT(水平触发)是“持续响铃模式”:只要某张桌还有未完成的服务需求(比如菜还没上齐,即内核缓冲区里还有数据可读),铃铛就一直响。服务员任何时候看就绪名单,这张桌都在上面,可以慢慢处理,处理完之前它不会消失。

ET(边缘触发)则是“响一下模式”:铃铛只在顾客按下按钮的那一瞬间响一次,之后哪怕菜没上齐,铃也不会再响。对服务员来说,这意味着听到铃声后必须立刻冲过去把所有事情全部办完——在网络世界里,就是必须用非阻塞 IO,并且循环 read 直到返回 EAGAIN 错误码,才能确保缓冲区里的数据被全部读走。如果读了一部分就收手,剩余数据就永远等不到下一次铃声了——这叫事件丢失。

这两种模式各有拥趸:Redis 选择 LT,因为它追求代码简单可靠;Nginx 选择 ET,因为它追求极致性能,愿意承担更严格的编码纪律。用条件表达式的语言描述两种触发时机:

LT 像传统餐厅的响铃服务,稳妥但会重复打扰;ET 像极限翻台率餐厅的“一声令下,使命必达”,高效但容错率极低。选哪种,取决于你的餐厅是求稳还是求快。

LT:上报事件(t)  ⟺  buffer(t)>0\displaystyle LT: \text{上报事件}(t) \iff \text{buffer}(t) > 0
ET:上报事件(t0)  ⟺  buffer(t0−)=0 ∧ buffer(t0)>0\displaystyle ET: \text{上报事件}(t_0) \iff \text{buffer}(t_0^-) = 0 \ \wedge \ \text{buffer}(t_0) > 0
第一条说:LT 在任何时刻 t,只要缓冲区里有数据就上报;第二条说:ET 只在上一个瞬间缓冲区为空、这一瞬间变为非空的“跳变时刻”上报一次。一个看状态,一个看变化。
  1. 上报事件(t)epoll 是否向应用程序报告这个 fd 就绪的函数。
    上报事件(t)\displaystyle \text{上报事件}(t)
  2. buffer(t) > 0在时刻 t,内核接收缓冲区中还有数据没被读走。
    buffer(t)>0\displaystyle \text{buffer}(t) > 0
  3. t0 上标负号表示事件发生的“前一瞬间”,是边缘触发判断的关键——只看状态是否发生了从无到有的跳变。
    t0−\displaystyle t_0^-
  4. 逻辑与符号要求“之前为空”和“现在非空”两个条件同时成立,才触发一次上报。
    ∧\displaystyle \wedge

kqueue与跨平台:隔壁品牌的呼叫系统

你的餐厅做成了连锁品牌,总店开在 Linux 上,用的是 epoll 呼叫系统;分店开在 macOS 或 BSD 上,装的却是另一家厂商的 kqueue 系统。功能上两者半斤八两,但铃铛的形状、按钮的位置、维修的方式完全不同——这就是为什么跨平台框架如此重要。

kqueue 是 BSD 系操作系统(包括 macOS)提供的事件通知接口,地位与 epoll 完全对等。但它有两个显著个性:第一,事件范围更广,除了读写 IO 事件,还能监听信号(EVFILT_SIGNAL)、定时器(EVFILT_TIMER)、文件系统变更(EVFILT_VNODE)、用户自定义事件(EVFILT_USER)——相当于总机不仅能接顾客的按铃,还能接火警警报、下单提醒和老板的内部通知;第二,接口更统一,epoll 要三个系统调用各司其职,而 kqueue 把注册、修改、等待全部收敛到一个 kevent() 函数里,通过参数区分动作。

kqueue 注册一个事件时,需要填充一个六元组结构,这是它接口设计的核心:

对普通开发者来说,记住两套铃铛的差异太痛苦了。于是 Netty、libevent、libuv 这些“连锁集团”在底层做了设备适配层,把 epoll、kqueue、IOCP 统一成一个接口。你依然用同一套 API 写代码,但心里要清楚:在 Linux 上是 epoll 在跑,在 mac 上是 kqueue 在跑。

kevent=(ident,filter,flags,fflags,data,udata)\displaystyle \text{kevent} = (\text{ident}, \text{filter}, \text{flags}, \text{fflags}, \text{data}, \text{udata})
这个元组就是“给总机递一张事件登记表”:填上你要监听的对象(ident)、事件类型(filter)、动作(flags)、附加条件(fflags)、返回数据(data)和用户自定义注释(udata),一张表说明白所有需求。
  1. ident事件关联的对象,通常就是文件描述符(fd),相当于桌号。
    ident\displaystyle \text{ident}
  2. filter事件过滤器类型,决定监听什么——读就绪、写就绪、定时器还是信号。
    filter\displaystyle \text{filter}
  3. flags本次调用的动作标志,比如添加(EV_ADD)、删除(EV_DELETE)还是使能(EV_ENABLE)。
    flags\displaystyle \text{flags}
  4. data 与 udatadata 是内核返回的附加数据(如可读字节数),udata 是用户自己挂的备注信息,回调时原样带回。
    data, udata\displaystyle \text{data}, \ \text{udata}

惊群与Reactor:一群服务员抢一单

餐厅中午高峰期,八个服务员都守在总机旁边等铃响。忽然一声铃响,八个人条件反射般同时冲过去——结果只需要一个人端菜,剩下七个跑到一半悻悻而归。这不仅白跑一趟,还可能挤撞在一起。在网络世界里,这叫惊群效应:多个进程或线程同时 epoll_wait 同一个监听 socket,新连接到达时它们全部被唤醒,但最终只有一个能 accept 成功,其余的都白被唤醒一次,白白消耗 CPU。

怎么解决?历史上走过三条路。第一条,内核锁与序列化:老版本 Nginx 用 accept_mutex 互斥锁,只有抢到锁的 worker 才能去 accept,其他人继续睡觉——相当于老板规定“只有跑得最快的前三名才许冲,其他人在原地待命”;第二条,SO_REUSEPORT:让多个 socket 分别都 bind 到同一个端口,内核直接把新连接分发到其中一个 socket 上,从源头让每个 worker 只听到属于自己的铃声——相当于把餐厅划成八个区域,每个服务员只听自己区域内的铃;第三条,EPOLLEXCLUSIVE:Linux 4.5 之后的标志,epoll 事件唤醒时只唤醒一个等待者,而不是广播给所有人。

惊群的本质是概率问题。假设 n 个 worker 都在睡,新连接到达时被唤醒的人数分布,可以用这样一个公式刻画:

惊群只是并发路上的一块绊脚石。真正塑造现代网络框架的,是Reactor 模型:总台接线员(事件循环线程)不亲自干活,只负责听铃、查名单、把任务派给对应的服务员(handler 回调)。Nginx、Redis、Netty 的主循环全都是这个套路——一个线程 epoll_wait 拿到就绪事件列表,然后逐个调用注册好的回调函数。理解了这套派单逻辑,你就读懂了几乎所有高性能网络框架的心脏。

P(至少两个 worker 被唤醒)=1−(1−1n)n\displaystyle \text{P}(\text{至少两个 worker 被唤醒}) = 1 - \left(1 - \frac{1}{n}\right)^n
当 n 为 8 时,这个概率约等于 65%——也就是说,如果不加任何防护,一大半的新连接都会触发至少一次惊群。n 越大,惊群几乎必然发生。
  1. P(至少两个 worker 被唤醒)一次新连接到来时,出现“多人同时醒来”这一混乱事件的概率。
    P(至少两个被唤醒)\displaystyle \text{P}(\text{至少两个被唤醒})
  2. 1/n在均匀分布下,某个特定 worker 被选中的概率——每个 worker 觉得自己“可能被点到”。
    1n\displaystyle \frac{1}{n}
  3. 1 - (1 - 1/n)^n用 1 减去“只有至多一个 worker 被唤醒”的互补概率,得到“至少两个”的概率。
    1−(1−1n)n\displaystyle 1 - \left(1 - \frac{1}{n}\right)^n

io_uring与未来:自助下单的新时代

epoll 已经很优秀了,但它还有一个尴尬之处:它只管“通知”,不管“搬运”。服务员听到铃声跑过去,还得自己端菜(read/write)。每完成一次 IO,基本要两次系统调用:一次 epoll_wait 等通知,一次 read/write 搬数据。连接一多,系统调用的开销就成了新的瓶颈。

io_uring 给出的答案是——把餐厅改成自助下单。顾客进门直接在自助机上下单,小票投进厨房的“订单信箱”(提交队列 SQ),厨师按照信箱里的小票做菜,做好后端到“取餐口”(完成队列 CQ),整个过程不需要服务员来回传话。在内核实现上,io_uring 在用户态和内核态之间开辟一块共享内存环形队列,应用程序把成百上千个读写请求一次性写进 SQ,然后只发一次系统调用(io_uring_enter),内核批量处理,结果统一写回 CQ,应用直接从队列里取。一次简单的 io_uring_enter,完成了过去几百次系统调用的工作量:

io_uring 自 Linux 5.1 引入以来,正在从存储领域向网络领域蔓延,RocksDB 等存储引擎已经用上,Redis 也在探索。它的意义不是“淘汰 epoll”,而是把“按铃呼叫”升级为“自助下单”——少说话、批量做、真异步。对工程师来说,理解这个趋势,就等于看见了 IO 模型的下一个十年。

Nepoll=O(k),Nio_uring=O(1)\displaystyle N_{epoll} = O(k), \quad N_{io\_uring} = O(1)
对比的是“完成 k 次 IO 需要的系统调用次数”。传统方式每次都要“等通知 + 搬数据”两趟来回,k 次 IO 就要 O(k) 次系统调用;io_uring 把 k 个请求攒成一摞小票一次提交,系统调用次数压到常数级。
  1. N_epoll = O(k)传统 epoll 模型下,每完成一次 IO 至少一次 epoll_wait 加一次 read/write,k 次 IO 就要付出线性增长的系统调用代价。
    Nepoll=O(k)\displaystyle N_{epoll} = O(k)
  2. N_io_uring = O(1)io_uring 模式将 k 个请求批量提交,一次系统调用处理所有事。
    Nio_uring=O(1)\displaystyle N_{io\_uring} = O(1)
  3. k 的语义这里指 IO 请求的总次数,在批量提交下,k 可以轻松达到成百上千。
    k=IO 请求次数\displaystyle k = \text{IO 请求次数}

AI 应用场景

大模型推理服务的流式输出

阶段:推理服务部署与性能调优

解决的问题:GPT 类模型逐 token 生成内容,一个推理请求可能保持几十秒的 SSE 长连接。如果采用“一连接一线程”的阻塞模型,1 万并发就要 1 万线程,内存与上下文切换开销直接把服务压垮。FastAPI/uvicorn 底层的事件循环正是借助 epoll 管理海量长连接,用几个线程就撑住了几十万并发连接。

具体案例:某大模型 API 的 /v1/chat/completions 接口,高峰期同时维持 8 万条 SSE 长连接推送 token,单机 CPU 占用率不到 60%——靠的就是事件循环 + 非阻塞 IO,这正是 epoll 的主场。

API网关:所有AI请求的第一道门

阶段:在线服务流量入口

解决的问题:AI 中台要统一管理成百上千个模型服务的路由、鉴权、限流。Kong、Envoy、Nginx 这类网关全部基于 epoll 事件驱动,才能支撑几十万并发的接入流量。如果网关层用 select 或阻塞模型,它会最先成为瓶颈,后端模型再快也进不来。

具体案例:某公司 AI 中台网关承载 200 多个模型服务的统一路由,单集群日处理百亿级请求,网关 CPU 依然平稳——这是 epoll 高并发能力在真实生产环境中的典型验证。

实时推荐系统的长连接推送

阶段:特征数据接入与在线推荐下发

解决的问题:个性化推荐系统需要向用户端维持海量 WebSocket 长连接,实时推送商品特征与推荐结果。每个用户一条长连接,50 万在线用户就是 50 万连接,底层必须用 kqueue/epoll 事件循环管理这些长连接的读写状态,而不是为每个用户开一个线程。

具体案例:某电商推荐平台用 Netty(基于多路复用 NIO)维护 50 万在线 WebSocket 连接,在双十一高峰期完成每分钟百万级推荐消息的下发,单机支撑数万连接。

Python 演示

python30 行
import selectorsimport socket sel = selectors.DefaultSelector()  # Linux 上底层就是 epoll def on_accept(server, mask):    conn, addr = server.accept()    conn.setblocking(False)        # 必须非阻塞, 事件驱动的前提    sel.register(conn, selectors.EVENT_READ, on_read) def on_read(conn, mask):    data = conn.recv(1024)    if data:        conn.send(data)            # echo 回显    else:        sel.unregister(conn)       # 关闭连接, 相当于 epoll_ctl DEL        conn.close() server = socket.socket()server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server.bind(('127.0.0.1', 8888))server.listen(128)server.setblocking(False)sel.register(server, selectors.EVENT_READ, on_accept) print('echo server is running, try: nc 127.0.0.1 8888')while True:    events = sel.select()          # 相当于 epoll_wait, 阻塞等待就绪事件    for key, mask in events:        key.data(key.fileobj, mask)
目的:用 30 行代码演示 epoll/kqueue 在真实网络编程中的样子——一个事件循环 + 两个回调函数,就完成了一个可用的高并发 echo 服务器。
  1. sel = selectors.DefaultSelector()根据操作系统自动选择 epoll(Linux)或 kqueue(macOS),这就是跨平台适配层的雏形。
  2. conn.setblocking(False)把连接设为非阻塞,这是事件驱动的前提:没有数据时 read 立即返回错误而不是卡住线程。
  3. sel.register(conn, selectors.EVENT_READ, on_read)注册一个“读就绪”事件并绑定回调,等价于 epoll_ctl 往红黑树里挂节点。
  4. events = sel.select()阻塞等待内核返回就绪事件列表,等价于 epoll_wait。
  5. key.data(key.fileobj, mask)取出注册时绑定的回调并执行,这就是 Reactor 的派单动作。

常见误区

误区epoll 不阻塞,数据准备好了会自动通知我,那它不就是异步 IO 吗?io_uring 和 epoll 应该是一回事吧。

正解epoll 本质是同步非阻塞 IO 的多路复用——它只负责高效地告诉你“哪个 fd 就绪了”,但真正的 read/write 还是要你亲自发起、亲自完成。打个比方:epoll 是总机告诉你“3 号桌在招手”,你还得自己走过去听顾客点单;io_uring 才是顾客直接写好小票,你取走就能做菜。数据拷贝这一步,epoll 并没有交给内核。

为何常见:“不阻塞”和“异步”两个概念在中文教程里经常被混用,“事件驱动”标签又让 epoll 看起来很像异步,初学者极易混淆。

误区ET 只响一次,省掉了重复上报的开销,Nginx 用 ET 就是比 Redis 用 LT 高级,我以后无脑用 ET。

正解ET 省的是“重复上报”,但代价是必须循环读/写直到 EAGAIN,一旦中途退出就会漏数据。LT 每次走到就绪链表都会带上它,代码简单不易丢事件。选 ET 的前提是你彻底吃透了非阻塞 IO 和循环读取,否则线上丢数据事故等着你。技术选型没有银弹,只有场景匹配。

为何常见:性能崇拜心理在作祟——看到明星项目用 ET 就当银弹,却忽略了它背后更严格的编码纪律和更高的出错风险。

误区只要上了 epoll,服务性能就有保障,其他模型都该淘汰。以后写代码一律 epoll。

正解epoll 擅长的是“海量空闲长连接”场景——连接数大、活跃比例低。如果是几十个连接、每个请求都是重 CPU 计算,多线程阻塞模型反而更简单高效;短连接频繁建立销毁的场景,epoll 的注册与回收开销同样不可忽视。选型要看 workload,而不是看单个技术名词。

为何常见:技术社区热衷于“XX 性能爆表”的标签化传播,让人忽略前提条件,把局部最优当成了全局真理。

误区既然 epoll 这么强,select/poll 就是上个世纪的古董,面试背一背就好,不值得动手理解。

正解Windows 没有 epoll,跨平台小工具和脚本里 select 依然是兜底方案;fd 数量少的时候,select 的实现简单直接,反而没有红黑树和就绪链表的复杂度。Go 的 netpoller、Python 的 selectors 底层都保留了 select 作为降级选项。理解它的轮询本质,你才能真正理解 epoll 改进了什么、为什么改进。

为何常见:喜新厌旧是学习常态,只盯着性能最优解,忽视了兼容层、兜底方案和演进脉络的价值。

误区Linux 有了 SO_REUSEPORT 和 EPOLLEXCLUSIVE,惊群早就成为历史,不用再考虑了。

正解这两个机制确实大幅缓解了惊群,但 SO_REUSEPORT 需要多个 socket 各自 bind 同一端口,存在长连接分配不均、某一 worker 连接堆积的问题;EPOLLEXCLUSIVE 保证“只唤醒一个”,但如果多个线程仍对同一个 fd 做 accept,依然需要配合锁机制。框架开发者在设计多进程模型时,这些细节一个都省不掉。

为何常见:把两个补丁式方案当成万能药,忽略了它们各自的适用条件和副作用——真实的工程世界从来没有“一键解决”。

面试题

Q1. epoll 在内核中使用哪种数据结构管理已注册的文件描述符?

单选题 · easy

  • A.定长数组维护全部连接
  • B.红黑树管理注册表结构← 正确答案
  • C.哈希表进行快速定位
  • D.单向链表进行顺序查找

答案:B。epoll 用红黑树(rbtree)组织所有已注册的 fd(epitem),支持 O(log n) 的增删查;就绪的 fd 则通过指针挂进就绪链表。数组是 select 的位图实现,哈希表和单向链表都不是 epoll 的核心数据结构。

Q2. Redis 的事件循环基于下列哪种模型?

单选题 · easy

  • A.多线程阻塞 IO 模型
  • B.单线程加 IO 多路复用← 正确答案
  • C.纯异步 io_uring 模型
  • D.无阻塞循环轮询模型

答案:B。Redis 是典型的单线程 Reactor:一个主线程跑事件循环,用 epoll 同时监视成千上万个客户端连接,就绪事件逐个处理。它没有用多线程阻塞模型,也没有全面转向 io_uring,第 4 项“无阻塞循环轮询”描述的是忙等待,不是 Redis 的做法。

Q3. 下列关于阻塞 IO 的说法,正确的是?

单选题 · easy

  • A.线程在等待时不断轮询
  • B.线程在等待时被挂起← 正确答案
  • C.线程在等待时继续运行
  • D.线程在等待时直接返回

答案:B。阻塞 IO 调用 read/write 后,线程进入睡眠(挂起)状态,直到内核准备好数据才被唤醒,期间不消耗 CPU 但也不能服务其他连接。轮询是非阻塞 IO 的做法,“继续运行”或“直接返回”对应的是非阻塞 IO 的忙等待过程。

Q4. 关于水平触发(LT)与边缘触发(ET),下列说法正确的是?

单选题 · mid

  • A.LT 只在缓冲区从空变非空时通知一次
  • B.ET 在缓冲区非空时持续通知直到读完
  • C.LT 在缓冲区非空时持续通知直到读完← 正确答案
  • D.ET 与 LT 的通知机制和时机完全相同

答案:C。LT(水平触发)只要缓冲区里还有数据,每次 epoll_wait 都会上报,直到数据被全部读走;ET(边缘触发)只在缓冲区从空变为非空的那个瞬间通知一次,之后不再重复。A 说的是 ET,B 说的是 LT,D 明显错误。

Q5. epoll_wait 返回后,获取就绪事件的效率是怎样的?

单选题 · mid

  • A.仍需要遍历全部已注册连接
  • B.只需要遍历就绪链表即可← 正确答案
  • C.需要额外两次系统调用来确认
  • D.需要在用户态轮询所有缓冲区

答案:B。epoll 的核心优化就在于此:内核把就绪的 fd 直接串在就绪链表上,epoll_wait 返回时只需要把这个链表上的节点拷贝给用户态,复杂度为 O(k),k 是就绪连接数。select 才需要遍历全部 n 个连接,所以是 O(n)。

Q6. kqueue 相比 epoll 的独有能力是什么?

单选题 · mid

  • A.支持监听定时器和进程信号← 正确答案
  • B.支持水平触发模式读取数据
  • C.复杂度为 O(n) 且无上限
  • D.可运行在 Windows 平台上

答案:A。kqueue 的 filter 体系不仅能监听读写 IO 事件,还能监听信号(EVFILT_SIGNAL)、定时器(EVFILT_TIMER)、用户自定义事件(EVFILT_USER)等,这比 epoll 管的范围广得多。水平触发是两者都有的能力,Windows 平台走的是 IOCP 而不是 kqueue。

Q7. 在边缘触发(ET)模式下读取 socket 数据,正确的做法是?

单选题 · hard

  • A.读到一部分就停止并等待下个事件← 正确答案
  • B.循环读取直到返回 EAGAIN 为止
  • C.使用阻塞模式只读取一次即可
  • D.每次固定读取 1024 字节即可

答案:A。ET 只在缓冲区从空变非空的瞬间通知一次,如果读到一部分就停止,剩余数据不会再触发通知,会造成“事件丢失”。正确做法是配合非阻塞 IO 循环 read,直到返回 EAGAIN 错误码,表示缓冲区已空,才能确认数据读全了。A、C、D 都会导致数据残留。

Q8. 下列哪个标志位能让多进程 epoll 监听同一 fd 时只唤醒其中一个等待者?

单选题 · extreme

  • A.EPOLLET 边缘触发标志
  • B.EPOLLEXCLUSIVE 独占唤醒← 正确答案
  • C.EPOLLWAKEUP 唤醒锁标志
  • D.EPOLLONESHOT 单次触发标志

答案:B。EPOLLEXCLUSIVE 是 Linux 4.5 引入的标志,在多进程/多线程同时 epoll_wait 同一个 fd 时,内核只唤醒一个等待线程,从源头缓解 accept 惊群。EPOLLET 管触发模式,EPOLLONESHOT 是让事件只触发一次后自动注销,与“唤醒一个”无关。

Q9. 下列关于惊群效应的说法,正确的有哪些?

多选题 · mid

  • A.多个进程同时等待同一个事件← 正确答案
  • B.事件到达时多个进程被同时唤醒← 正确答案
  • C.惊群问题至今完全无法避免
  • D.使用 SO_REUSEPORT 可从源头分流← 正确答案

答案:ABD。惊群的定义就是多个进程/线程同时等待同一监听 fd,事件到达时全部被唤醒但只有一人能成功处理。SO_REUSEPORT 让多个 socket 各自 bind 同一端口,内核按负载分发新连接,属于从源头分流,能有效缓解惊群。C 说“完全无法避免”是错误的,EPOLLEXCLUSIVE 等机制已能控制唤醒范围。

Q10. io_uring 相对传统 epoll+read/write 的优势包括哪些?

多选题 · hard

  • A.大幅减少昂贵的系统调用次数← 正确答案
  • B.通过共享环形队列传递请求结果← 正确答案
  • C.支持真正异步的零拷贝读写← 正确答案
  • D.无需共享内存即可完成全部工作

答案:ABC。io_uring 的核心是内核与用户态共享的 SQ/CQ 环形队列,应用批量写入请求后用一次 io_uring_enter 提交,彻底改变了传统模型“一次 IO 至少两次系统调用”的局面,并且支持真正的异步读写。它恰恰依赖共享内存,所以 D 说反了。