可重启序列:多核微处理器性能提升利器,最高让性能提升百万倍!
可重启序列系统编程前沿秘密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()请求一个新的内存页将其分成小块全部入栈然后再次尝试分配操作。但缺点是很难将内存释放回系统。最后编写了测试入栈和出栈函数的代码创建多个线程进行测试并在程序结束时清理内存。那么可重启序列在未来的系统编程中还会有哪些更出色的表现呢