想象你开了一家生意火爆的餐厅。开业第一天,你请了八个服务员,每人只盯一张桌子,顾客一举手,服务员立刻小跑过去——这叫“一对一专属服务”。可你很快发现:大部分时候服务员都在闲站,因为顾客点完菜后要等很久才会再需要服务。一个服务员一天真正干活的时长不到两成,人力全浪费了。 你开始琢磨,怎么让一个服务员同时照看很多桌?第一版方案:服务员每隔几分钟绕场一圈,挨桌问“您需要什么吗”,不管有没有需求都问一遍——这叫“巡场询问”,客人一多,光问一圈就要半小时。第二版方案:你在每张桌上装一个呼叫铃,顾客按铃,前台总机把桌号记下来,空闲服务员照单服务,没按铃的桌子根本不用跑。这套“按铃呼叫”系统,就是网络世界里 IO 模型不断进化的缩影。 而 epoll 与 kqueue,正是 Linux 和 macOS 上两套最出色的“餐厅呼叫系统”。Nginx、Redis、Netty 都在用它们扛住百万并发。这篇文章,我们就从“挨桌询问”讲到“按铃呼叫”,再讲到“自助下单”,把高性能并发的底座彻底看明白。
对 AI 从业者:大模型推理、流式输出、高并发 API 网关背后全靠这套 IO 底座。搞懂 epoll/kqueue,你才能真正看懂“高并发 AI 服务”的性能天花板在哪里,而不是只会调参。
目录
可视化演示
对比展示广度优先搜索与深度优先搜索的节点访问顺序,动态呈现队列与栈的驱动力差异及遍历路径的构建过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
广度优先搜索 BFS 队列驱动逐层扩展访问节点 先访问邻居再访问下一层 Q→A→B,C→D,E,F,G 层次遍历顺序
IO演进之路:从一对一陪聊到总机接线
开餐厅的第一天,你最朴素的想法是:一个服务员盯一桌,顾客一招手就过去,全程只服务这一桌。这在网络世界里就是阻塞 IO:线程发起一次 read 之后,就一直挂在那里等数据返回,在这期间它无法服务任何其他连接。这种模式写代码最简单,但代价极大——一万人同时来吃饭,你就得雇一万个服务员(线程),服务器内存和上下文切换开销直接爆炸。
后来你改进了一下:服务员不再死盯一桌,而是每隔几秒就去问一次“您需要什么吗”,没有需求就转身去下一桌。这就是非阻塞 IO:线程每次 read 立即返回,有数据就处理,没数据就去做别的事,过一会儿再回来问。听起来不错,可仔细一想,服务员的大量时间还是耗在“来回跑着问”上,CPU 也被空轮询白白烧掉了——这其实就是忙等待。
真正的转折点,是你给每张桌子装上了呼叫铃。顾客需要服务时按一下铃,前台总机把桌号记下来,服务员不用巡场,只盯着名单等派单。这套模式在 IO 世界里叫多路复用:一个线程可以同时监视成千上万个连接,只处理真正就绪的那些。在这之后还有两个变体——信号驱动 IO 像顾客按完铃先准备菜,准备好了再正式通知总机;异步 IO 则像顾客直接写好小票投进厨房,全程不用服务员参与。把这五阶段的总时间写成一个公式,能直观看出差距:
从一对一陪聊到总机接线,每一次升级的本质都是“减少服务员的无效等待”。而 epoll 正是这套总机系统最成熟的形态——接下来我们看看它之前的两位前任有多笨拙。
- T_total完成全部 n 个请求所花费的总时间,阻塞模型下的核心指标。
- 求和符号从 i=1 一直加到 i=n,把 n 个请求各自的耗时全部累加起来,体现“串行排队”的本质。
- t_i 与下标 i第 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。
- T_select一次 select/poll 调用从开始到返回所花的时间成本。
- O(n) 记号表示耗时随 n 线性增长——n 翻一倍,巡场时间也翻一倍,这是算法复杂度的“线性级”。
- n 的含义当前监视的文件描述符总数,也就是餐厅里的总桌数,跟实际有几桌按铃没有任何关系。
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。
- T_select = O(n)select 的代价由连接总量 n 决定,巡场一圈,付出全量成本。
- T_epoll = O(k)epoll 的代价由就绪数量 k 决定,名单上有多少桌就服务多少桌。
- k 远小于 n在真实的高并发场景下,几万连接里同时活跃的往往只有几十个,k 比 n 小几个数量级。
LT与ET触发:两种响铃方式
同样是按铃呼叫,铃铛可以设置两种响法。LT(水平触发)是“持续响铃模式”:只要某张桌还有未完成的服务需求(比如菜还没上齐,即内核缓冲区里还有数据可读),铃铛就一直响。服务员任何时候看就绪名单,这张桌都在上面,可以慢慢处理,处理完之前它不会消失。
ET(边缘触发)则是“响一下模式”:铃铛只在顾客按下按钮的那一瞬间响一次,之后哪怕菜没上齐,铃也不会再响。对服务员来说,这意味着听到铃声后必须立刻冲过去把所有事情全部办完——在网络世界里,就是必须用非阻塞 IO,并且循环 read 直到返回 EAGAIN 错误码,才能确保缓冲区里的数据被全部读走。如果读了一部分就收手,剩余数据就永远等不到下一次铃声了——这叫事件丢失。
这两种模式各有拥趸:Redis 选择 LT,因为它追求代码简单可靠;Nginx 选择 ET,因为它追求极致性能,愿意承担更严格的编码纪律。用条件表达式的语言描述两种触发时机:
LT 像传统餐厅的响铃服务,稳妥但会重复打扰;ET 像极限翻台率餐厅的“一声令下,使命必达”,高效但容错率极低。选哪种,取决于你的餐厅是求稳还是求快。
- 上报事件(t)epoll 是否向应用程序报告这个 fd 就绪的函数。
- buffer(t) > 0在时刻 t,内核接收缓冲区中还有数据没被读走。
- t0 上标负号表示事件发生的“前一瞬间”,是边缘触发判断的关键——只看状态是否发生了从无到有的跳变。
- 逻辑与符号要求“之前为空”和“现在非空”两个条件同时成立,才触发一次上报。
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 在跑。
- ident事件关联的对象,通常就是文件描述符(fd),相当于桌号。
- filter事件过滤器类型,决定监听什么——读就绪、写就绪、定时器还是信号。
- flags本次调用的动作标志,比如添加(EV_ADD)、删除(EV_DELETE)还是使能(EV_ENABLE)。
- data 与 udatadata 是内核返回的附加数据(如可读字节数),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/n在均匀分布下,某个特定 worker 被选中的概率——每个 worker 觉得自己“可能被点到”。
- 1 - (1 - 1/n)^n用 1 减去“只有至多一个 worker 被唤醒”的互补概率,得到“至少两个”的概率。
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 模型的下一个十年。
- N_epoll = O(k)传统 epoll 模型下,每完成一次 IO 至少一次 epoll_wait 加一次 read/write,k 次 IO 就要付出线性增长的系统调用代价。
- N_io_uring = O(1)io_uring 模式将 k 个请求批量提交,一次系统调用处理所有事。
- k 的语义这里指 IO 请求的总次数,在批量提交下,k 可以轻松达到成百上千。
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 演示
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)
sel = selectors.DefaultSelector()根据操作系统自动选择 epoll(Linux)或 kqueue(macOS),这就是跨平台适配层的雏形。conn.setblocking(False)把连接设为非阻塞,这是事件驱动的前提:没有数据时 read 立即返回错误而不是卡住线程。sel.register(conn, selectors.EVENT_READ, on_read)注册一个“读就绪”事件并绑定回调,等价于 epoll_ctl 往红黑树里挂节点。events = sel.select()阻塞等待内核返回就绪事件列表,等价于 epoll_wait。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 在内核中使用哪种数据结构管理已注册的文件描述符?
- A.定长数组维护全部连接
- B.红黑树管理注册表结构← 正确答案
- C.哈希表进行快速定位
- D.单向链表进行顺序查找
答案:B。epoll 用红黑树(rbtree)组织所有已注册的 fd(epitem),支持 O(log n) 的增删查;就绪的 fd 则通过指针挂进就绪链表。数组是 select 的位图实现,哈希表和单向链表都不是 epoll 的核心数据结构。
Q2. Redis 的事件循环基于下列哪种模型?
- A.多线程阻塞 IO 模型
- B.单线程加 IO 多路复用← 正确答案
- C.纯异步 io_uring 模型
- D.无阻塞循环轮询模型
答案:B。Redis 是典型的单线程 Reactor:一个主线程跑事件循环,用 epoll 同时监视成千上万个客户端连接,就绪事件逐个处理。它没有用多线程阻塞模型,也没有全面转向 io_uring,第 4 项“无阻塞循环轮询”描述的是忙等待,不是 Redis 的做法。
Q3. 下列关于阻塞 IO 的说法,正确的是?
- A.线程在等待时不断轮询
- B.线程在等待时被挂起← 正确答案
- C.线程在等待时继续运行
- D.线程在等待时直接返回
答案:B。阻塞 IO 调用 read/write 后,线程进入睡眠(挂起)状态,直到内核准备好数据才被唤醒,期间不消耗 CPU 但也不能服务其他连接。轮询是非阻塞 IO 的做法,“继续运行”或“直接返回”对应的是非阻塞 IO 的忙等待过程。
Q4. 关于水平触发(LT)与边缘触发(ET),下列说法正确的是?
- A.LT 只在缓冲区从空变非空时通知一次
- B.ET 在缓冲区非空时持续通知直到读完
- C.LT 在缓冲区非空时持续通知直到读完← 正确答案
- D.ET 与 LT 的通知机制和时机完全相同
答案:C。LT(水平触发)只要缓冲区里还有数据,每次 epoll_wait 都会上报,直到数据被全部读走;ET(边缘触发)只在缓冲区从空变为非空的那个瞬间通知一次,之后不再重复。A 说的是 ET,B 说的是 LT,D 明显错误。
Q5. epoll_wait 返回后,获取就绪事件的效率是怎样的?
- A.仍需要遍历全部已注册连接
- B.只需要遍历就绪链表即可← 正确答案
- C.需要额外两次系统调用来确认
- D.需要在用户态轮询所有缓冲区
答案:B。epoll 的核心优化就在于此:内核把就绪的 fd 直接串在就绪链表上,epoll_wait 返回时只需要把这个链表上的节点拷贝给用户态,复杂度为 O(k),k 是就绪连接数。select 才需要遍历全部 n 个连接,所以是 O(n)。
Q6. kqueue 相比 epoll 的独有能力是什么?
- A.支持监听定时器和进程信号← 正确答案
- B.支持水平触发模式读取数据
- C.复杂度为 O(n) 且无上限
- D.可运行在 Windows 平台上
答案:A。kqueue 的 filter 体系不仅能监听读写 IO 事件,还能监听信号(EVFILT_SIGNAL)、定时器(EVFILT_TIMER)、用户自定义事件(EVFILT_USER)等,这比 epoll 管的范围广得多。水平触发是两者都有的能力,Windows 平台走的是 IOCP 而不是 kqueue。
Q7. 在边缘触发(ET)模式下读取 socket 数据,正确的做法是?
- A.读到一部分就停止并等待下个事件← 正确答案
- B.循环读取直到返回 EAGAIN 为止
- C.使用阻塞模式只读取一次即可
- D.每次固定读取 1024 字节即可
答案:A。ET 只在缓冲区从空变非空的瞬间通知一次,如果读到一部分就停止,剩余数据不会再触发通知,会造成“事件丢失”。正确做法是配合非阻塞 IO 循环 read,直到返回 EAGAIN 错误码,表示缓冲区已空,才能确认数据读全了。A、C、D 都会导致数据残留。
Q8. 下列哪个标志位能让多进程 epoll 监听同一 fd 时只唤醒其中一个等待者?
- A.EPOLLET 边缘触发标志
- B.EPOLLEXCLUSIVE 独占唤醒← 正确答案
- C.EPOLLWAKEUP 唤醒锁标志
- D.EPOLLONESHOT 单次触发标志
答案:B。EPOLLEXCLUSIVE 是 Linux 4.5 引入的标志,在多进程/多线程同时 epoll_wait 同一个 fd 时,内核只唤醒一个等待线程,从源头缓解 accept 惊群。EPOLLET 管触发模式,EPOLLONESHOT 是让事件只触发一次后自动注销,与“唤醒一个”无关。
Q9. 下列关于惊群效应的说法,正确的有哪些?
- A.多个进程同时等待同一个事件← 正确答案
- B.事件到达时多个进程被同时唤醒← 正确答案
- C.惊群问题至今完全无法避免
- D.使用 SO_REUSEPORT 可从源头分流← 正确答案
答案:ABD。惊群的定义就是多个进程/线程同时等待同一监听 fd,事件到达时全部被唤醒但只有一人能成功处理。SO_REUSEPORT 让多个 socket 各自 bind 同一端口,内核按负载分发新连接,属于从源头分流,能有效缓解惊群。C 说“完全无法避免”是错误的,EPOLLEXCLUSIVE 等机制已能控制唤醒范围。
Q10. io_uring 相对传统 epoll+read/write 的优势包括哪些?
- A.大幅减少昂贵的系统调用次数← 正确答案
- B.通过共享环形队列传递请求结果← 正确答案
- C.支持真正异步的零拷贝读写← 正确答案
- D.无需共享内存即可完成全部工作
答案:ABC。io_uring 的核心是内核与用户态共享的 SQ/CQ 环形队列,应用批量写入请求后用一次 io_uring_enter 提交,彻底改变了传统模型“一次 IO 至少两次系统调用”的局面,并且支持真正的异步读写。它恰恰依赖共享内存,所以 D 说反了。