可重启序列:多核微处理器性能提升利器,最高让性能提升百万倍!
可重启序列:系统编程前沿秘密
2026年5月31日,Linux 4.18+(约2018年)引入的可重启序列(restartable sequences)概念,简称rseq,是目前系统编程前沿最不为人知的秘密。借助它,无需使用锁或原子操作,就能创建线程安全的数据结构,且这些数据结构在多核微处理器上也能实现良好的扩展性。目前,在Linux上只能通过手写汇编代码来使用rseq。不过未来,所有操作系统可能都会更新以支持`rseq()`,所有系统编程语言也会重新设计,以便能够表达可重启序列,所有数据结构库也都会重写,以使用可重启序列。
rseq应用效果显著
到目前为止,已知使用rseq的软件只有tcmalloc、jemalloc、glibc和cosmopolitan。随着128核甚至192核的微处理器价格逐渐降低,这种情况注定会改变。例如,在价值160美元的4核树莓派5上,rseq让`malloc()`实现速度提高了3倍;在价值4834美元、搭载Ampere 128核3GHz Altra CPU的System76 Thelio Astra上,rseq让cosmopolitan的`malloc()`速度提高了34倍;在价值17628.55美元、拥有96核的AMD Threadripper Pro 7995WX上,rseq让`malloc()`速度提高了43倍。
如果系统程序员没有像上述配置的工作站,就会像恐龙一样被时代淘汰,错失10倍性能优化这样的唾手可得的成果。比如,若没斥资购买96核CPU,就无法实现矩阵乘法的加速。虽然购买CPU让经济紧张,但一切都是值得的,工作获得了媒体报道,在AI社区中声名远扬,项目被32%的组织采用,甚至还获得了谷歌的工作邀请。
可重启序列解决的问题
每当Cosmopolitan C运行时在Linux系统上创建线程时,会发出一个`rseq()`系统调用,为内核提供32字节的TLS内存。线程生命周期内,每当线程被重新调度,内核都会更新TLS内存中的CPU编号,这对改进`sched_getcpu()`实现非常有帮助,现在只需1纳秒的宽松`mov`指令就能获取CPU编号,而之前则需要等待1微秒的`getcpu()`系统调用。
rseq TLS内存中有额外字段,允许线程将信息返回给内核。通常,`rseq_cs`字段为`NULL`,但可用指针更新它,指向程序中的一段汇编指令序列。当内核抢占线程并试图将其移动到不同CPU时,会检查程序计数器是否位于指定区间内,若是,内核将强制线程跳转到指定的中止处理程序,该处理程序可做一些事情,比如跳回到函数开头重试操作。
假设用类似全局解释器锁(GIL)的东西保护数据结构,在拥有数十个核心的系统上,性能会变得很慢,因为任何时候只有一个线程可以持有锁。若用原子操作创建无锁列表,处理出栈操作时需要处理[ABA问题](https://en.wikipedia.org/wiki/ABA_problem),但这种方法可能同样慢,甚至更慢,因为多个核心共享同一64字节的内存区域,会导致CPU内部基本上使用互斥锁,且CPU的内部互斥锁可能不如用户空间实现的好。
一种更明智的方法是对数据结构进行分片,让每个CPU都有自己的区域。但这样做仍然需要互斥锁,因为操作系统可能会在加载CPU编号和进行任何数据修改之间抢占并重新定位线程。不过,为每个CPU使用单独的互斥锁,能确保只有在遇到极端情况时才会发生竞争。使用像[nsync](../mutex/)这样优秀的互斥锁库,有竞争的锁操作至少需要200纳秒,而无竞争的锁/解锁操作只需要大约15纳秒。还使用`alignas(64)`确保每个CPU的指针位于不同的缓存行,从而将硬件内部的竞争概率降至极低。
然而,如果只是进行入栈和出栈操作,与仅需约1纳秒的线程本地链表入栈或出栈操作相比,这15纳秒的开销仍然很大。所以想去掉互斥锁,唯一的障碍是一种很少出现的极端情况,即操作系统在修改链表的一小段汇编指令序列期间中断了线程。
Linux现在提供`rseq()`,它是一个更明智的解决方案。借助可重启序列,实际上可以去掉互斥锁和原子操作,同时操作系统仍能完全抽象调度过程。其工作原理是,当程序进入不想被中断的关键代码段时,通知内核。这个关键代码段可能最多只有10条汇编指令。第一条汇编操作码应该是一个移动指令,用于设置`rseq_cs`字段。最后一条指令需要对全局数据结构进行修改。可以将其看作一个非常小的数据库事务。它之所以快,是因为与内核的双向通信是通过共享内存实现的。
示例一:构建最快的点击计数器
下面介绍可重启序列,构建一个简单的程序,用于对一个数字进行递增操作。假设博客每秒有数十亿的访问量,由多线程Web服务器托管,需要跟踪访问量。有五种构建点击计数器的方法,使用[cosmocc](../cosmo3/)来编译示例代码。
通过对比不同实现方式的性能数据,可发现:rseq与使用glibc互斥锁来保护递增操作相比,从CPU时间消耗来看,实际上可以让性能提高一百万倍。在这五种方法中,只有三种值得考虑:
一是分片,在需要兼容所有操作系统时,分片是最佳选择。使用`cosmo_shard()`函数指针来实现,在现代Linux上,它会使用`__get_rseq()->cpu_id`,如果不可用,则会回退到其他方法。
二是亲和性,亲和性方法速度最快,但需要对所有线程进行微观管理,对于库作者来说不可行,对于应用程序作者来说可能也不是个好主意。不过,在某些特定情况下,它可能是合适的。
三是可重启序列,可重启序列做出了更优的权衡。目前它只在现代Linux上可用,所以如果正在构建一个库或开源项目,还需要支持其他策略。编写可重启序列的代码难度较高,目前大语言模型(LLM)还不够智能,无法帮助构建可重启序列。但未来编程语言可能会发生变化,让我们能够优雅地表达可重启序列。
从ARM工作站的情况来看,Ampere的ARM Altra CPU的原子操作速度非常快,Cosmopolitan对POSIX互斥锁的实现非常复杂,其他数字上的差异大多与价格差异相称,使用可重启序列让3GHz CPU的性能提升到了33GHz,使用互斥锁让3GHz CPU的性能降至219MHz。
示例二:链表的入栈和出栈操作
假设想全局跟踪对象实例,使用rseq,相对容易实现分片链表的`push()`和`pop()`操作。以下是一个可以用[cosmocc](../cosmo3/)编译的示例代码,去掉了锁和原子操作。使用`alignas(64)`确保每个CPU的内存位于不同的缓存行,这意味着硬件内部不会发生同步冲突。
对这段汇编代码进行分析,使用[理查德·斯托曼(Richard Stallman)的Math 55汇编表示法],GNU汇编器与C/C++约束系统相结合,是非常强大的工具。代码中通过`.pushsection`和`.popsection`将内容转移到可执行文件的不同区域,布局了`static const struct rseq_cs`的内容,描述了可重启序列。
可重启序列中的前两条操作码修改与内核共享的一段TLS内存,当内核决定抢占线程时,会检查`rseq_cs`字段,并读取只读数据结构,以确定程序计数器是否当前位于`300`标签内,若是,内核会将程序计数器更改为指定的`abort_ip`。
接下来加载`__get_rseq()->cpu_id_start`字段,左移6位,重新计算内存索引。最后执行实际的入栈操作,直到最后一条指令才实际修改全局内存,整个序列就像一个事务,最后一条指令是提交更改的指令。
中止处理程序很简单,只是跳回到可重启序列的开头。这段代码唯一令人惊讶的是,内核要求它以一个任意的32位字作为前缀,这个字之前已经传递给`rseq()`系统调用。还将中止处理程序代码转移到二进制文件的`.text.unlikely`段。
在Cosmopolitan Libc的`malloc()`实现中使用了该技术,当请求一个小的内存分配(少于512字节)时,会尝试从全局分片列表中弹出一块内存。如果列表中没有可用的内存,会向`mmap()`请求一个新的内存页,将其分成小块,全部入栈,然后再次尝试分配操作。但缺点是,很难将内存释放回系统。
最后编写了测试入栈和出栈函数的代码,创建多个线程进行测试,并在程序结束时清理内存。那么,可重启序列在未来的系统编程中还会有哪些更出色的表现呢?
