一次 poll 调用的完整旅程:从 sys_poll 到等待队列,你该带走的 5 个结论
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
你有没有写过这种代码:打开一个设备节点,然后在 while 里不停 read,读到数据就干活,读不到就再来一次。top 一看不对劲,CPU 打满,同事问你在干嘛,你说"在等数据"。问题不在业务,在于你根本没资格忙等——内核早就给了你 poll_wait 这条路:驱动把进程挂上等待队列,设备有动静时内核主动把你叫醒。读完这篇,你能把 sys_poll 从进内核到被唤醒的每一步讲清楚。
🍜 餐厅叫号,就是 poll_wait 的全部逻辑
把一次 poll 想成去饭馆:你不用守在灶台边看锅,而是去前台取个号,坐下等短信,饭做好了后厨喊你的号。
对应到内核里有三个角色。你是食客,进程发起 sys_poll,内核在它的等待队列上插一张"号牌",然后让它睡觉。驱动是后厨,它手里攥着"锅的状态":有没有数据、缓冲区满没满。poll_table 是那张号牌,上面写着三样东西:哪个文件描述符、关心哪类事件(可读还是可写)、挂到了哪个队列头上。后厨每做一道菜就 broadcast 一次,内核拿着号牌逐个核对,只对号的人才发"起立"短信,这就是 wake_up 的活。下面跟着一次真实的 sys_poll 走一遍全流程。
一次 sys_poll 在内核里走了哪些路
入口在 sys_poll,真正干活的是 do_poll(在 fs/select.c 里)。它先干一件容易被忽略的事:准备一张工作表,poll_initwait 把唤醒回调注册进 poll_table,这个回调的名字叫 __pollwait——记住它,下一节所有 magic 都从它身上过。
然后对列表里的每个文件描述符调 do_poll 内部循环里的 vfs_poll,vfs_poll 在 include/linux/poll.h 中定义,本质就是转发给驱动的 f_op->poll。也就是说,内核通用层从不直接碰设备,它只是替你敲门。
驱动返回一个掩码,比如 EPOLLIN,表示"现在可读"。内核拿这个掩码和你 poll 时要求的 events 做与运算:有交集就立刻返回,你全程没睡过;一个 bit 都没交集,内核才把当前进程标成睡眠,挂进刚才排好号的队列,然后阻塞在定时器或超时上。
这里有个细节值得咂摸:__pollwait 每次被驱动调用一次,就往表里追加一条记录,每条记录独立挂到一个等待队列头上。所以你 poll 十个设备,就有十条号牌、十个队列,任何一个队列被唤醒,内核都会回头检查其他九条号牌,确认"这件事你关不关心"。这条链路走完,你的进程就睡着了。叫醒它的下一站,是驱动侧。
驱动侧的两个动作:先挂号,再验锅
以管道的实现 fs/pipe.c 里的 pipe_poll 为例(它就是内核自己的标准答案)。整个函数只有两步动作,顺序却被源码注释用大字标了出来:先把进程挂进 poll 表,然后才允许读状态。
if (filp->f_mode & FMODE_READ) poll_wait(filp, &pipe->rd_wait, wait); if (filp->f_mode & FMODE_WRITE) poll_wait(filp, &pipe->wr_wait, wait); /* 挂完号之后,才能做下面这个"有竞态"的检查 */ idx.head_tail = READ_ONCE(pipe->head_tail); if (!pipe_empty(idx.head, idx.tail)) mask |= EPOLLIN | EPOLLRDNORM;为什么顺序不能反?假设你先看锅:锅是空的。还没来得及挂号,写端"叮"一声写了一字节。你再挂号——数据永远在那,但没人会再来叫你一次,你睡死过去。反过来先挂号:就算看锅和挂号之间真的漏了状态,驱动那边一有动静就 wake_up,你的号牌会被兜住。pipe.c 里那段注释说得很直白:"if something changes and you got it wrong, the poll table entry will wake you up and fix it"。这是 poll_wait 的第一铁律。
事件到来时的反向唤醒
数据到达时,驱动调 wake_up_interruptible(&pipe->rd_wait) 这类函数,内核遍历队列上所有号牌,每块号牌都会走一遍 pollwake(fs/select.c 中定义):它从号牌里取出当年挂表时记下的 filp 和事件掩码,拿这次醒来的原因和掩码做与运算,对得上才把对应的进程从 TASK_INTERRUPTIBLE 翻成 TASK_RUNNING,再走一遍"现在到底有没有数据"的验锅。对不上号的号牌只被摸一下就放下,进程继续睡。
这就是为什么惊群没那么可怕:同一个队列上挂十个进程,十个号牌,但只有掩码匹配的才被真正唤醒干活。另外,验锅不通过(虚假唤醒)时驱动返回 0,用户空间的 poll 会重新挂进去睡,这个"睡回去"的动作对用户完全透明。
写一个驱动 poll 函数:4 条硬规则
- 先 poll_wait 后读状态,顺序反了就有挂不到事件的窗口,pipe.c 的注释就是现成教材。
- 返回掩码与唤醒时机一致:你什么时候会调 wake_up,就什么时候在 mask 里给对应 bit,两边对不上就是 bug。
- 事件路径必须真的调 wake_up:数据到了只改标志位不喊人,等待者就永久睡眠。
- 善用 wq_has_sleeper 和 wake_up_nr:队列空了别白干重活;想只叫醒一个处理者,用 wake_up_nr 限个数,而不是全喊。
3 个真实翻车现场:现象、根因、解法
现场一:进程睡死,再也没醒。现象是 poll 卡住超时才返回,dmesg 干干净净。根因九成是驱动的接收回调里改了数据缓冲区,却忘了调 wake_up 系列函数——号牌挂上了,后厨没喊号。解法:在驱动里全局搜一遍 wake_up,确认每个"状态翻转点"后面都跟了一次唤醒。
现场二:CPU 又打满了,比忙等还惨。现象是进程频繁醒、验锅为空、再睡,形成自激循环。根因是唤醒端和掩码端不一致:驱动对"写缓冲区变了一点点"也 wake_up 读端,读端被反复白醒。解法:把唤醒条件收紧到"真正产生可读数据",或者按第 4 条限制唤醒数量。
现场三:一个队列上挂了一堆进程,一醒全醒。现象是 N 个进程同时被调度,只有一个真正干活。这是典型的"被误伤"放大。解法:驱动用 wake_up_nr 只叫醒一个,其余留到下轮;内核的 pollwake 掩码比对会帮你挡掉大部分白醒,挡不住的就得驱动自己收敛。
回到那个 top 里的 100%
现在再回头看你最初的场景:把 while-read 换掉,驱动里老老实实 poll_wait 加 wake_up,top 里那条曲线就平了。想继续挖,按这个顺序看:include/linux/poll.h(poll_wait 与 poll_table 的定义)、fs/select.c(__pollwait 与 pollwake 的实现)、fs/pipe.c(一份可以直接抄作业的驱动侧实现)、include/linux/wait.h(等待队列基础设施)。
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考