LWN: 对 edge-trigger 的误解!
关注了就能看到更多这么棒的文章哦~
The edge-triggered misunderstanding
By Jonathan Corbet
August 5, 2021
DeepL assisted translation
https://lwn.net/Articles/864947/
安卓 12 beta release 将于今年 5 月首次公布。当然,每次发布新版本的时候都宣称 "安卓历史上最大的设计改动"。如果不要求用户重新学习一切,那还配称自己为一个新的安卓版本吗?不过,这一历史性事件并不打算包含一个许多测试者都注意到了的一个改动,这就是一个破坏了大量应用程序的内核 regression 问题。这个问题刚刚被修复,但它是一个很好的例子,说明为什么如此难于阻止 regression 问题的出现,以及当 regression 发生时,内核项目是如何应对的。
早在 2019 年底的时候,David Howells 对 pipe 管道相关代码进行了一些修改来解决一些问题。不幸的是,这项工作引起了内核社区认为最不可接受的那一类 regression:它让内核的编译过程变慢了(甚至可能完全停止)!经过广泛的讨论,终于查出来一个对 GNU make job server 的影响,Linus Torvalds 合入了一个 fix,问题就消失了。不久之后,5.5 版本的内核发布了,内核构建的速度又加快了,人们认为这个问题应该已经解决了。
Not done yet
7 月底,Sandeep Patil 通知到社区,虽然 GNU make 的问题可能已经 fix 了,但这个 fix 产生了一个新问题。他附上了一个 patch 将 Torvalds 的 fix 进行 revert 操作。很明显,这个 patch 不可能被直接合入,因为内核开发者完全不愿意再经历缓慢的 kernel build 过程,但这引发了对真正问题的调查。
2019 年的 pipe 的 rework 工作,以及后来的 fix 都让 Torvalds 经历了意料之外的痛苦,所以他对代码的结构和行为都做了一些改动。具体来说,对 pipe 与 epoll_wait()、poll()和 select() 等系统调用的工作方式都进行了重要修改。如果希望进行的 I/O 操作不可以允许阻塞的话,这些调用就会把进程放到一个等待队列中去。当情况发生变化时(比如可以开始读取或写入数据了),那么相应的等待队列上的进程就会被唤醒,它们也就可以继续进行相应的 I/O 请求了。
2019 年的 fix 改变了完成唤醒的方式。以前,向 pipe 写东西会无条件地唤醒所有在等待的 reader 角色进程,事实上,在一次系统调用中可能会多次唤醒它们。这次 fix 改变了这个行为,变成只在操作开始时 pipe buffer 是为空的情况才会进行 wakeup 唤醒操作。也就是说,如果在写入的目标 pipe 内已经有待读取的数据的话,那么就仅仅只添加新的数据,而不会进行 wakeup 操作。这个改动的逻辑很明确:如果数据已经可以 read 了,那么 polling 类型的系统调用将立即返回,所以只要 pipe 里有可用的数据了,就不应该有任何进程在继续等待了。
On the edge
然而,epoll_wait() 有一种叫做 "边缘触发"(edge triggered, 或 EPOLLET)的模式,它的行为有点不同寻常。如果有可用的数据的话,申请进行 edge-trigged wait 的进程将不会像 epoll_wait() 那样立即返回。相反,它会一直 wait 直到情况再次发生变化。至少,在 2019 年的 patch 之前是这样的行为模式。所以,如果 pipe 驱动程序在数据到达时不再进行 waktup (当已经有数据可用的情况下),在进行 edge-trigged wait 的进程将不会看到 "edge",因此也不会被 wakeup。
我们很有理由怀疑这种问题是否真的会出现。pipe_write() 的上一个版本的注释就表达了这个观点:
/* Always wake up, even if the copy fails. Otherwise
we lock up (O_NONBLOCK-)readers that sleep due to
syscall merging.
FIXME! Is this really true?
*/
事实证明,确实会有这种情况。有一些 Android 库,比如 Realm,哪怕在 epoll_wait() 调用之前 pipe 中已经有数据在等待了,也会要依赖 edge-triggered wakeup。显然这里的目的是希望一直等待到 pipe buffer 全满了,然后一次性地读取到所有的数据。当 5.10 内核跟安卓 12 beta 配合起来时,这些 library 就不再正常了,因此一组应用程序也随之无法正常工作了。此后,Realm 已经解决了这个问题,但正如 Patil 指出的,很多进程 bundle 在一起就意味着 "等所有应用程序都使用了更新过的 library 的话还会需要不少时间"。如果在 kernel 里面进行 fix 的话就可以为所有应用程序修复这个问题。
人们似乎普遍认为,这些 library 实际上是误解了 "edge-triggered" 的含义,并且错误地使用了这种模式。正如 Torvalds 所解释的:
这实际上是对 epoll() 中的 "edge" 的含义的误解。
edge 并不是指 "有人写入了更多的数据"。edge 的意思是 "以前没有数据,现在有数据了"。
而一个 level triggered 事件 也不是 指 "有人写了更多的数据"。它只是表示 "这里有数据"。
请注意,edge 和 level 都没有提到 "更多的数据" 这个信息。其中一个是指从 "没有数据"->"有数据 "的这个变化,而另一个只是表示 "有数据"。
然而,pipe 的 edge-triggered 操作的实现方式并没有做成这个样子。不出所料,在 Hyrum 法则的作用下,应用程序们开始根据系统实际实现出来的行为来实现了,而不是根据原本定义的语义。epoll() man page 与 Torvalds 的描述一致,介绍了这种阻塞行为(这些无法工作的应用程序所面临的情况)。如果是很久以前的话,内核开发者们可能只是说一句 “这些库做错了”。但现在内核不是这样处理这类问题的了,因此,Torvalds 继续说:
但是我们的 regression 是这么定义的:哪怕是参照文档修改的,或者改成了正确的行为,也不是它可以不被称为 regression 的理由。
regression 是指某个用户的应用程序是否可以被观察到发生了破坏。
基于这种对 "regression" 的解释,那么大家就需要 fix 这个问题。事实上,在 7 月底已经完成了一个新的 patch,被合并到 5.14-rc4 中,并被包含在 5.10.56 和 5.13.8 的 stable update 中了。这个补丁并没有完全恢复之前的行为,具体来说,它在每个写操作中只会进行一次 wakeup 动作。不过,似乎确实解决了这个问题。
Problem solved?
5.5 内核是在 2020 年 1 月发布的,当时我们之中很少有人意识到我们后续会面对这个世界会变成什么样,像这个比较严重的内核 regression 只是又一个意外而已。这个 regression 一直存在了一年半的时间,并在去年 12 月的时候进入了 5.10 long-term-stable release。现在才浮出水面,这说明在某些用户场景的测试存在遗漏。令人高兴的是,它在下一个安卓版本最终确定之前就被发现了。
不过,不确定在这一年半的时间里是否有任何应用程序已经开始依赖更新过的语义了。事实上,已经有一份报告(来自英特尔的自动测试系统)显示,在合入了最新的 fix 后,hackbench 这个 benchmark 产生了将近 13% 的性能下降。Torvalds 回应说,他 "不确定 hackbench 到底有多重要",也许这种性能下降 "可能一点都不重要"。即便如此,他还是发布了一个新的 patch,提供了更接近于旧有行为的实现,但也仅当 pipe 是用这些 polling 函数族中的其中一个时才有效。如果事实证明 hackbench 的性能下降确实是很重要的问题,那么至少我们手头会有一个 fix 已经准备好了。
如果最新的 fix 还破坏了其他东西,那么内核开发者可能会面临一个两难选择。可能就无法在不导致应用程序被破坏的情况下继续推进了,这就是为什么需要尽量早抓到这些 regression。希望我们运气足够好,不会有这种两难选择抛给我们,并且这个意料之外的故事能终结在这个地方。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~