Linux 6.19 对 restartable sequences,也就是 RSEQ,进行性能优化时,移除了对 struct rseq 中 cpu_id_start 字段的无条件更新。这个看似合理的内核优化却触发了 TCMalloc 回归:后者长期利用该字段完成一种未被正式接口承诺的“中断检测”。当内核不再执行原先的写入动作,分配器依赖的隐含行为随之消失。
这件事很小,讨论的却是操作系统最棘手的问题之一:当高性能用户态组件依赖内核实现细节时,什么才算稳定 ABI?内核应为未文档化的用法承担多大兼容成本?又怎样在不拖慢所有线程的前提下,为少数极端性能场景提供足够能力?

RSEQ 解决了什么问题
多核程序经常需要执行“只要仍在当前 CPU 上,就可以安全完成”的短操作。例如内存分配器希望访问当前 CPU 的本地缓存,计数器希望更新 per-CPU 数据。传统方案可以通过系统调用查询 CPU,也可以使用锁或原子操作,但这些手段在每秒执行数千万次的小操作上成本明显。
RSEQ 允许线程在用户空间登记一段很短的临界序列。只要执行期间没有被抢占、迁移或收到需要重启的事件,这段代码就能以接近普通指令的成本完成;一旦发生干扰,内核在返回用户态前修改状态并把执行流导向中止路径,用户代码重新尝试。它把“当前 CPU 身份”和“是否被打断”这一小部分协作放在内核与用户空间共享的数据结构里,换来了低延迟的 per-CPU 操作。
struct rseq 中的 CPU 标识字段由内核维护。cpu_id 表示当前处理器,cpu_id_start 在早期设计中用于记录进入用户态时的 CPU。随着接口演进,内核开发者发现无条件写入某些字段会让未使用相应能力的线程也支付开销,因此尝试精简返回用户态路径。
TCMalloc 为什么会依赖它
TCMalloc 的目标是让小对象分配尽量避免全局锁。它把部分空闲对象组织在与 CPU 关联的缓存中,让运行在同一 CPU 的线程快速获取和归还内存。难点在于:线程读取当前 CPU 后,可能在更新缓存之前被调度到另一个 CPU。若无法发现迁移,它就可能修改错误的 per-CPU 状态。
TCMalloc 使用过一种巧妙但脆弱的办法:向本应由内核维护的 cpu_id_start 写入哨兵值,之后观察内核是否把它恢复成有效 CPU 编号,以判断执行是否被打断。这种技巧利用的是实现行为,并不是文档明确赋予用户空间的写入语义。它在旧内核上运行良好,以至于逐渐成为事实依赖;当 Linux 6.19 为减少写入而优化 RSEQ 路径时,隐含契约被打破。
从内核角度看,去掉无条件更新可以减少热点路径中的工作,受益者是所有线程;从 TCMalloc 角度看,一个多年可用的行为突然改变,会造成真实应用回归。双方都“有道理”,这正是 ABI 争议最难处理的地方。
RSEQ operations 提案
Olivier Dion 提出的 RSEQ operations 试图把这种需求从旁路技巧变成显式接口。用户空间可通过 prctl() 的注册与注销操作,声明内核在返回用户态时需要执行哪些简单动作,例如重置某个值,或按当前 CPU、虚拟 CPU 标识对内存位置进行步进式写入。
设计的重点是按需付费。没有注册操作的线程不承担额外成本;只有确实需要这类通知的高性能运行库,才让内核在返回用户态路径执行对应动作。操作节点保存在用户空间内存中,并设定总数上限,以避免无限链表和不可控开销。
这个方案也引出安全和复杂度问题。内核需要谨慎读取用户空间提供的结构,处理页错误、竞态和恶意输入,并确保操作集合足够有限,不能演化为一个难以验证的小型解释器。维护者对“功能会不会不断扩张”的担忧并非保守:一旦用户空间开始依赖某套操作,内核就要长期维护其语义。
提案没有直接采用 BPF,一个原因是 BPF 程序通常涉及能力权限和更复杂的验证、生命周期管理。TCMalloc 这样的通用运行库不能假定应用具有 CAP_BPF。但如果 RSEQ operations 逐步增加条件、算术和跳转,它又可能走向类似字节码系统的复杂度。因此,接口边界应保持窄而稳定,只覆盖返回用户态时极少数可证明安全的状态更新。
为什么“返回用户态”如此敏感
系统调用、异常和调度结束后,内核都可能沿公共路径返回用户空间。这条路径执行频率极高,也已经承担信号处理、审计、任务工作和 RSEQ 通知等职责。每增加一个无条件分支或内存访问,都可能在微基准中只增加极小延迟,却在整台机器的总运行时间中持续累积。
更麻烦的是缓存影响。写入线程共享结构会使对应缓存行变脏,可能与用户态读写产生缓存一致性流量。多插槽服务器上,缓存行跨 NUMA 节点移动的代价更高。因此,内核开发者关心的不只是几条指令,还关心写入是否破坏缓存局部性。RSEQ operations 的“只有注册者付费”思路,正是试图把这类成本限制在受益线程上。
接口实现还要面对用户地址不可靠的问题。用户空间可以在注册后解除映射、改变权限或让另一个线程修改链表。内核不能像访问自身对象那样相信指针,需要使用安全的用户内存访问方法,并定义故障时如何处理。如果每次返回用户态都可能触发复杂页错误,性能收益就会被抵消;如果直接忽略错误,运行库又可能误判状态。操作数量上限、固定格式和禁止任意控制流,都是为了让最坏情况可以估计。
如何验证这类改动
RSEQ 相关测试至少需要覆盖线程迁移、抢占、信号、CPU 热插拔和虚拟 CPU 编号变化。单线程稳定运行最容易通过,却无法触发真正风险。测试程序应刻意把线程绑定和解除绑定,在临界序列中制造信号与调度压力,并检查用户态状态是否出现不可能组合。
分配器层面则要同时测正确性和性能。正确性测试包括高并发分配释放、线程频繁创建销毁、不同对象尺寸、内存压力和故障注入;性能测试除了吞吐,还要看 P99、P999 延迟、上下文切换和每次分配的 CPU 周期。若新接口只在低负载微基准上更快,却在频繁迁移时产生长尾,就不能称为成功。
部署时还应具备特性探测。运行库不能只根据内核版本号猜测接口,因为发行版可能回移补丁。更稳妥的方式是尝试注册能力,根据明确返回值启用新路径,否则退回已知安全实现。这样能够让同一二进制跨多个发行版运行,也为内核接口继续调整留下空间。
性能优化中的隐形税
这次回归提醒我们,性能从来不是单个基准数字。Linux 返回用户态是一条极热路径,多一次无条件内存写入在单次测量中微不足道,乘以所有进程、所有调度事件后就可能形成可观成本。内核开发者希望让不用 RSEQ 特性的线程零负担,是合理目标。
另一方面,内存分配器对纳秒级开销高度敏感。若为了避免迁移竞态而改用系统调用、重锁或更昂贵的原子同步,大规模服务的吞吐和尾延迟都可能受影响。平台需要的不是简单选择“兼容”或“性能”,而是把成本精确归属给功能使用者。
这种按需机制还有一个工程优势:它让依赖变得可观察。过去的哨兵技巧不容易被内核维护者发现,测试覆盖也不明确;显式注册后,内核可以统计使用情况、编写自测,并在接口演进时识别真实消费者。公开契约比聪明的隐式技巧更有利于长期维护。
给系统软件开发者的教训
第一,能运行多年不代表实现细节已经成为正式 ABI。高性能库若必须利用未文档化行为,应尽早与上游讨论,将需求转化为明确接口,并建立跨版本测试。
第二,内核优化需要用真实用户态生态验证。微基准显示某个字段写入可以删除,并不意味着没有应用依赖它。针对 glibc、TCMalloc、jemalloc、语言运行时和数据库的回归测试,应成为关键接口变更的一部分。
第三,兼容路径也有成本。为了一个特殊用户保留所有旧行为,会让每个线程持续付费,并增加内核复杂度。理想方案是能力协商、显式注册和按需执行,使默认路径保持精简。
第四,RFC 阶段的接口不应被当作既定事实。RSEQ operations 仍可能调整,生产系统更应关注最终合入版本、glibc 协作方式和 TCMalloc 的迁移计划,而不是提前绑定补丁原型。
一个字段的更新策略能够影响大型数据中心使用的内存分配器,说明现代软件栈的性能建立在非常细的跨层契约上。RSEQ 的价值在于用内核协作换取用户态速度,而这次争论的意义,是推动这种协作从隐含假设走向可维护的正式接口。
参考资料