关注了就能看到更多这么棒的文章哦~
Restartable sequences in glibc
By Jonathan Corbet
January 31, 2022
DeepL assisted translation
https://lwn.net/Articles/883104/
"Restartable sequence" 是位于用户空间的一小段代码,用来访问 per-CPU 的数据结构,从而不需要引入锁的开销。尽管从 4.18 版本开始就被 Linux 内核所支持,但是这个功能还是比较模糊。此外,GNU C 库(glibc)中一直没有支持该功能。不过,即将发布的 glibc 2.35 版本就会加上支持,所以我们有必要看看这个功能的用户空间 API 是什么样。
内核里面经常会使用 per-CPU 的数据结构来避免使用锁。如果内核在操作这些数据结构时注意了关闭 preemption (抢占),那么这种技巧就很有用了。只要内核中运行的 task 对这个数据有独占访问权(exclusive access),那就可以安全地修改这个数据。如果能在用户空间使用类似的技术就更好了,但是用户空间的代码无法关闭 preemption。所以必须使用其他的方法,也就是依赖于检测 preemption 而不是阻止它。
A restartable-sequences refresher
这个方案就是 restartable sequence (可重启序列),最早是由 Paul Turner 在 2015 年所提出的,后来由 Mathieu Desnoyers 继续完善,并在 2018 年合并的。restartable sequence 依靠几个简单的规则来实现安全、无锁(lock-free)的临界区(critical section)。第一条规则,临界区代码在最后一条指令之前,不能对其他线程可见的、受保护的数据结构进行任何修改。最后一条指令通常会是一个指针赋值,让数据变成一个新的状态。另一条规则是,这个临界区可以在最后一条指令之前的任何时候被中断(interrupted)。当这种情况发生时,代码必须能够 recover 并从头开始这一系列操作。
使用 restartable sequence 功能有点棘手,因为用户空间必须能够在运行这部分代码的时候告诉内核。不过如果通过系统调用来通知的话就会破坏整个过程,此时该线程可能也已经抓住了一个锁了。所以,实际实现中 restartable sequence 是由用户空间和内核之间共享的特殊内存区域来实现的。具体来说,用户空间设置了一个特殊的结构,即 rseq 结构,并使用 rseq() 系统调用来告知内核这个结构的存在。这个 structure 有点复杂,但是核心是一个叫做 rseq_cs 的字段,这是一个指向同样名为 rseq_cs 的 struct 的指针,包含了临界区的描述信息:
struct rseq_cs {
__u32 version;
__u32 flags;
__u64 start_ip;
__u64 post_commit_offset;
__u64 abort_ip;
};
在配置临界区的时候,用户空间线程就会填写一个 rseq_cs 结构,将 start_ip 设置为这个区域的代码的第一条指令的地址。post_commit_offset 是临界区代码的长度,单位是字节数;跟 start_ip 加起来就是该临界区结束后的第一条指令。abort_ip 放置的指令地址,是如果这一段临界区代码在完成前被打断了(例如发生了抢占或 CPU 迁移)则跳转到该地址。version 应该为 0,而 flags 字段可以用来调整 restart 的行为。在 man page 源代码中可以找到一些相关信息。
实际运行临界区代码的时候,就是将 rseq_cs 结构的地址放到在内核中已经注册了的 rseq 结构中。这应该是在进入临界区之前就要先完成的。每当内核对这个线程进行抢占的时候,内核会检查 instruction pointer (指令指针),看当时是否正在执行临界区代码。是的话,就需要在后续此线程恢复执行时让它跳到 abort_ip 地址继续执行,从而可以恢复到之前状态并重新进行尝试。
restartable sequence ABI 存在的一个潜在问题是,任何一个线程都只能向内核注册唯一一个 rseq 结构。哪怕是检查一个 structure 内容的工作也会给调度器中最关键部分的代码又带来一些额外的开销,肯定无法接受要去检查一个这种结构的 list。因此这个限制本身是很合理的,但是在一个线程中可能有不止一个需要用到 restartable sequence 的位置的情况下,这确实变成了一个麻烦。而且其中一些用到 restartable sequence 的地方被深藏在一些 library 之中,用户可能都看不到这些,而是在他们调用的函数栈往下要找好几层的位置上。为了使 restartable sequence 能成为一种可靠的机制,必须有一种方法来防止多处用到的地方互相影响。
The GNU C Library's approach
如果 glibc 要将 restartable sequence 暴露给 glibc 用户,那么就必须能给出一个合理的方案来解决这种共享使用的问题。Florian Weimer 提出的实现方法是把 glibc 来插手管理所有用到这个机制的地方。因此,rseq 结构与 rseq() 系统调用的注册工作都是由 glibc 本身在初始化期间完成的,所以当用户代码运行时,这些配置已经做好了。如果某个应用程序想要自己进行注册(也就是根本不使用 glibc 中的相关支持),那么可以使用 glib.pthread.rseq 这个配置项来关闭 glibc 的自动注册机制。
通过 glibc 来使用 restartable sequence 的应用程序中,需要 include <sys/rseq.h> 这个头文件,其中定义了 rseq 和 rseq_cs 结构以及一些重要的变量,第一个需要关注的变量就是 __rseq_size,用来指明 C 库中注册的 rseq 结构的大小,如果由于某种原因(例如内核中不支持或禁用了这个功能)没有注册的话,这个值就是 0。
找到 glibc 注册的 rseq 结构并不像人们想象的那样简单。它存储在 C 库所维护的线程控制块(TCB, thread control block)中,具体来说,它位于在线程指针加上 __rseq_offset 偏移的位置。可是获取线程指针的方式在每个架构中是有差异的。GCC 为一些架构提供了 __builtin_thread_pointer(),但不是所有架构都有。碰巧 x86 就没有,这个体系架构中线程指针被存储在了 FS 寄存器中,应用程序必须自己从中取得地址。
glibc 注册的 rseq 结构在一个线程中是所有用到的地方需要共享使用的,但每个用到的地方都应该自己创建自己的 rseq_cs 结构来描述自己这个临界区。在进入临界区之前,线程应该将其 rseq_cs 结构的地址存储到全局的 rseq 结构中的 rseq_cs 字段中,退出时应该将该字段重置为 NULL。这种方案就意味着临界区不能嵌套,但这些临界区本来就应该是很短小的区域,而且也不应该调用其他代码,所以这应该不是问题。
位于 abort_ip 位置的代码必须开头是一个特定的 RSEQ_SIG 标记(sentinel),这个标记是以一种每个架构特定方式来定义的。注意,如果 abort 代码被调用的话,rseq_cs 字段就会被内核清零,下次重新进入关键部分之前必须重新配置一下。
还有一个 __rseq_flags 变量,其中包含在向内核注册时所使用的 flag。根据 Weimer 的文档补丁,这个变量目前总是被设置为零。
这个结构存在之后,使用 glibc 的应用程序现在就可以合作来使用 restartable sequence 了。不幸的是,目前还没有什么有实际价值的代码来作为使用这个新 API 的例子。目前来说这一切都是全新的东西。
读者现在应该已经能想到了,实际上对于临界区的编程几乎必定需要借助于汇编语言了。这显然不是一个平常可以随便就用或频繁使用的功能,但它显然可以在某些具有高扩展性(high scalability)要求的系统上提供显著的性能提升。GNU C library 中对此功能的支持将使 restartable sequence 更容易使用,但很可能它的命运是作为少数开发者使用的一个小众功能。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~