关注了就能看到更多这么棒的文章哦~
Timer IDs, CRIU, and ABI challenges
By Jonathan Corbet
March 6, 2025
Gemini-1.5-flash translation
https://lwn.net/Articles/1012490/
内核项目通常愿意做出根本性的内部改动,如果这些改变最终能带来一个更好的内核。然而,该项目也会尽力避免破坏暴露给用户空间的接口,即使程序依赖于从未被记录在案的行为。有时,这两个原则会发生冲突,导致内核内部问题的修复变得困难或不可能。这种情况阻碍了内核 POSIX 定时器(可移植操作系统接口定时器)实现中的性能改进已经有一段时间了,但现在看来已经找到了解决方案。
定时器和 CRIU
POSIX 定时器 API 允许进程基于内核提供的任何时钟创建自己的私有间隔定时器。进程调用 timer_create()
来创建这样的定时器:
int timer_create(clockid_t clockid, struct sigevent *sevp, timer_t *id);
id
参数是一个指针,指向内核可以返回一个 ID 的位置,该 ID 用于标识新的定时器;它的类型是 timer_t
,最终映射到一个 int。各种其他的系统调用可以使用该 ID 来启动或停止定时器,查询其当前状态,或完全删除它。timer_create() 的 man 手册指出,每个创建的定时器都有一个在其创建进程中唯一的 ID,但对返回的值没有做出其他承诺。
“在进程中唯一” 的保证是在 2013 年的 3.10 内核版本中引入的;之前,定时器 ID 在系统范围内是唯一的。要理解这个变化,就必须了解 用户空间检查点/恢复(Checkpoint/Restore in Userspace,CRIU) 项目,该项目长期致力于将一组进程的状态保存到持久存储中,然后在未来的某个时间恢复该组进程,可能是在不同的系统上。充分地重建一组进程的状态,使进程本身没有意识到以这种方式被恢复,这是一项具有挑战性的任务;CRIU 的开发者经常努力使所有的部分都能正常工作(并保持这种状态)。
POSIX 定时器是他们遇到麻烦的地方之一。要恢复一个正在使用定时器的进程,CRIU 必须能够使用与检查点时相同的 ID 重新创建定时器,但是系统调用 API 没有提供请求特定定时器 ID 的方法。即使存在这种能力,定时器的单一、系统范围的 ID 空间也是一个无法克服的问题;CRIU 可能会尝试为一个进程重新创建一个定时器,却发现系统中一些其他不相关的进程已经有了一个具有该 ID 的定时器。在这种情况下,恢复将会失败。
这个问题通过 Pavel Emelyanov 的 这个补丁 解决,该补丁实现了一个新的哈希表来存储定时器 ID。该表仍然是全局的,但是其中保存的定时器 ID 将拥有进程的身份(具体来说,是它的 signal_struct
结构的地址)考虑在内,从而将每个进程的定时器 ID 与所有其他进程的定时器 ID 分离开来。至此,恢复进程时 ID 冲突的问题消失了。
另一个问题——缺少请求特定定时器 ID 的方法——仍然存在。为了解决这个问题,CRIU 坚持了它以前使用的方法,该方法基于一些关于内核如何分配这些 ID 的内部知识。这里有一个简单的、per-process (每个进程)的计数器,从零开始,用于定时器 ID;每次创建一个新的定时器时,该计数器都会递增。因此,一系列的 timer_create()
调用将产生一个可预测的 ID 序列,在整数空间中计数。当 CRIU 必须在要恢复的进程中创建一个具有特定 ID 的定时器时,它会利用这些知识,简单地运行一个循环,分配和销毁定时器,直到返回请求的 ID。
如果一个进程在其生命周期中只创建少量的定时器,那么这种线性 ID 搜索不会花费很长时间。但是,检查点通常用于长时间运行的进程,以便在过程中出现问题时保存它们的状态。这种进程如果定期创建和销毁定时器,最终可能会在整数空间中广泛分布 ID。反过来,这意味着在恢复时可能需要很长时间才能找到所需的 ID。
没有桨(Without a paddle)
在 2023 年,Thomas Gleixner 发送了 这个摘要,以回应一个定时器错误报告;他指出,在某些情况下,分配循环 “将运行至少一个小时才能恢复单个定时器”。这不是 CRIU 用户可能希望的快速恢复操作。但当时真正的问题是,内核中按顺序分配定时器 ID 的要求阻碍了对内部全局哈希表的一些必要更改,而这些更改反过来又阻碍了定时器子系统中的其他更改。由于在不破坏 CRIU 的情况下无法更改此行为,Gleixner 总结说,内核 “没有桨还要逆流而上”。
当时,考虑了一些可能的解决方案。将 ID 空间从 0..INT_MAX
缩小到更小的空间可以加快 ID 搜索,但它仍然会是一个 ABI (应用程序二进制接口)的破坏;CRIU 将无法再恢复任何创建了具有更大 ID 的定时器的进程。一个新的系统调用来创建一个具有给定 ID 的定时器是另一种可能性,但是,由于定时器 API 的工作方式(以及它接受的 sigevent
结构),64 位和 32 位版本的系统调用无法兼容。这将需要添加另一个 “compat” 系统调用,这是内核开发者们一直竭力避免的事情。最终,对话在没有找到解决方案的情况下结束。
在 2025 年 2 月中旬,网络开发者 Eric Dumazet 发布了 一个补丁系列,旨在减少内核定时器代码中的锁竞争,理由是 “涉及大量并发 POSIX 定时器的事件”。这项工作引起了 Gleixner 的一些恼火的回应,但毫无疑问存在一个实际问题。因此,Gleixner 开始创建 他自己的补丁系列,结合了 Dumazet 的工作,然后也旨在解决其他问题。该系列的大部分重点是实现一个新的哈希表,该表缺乏当前内核中发现的性能问题;封面信中包含的基准测试结果表明,在这方面取得了一些成功。
值得一提的是,Gleixner 的补丁系列避免了引入 non-merge changeset (无法合并的变更集) 以及对 mainline (主线) 代码的直接修改。这意味着该系列更容易被社区接受,并且可以更顺利地集成到主线内核代码中。
更好的 CRIU 解决方案
但是,Gleixner 随后着手解决 CRIU 问题。他没有创建一个新的系统调用来启用创建具有特定 ID 的定时器,而是得出结论: timer_create()
的 id
参数可用于提供该 ID。所需要的只是一个标志来告诉 timer_create()
使用提供的值而不是生成自己的值……但是 timer_create()
没有 flags
参数。因此,如果 timer_create()
要获得从用户空间读取定时器 ID 的能力,则需要找到其他方法来告知它请求了此行为。
答案是一对新的 prctl()
操作。像这样的调用:
prctl(PR_TIMER_CREATE_RESTORE_IDS, PR_TIMER_CREATE_RESTORE_IDS_ON);
将导致调用进程进入 “定时器恢复模式”,该模式使 timer_create()
从用户空间传递的 id
参数指向的位置读取请求的定时器 ID。在用户空间没有要请求的 ID 的情况下,可以提供特殊值 TIMER_ANY_ID
。另一个带有 PR_TIMER_CREATE_RESTORE_IDS_OFF
的 prctl()
调用将退出恢复模式,从而导致任何后续的 timer_create()
调用像往常一样在内部生成 ID。
此功能专门针对 CRIU 的需求。通常,添加这种进程范围的状态会引发问题;某个遥远的线程可能会在启用恢复模式时进行 timer_create()
调用,但期望旧的行为,因此会感到不愉快。但是,CRIU 可以在重新启动的进程已创建但尚未允许恢复在检查点处恢复运行的特殊点使用此模式。那时,CRIU 完全处于控制之中,可以正确管理状态。
另一个重要的点是, prctl()
调用将在不支持定时器恢复模式的旧内核上失败。当 CRIU 看到该失败时,它可以返回到旧的、蛮力方法来分配定时器。因此,CRIU 开发者将能够利用新的 API,同时保持对旧内核上用户的兼容性。
即使在合并此系列之后仍然存在的一个问题是,在没有新的 prctl()
操作的情况下, timer_create()
的顺序分配行为仍然是内核 ABI 的一部分。定时器开发者从未打算做出这样的承诺,但是只要 CRIU 安装继续依赖它,他们就无法摆脱它。好消息是,更新 CRIU 通常对于更新内核的用户来说是必要的,因为这是获得对较新内核功能支持的唯一方法。因此,也许在不久的将来,可以取消 timer_create()
的顺序分配保证——除非其他依赖于它的用户从幕后出现。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~