Linux 内核 Restartable Sequences 深度实战:per-CPU 的无锁世界
在高性能服务器领域,per-CPU 变量是每个性能工程师都要面对的核心概念。"谁写入,谁拥有"——每个 CPU 核心维护属于自己的私有数据副本,从根本上消除了缓存一致性协议带来的核间通信开销。然而,一个长期存在的痛点始终困扰着开发者:如果当前CPU在写入过程中被抢占并迁移到另一个CPU怎么办? 传统方案只能依赖相对昂贵的 atomic cmpxchg 循环,或者直接关中断——用户态程序无法使用后者。这个问题听起来简单,直到你在生产环境中看到它成为性能瓶颈时,才会发现它的严重性。这就是今日主角——Restartable Sequences (rseq)——要解决的核心问题。
rseq 是一个 Linux 内核引入的同步原语,从 Linux 3.17 开始可用,最初由 EfficiOS(LTTng 的开发者)提出并贡献给内核,目标是为用户态提供一个类似 CPU 事务的内存操作机制。与其试图与硬件事务内存(TSX/TME)竞争,rseq 选择了另一个思路:如果发生上下文切换,重新开始。
rseq 的核心原理
在探索代码之前,我们需要先理解 rseq 的基本数学结构。整个抽象建立在两个关键地址上:
// glibc 中定义(来自 <sys/rseq.h>)
struct rseq {
uint32_t cpu_id_start; // 执行序列开始时所在的 CPU
uint32_t cpu_id; // 执行序列结束时所在的 CPU
uint64_t ptr64; // 扩展字段,可指向 rseq_cs
uint32_t flags; // 标志位
uint32_t node_id; // NUMA 节点 ID
uint32_t mm; // 已弃用
char end[]; // 灵活数组成员
} __attribute__((aligned(32)));
// 描述一段可重启代码区域的结构体
struct rseq_cs {
uint32_t version; // ABI 版本
uint32_t flags; // 标志
uint64_t start_ip; // 代码区域起始指令
uint64_t offset; // 代码长度(相对于 start_ip)
uint64_t abort_ip; // 失败时跳转的地址
} __attribute__((aligned(32)));
关键洞察在于:rseq 通过记录"CPU 身份",让内核在恢复执行时快速判断是否遭到抢占或迁移。 如果 cpu_id_start == cpu_id 相等,一切都没有发生;如果不同,说明你被移动到了另一个核心,此时需要跳转到 abort_ip 重新开始。
当我们调用 sys_rseq(&rseq, sizeof(rseq), 0, SIGRSEQ) 进行注册时:
&rseq告诉内核用户态的共享内存位置SIGRSEQ(通常为 SIGSEGV)用来通知极端情况下的异常
注册完成后,内核承诺:在 rseq 临界区内发生的中断、抢占或页面故障都会返回 abort_ip,而不是执行到一半的不一致状态。
手写 rseq:一个 per-CPU 计数器
现代 glibc 已经内置了 helpers 和 automatic registration,但理解底层 ABI 对调试生产环境中的奇异性问题至关重要。让我们从零开始实现一个 per-CPU 无锁计数器:
// rseq_counter.c
// 编译:gcc -O2 -o rseq_counter rseq_counter.c -lpthread
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <syscall.h>
#include <sys/mman.h>
#include <linux/rseq.h>
#include <pthread.h>
#include <stdatomic.h>
// 用户注册的 rseq 区域(必须在 TLS 中,glibc 自动处理)
static __thread struct rseq __rseq_abi __attribute__((aligned(32))) = {
.cpu_id = RSEQ_CPU_ID_UNINITIALIZED,
};
// 每个逻辑 CPU 对应一个计数器
// 实际使用时应该根据 CPU 数量动态分配
#define MAX_CPUS 256
static alignas(64) long per_cpu_counters[MAX_CPUS];
static int sys_rseq(struct rseq *rseq, uint32_t rseq_len,
int flags, uint32_t sig)
{
return syscall(__NR_rseq, rseq, rseq_len, flags, sig);
}
// 注册 rseq(实际程序由 glibc 在启动时自动完成)
int rseq_register(void) {
return sys_rseq(&__rseq_abi, sizeof(__rseq_abi),
0, 0xfeedbeee); // 魔数内核会检查
}
// 静态的 rseq_cs 描述符,存储在可执行段中
// 内核通过该地址空间中的 offset 和 start_ip 查找
static struct rseq_cs rseq_cs_desc __attribute__((aligned(32))) = {
.version = 0,
.flags = 0,
.start_ip = 0, // 实际运行时填充
.offset = 0, // 代码区域长度
.abort_ip = 0, // 失败跳转地址
};
// 核心:递增当前 CPU 的计数器
static inline long rseq_inc_cpu_counter(void) {
long *target;
int cpu;
asm volatile (
// 关键指令:rseq 的核心是以下几条汇编
// 1. rseq_cpu_id_start → /cpu_id 比较 → 不同则跳转到 //gloo
// 2. target = &per_cpu_counters[cpu]
// 3. *target += 1 ← 原子操作
// <abort handler 在这里>
// 以下为简化伪代码——实际应该使用内核提供的宏
".long 0x11111111\n\t" // rseq_cs 引用
".long 0\n\t"
// 真实 rseq 内核验证后执行这里的代码:
: "+r" (target), "=&r" (cpu)
:
: "memory", "cc"
);
return 0; // 实际应返回
}
int main(void) {
// glibc >= 2.35 自动注册,此处为演示
if (rseq_register() != 0) {
fprintf(stderr, "rseq 注册失败(glibc 可能已自动处理)\n");
return 1;
}
// 实际演示:使用 pthread 模拟多线程并发
pthread_t threads[8];
for (int i = 0; i < 8; i++) {
pthread_create(&threads[i], NULL, worker, NULL);
}
for (int i = 0; i < 8; i++) {
pthread_join(threads[i], NULL);
}
// 汇总所有 CPU 的计数器
long total = 0;
for (int i = 0; i < MAX_CPUS; i++) {
if (per_cpu_counters[i] > 0) {
total += per_cpu_counters[i];
}
}
printf("最终计数: %ld\n", total);
return 0;
}
注意到这段代码中混合使用了汇编和实际思考。这带来了一个关键认识:用户态不应该手写 rseq 汇编。生产环境中的正确做法始终是使用 glibc 提供的 helpers,或者 liburseq 库。
glibc 集成:现代用法
glibc 2.35+ 会在程序启动时自动检测内核支持并注册 rseq TLS。只需包含 <sys/rseq.h>,使用 __rseq_abi 符号即可:
#include <sys/rseq.h>
// 检查 rseq 是否可用
if (__rseq_abi.cpu_id != RSEQ_CPU_ID_UNINITIALIZED) {
// rseq 已注册,可用
}
// glibc 提供的 helper 宏(简化版)
// 开发者使用更为高阶的封装:__rseq_percpu_size, __rseq_flags
对于 Java 虚拟机(如 HotSpot)以及 Go 运行时这类运行时,策略略有不同。OpenJDK 在 JDK 12 之后开始启用 rseq 作为 JVM 内部的 per-CPU 结构基础,支持偏向锁撤销、TLAB 分配等。
liburseq:实战工具库
librseq 提供了完整的用户态 API。典型用法如下:
#include <rseq/rseq.h>
// 声明 rseq 区域
static struct rseq atomic_counter;
// 在 main 中初始化
rseq_init_once(); // 一次性注册
// 核心宏:定义 rseq 临界区
static inline void percpu_add(int cpu, long value) {
asm volatile (
"jmp 1f\n\t" // 跳转到代码区
// rseq_cs 元数据
".long 0, 0, 0, 0, 0\n\t"
"1:\n\t"
// 临界区开始
"movq %[counter_table], %%rax\n\t"
"movq (%%rax, %[cpu], 8), %%rbx\n\t"
"addq %[val], %%rbx\n\t"
"movq %%rbx, (%%rax, %[cpu], 8)\n\t"
// 临界区结束
// 如果发生抢占/迁移,内核会跳转到 abort_label
// abort_label: cleanup and retry
:
: [counter_table] "r" (per_cpu_counters),
[cpu] "r" ((long)cpu),
[val] "r" (value)
: "rax", "rbx", "memory"
);
}
注意:librseq 的官方 API 里提供了类似 RSEQ_READ、RSEQ_WRITE 的高阶宏,可直接用于无锁读取或写入 while preventing tearing。
生产级实战案例
1. glibc 的 per-CPU malloc cache
glibc 的 ptmalloc2 通过 per_cpu 实现线程缓存快速路径。传统的做法是 cmpxchg_based,每次操作都要假设失败;使用 rseq 后:每个线程维护 tcache[CPU_ID],free() 直接 tcache[CPU_ID].push(ptr) 无需任何 atomic operation,仅在发生上下文切换或页面故障时才跳转到 cmpxchg slow path。这个特性使得 glibc 的 malloc 在单线程极高频率场景下获得了约 15% 的性能提升——关键在无法被看见的失败路径。
2. Go runtime 的 P-Local 操作
Go 的 mcache 每个 P(逻辑处理器)对应一个,rseq 用于验证 ptr 属于当前 P 的空间,否则回退到 sched.mcache 重取。LLVM ABI 中通过访问 rseq_abi 立即获取 P ID 而不是通过 TLS。
3. JVM 的偏向锁撤销
HotSpot 中,biased locking 使用 rseq 检查线程是否在位,如果不是,锁被直接撤销。这减少了每次 monitorenter 路径上的 TLS 查找次数。
rseq vs 其他同步原语
| 原语 | 适用场景 | 性能 | 复杂度 |
|---|---|---|---|
atomic_cmpxchg |
单变量原子读改写 | 高(受重试次数影响) | 低 |
seqlock |
读多写少 | 中(读重试) | 中 |
rcu |
读极多写极少 | 极低(延迟释放高) | 高 |
rseq |
per-CPU 无锁 |
极高(单次非原子 ALU 指令) | 极高(需内核配合) |
rseq 的代价是:仅支持简单线性内存操作,不支持跨页、不能包含 syscall、不能在 abort handler 中引入新 rseq 区域。这是 CPU 事务内存模拟的根本限制。
Linux 6.x 的新增强
Linux 6.x 对 rseq 做出的一个重要增强是 RSEQ_CS_FLAG_NO_RESTART_ON_PREEMPT 优化。在此之前,内核在抢占时无条件触发 abort。这给短临界区引入了不必要的开销(特别是 NUMA 拓扑下的迁移)。新标志位允许:
- 仅迁移触发 abort(不触发抢占)
- 内核 6.5 新增的
rseq_load_balance_on_block:在任务阻塞时主动负载均衡 rseq 期间的任务分布
另外,Linux 6.3 引入了 MEMBARRIER_CMD_PRIVATE_EXPEDITED_RSEQ,允许通过 sys_membarrier 强制所有已注册的线程执行完整的 rseq 重启,这在 JMM(Java 内存模型)同步时至关重要。
调试与观测
rseq 的一个隐藏痛点:段错误静默重启。如果 abort_ip 错误或内核通知用户态机制失灵,可能导致:
# 观察 rseq 重启次数
perf stat -e 'rseq:*' ./your_program
# 或者开启 liburseq 的 trace 模式
RSEQ_TRACE=1 ./your_program 2> | grep rseq_abort
如果你发现 rseq: 计数器异常高(超过总操作数的 0.1%),说明瓶颈可能在:
- NUMA 调度过于积极(禁用
numactl --cpunodebind) - 临界区跨越了页面边界(检查 rseq_cs 的 offset)
- abort handler 执行了
syscall或cpuid指令
总结
rseq 是内核提供的最被低估的性能工具之一。随着 glibc 自动注册、liburseq 稳定、以及 Linux 6.x 的增强,rseq 从一个 "实验性质" 的字节码设施,成长为现代高性能运行时(glibc/Go/HotSpot/LTTng)的基石。理解 rseq 不仅是为了自己实现 per-CPU 结构,更是理解 Linux 内核如何 "安全地向用户态开放抢占恢复能力" 这一设计哲学——这是一个关于信任与边界的故事,就像 io_uring 在 I/O 子系统做的那样,rseq 在 内存同步 子系统中写下了同样的答案。
参考:Linux 内核源码 kernel/rseq.c,glibc sysdeps/unix/sysv/linux/ 下的 rseq ABI,以及 librseq 项目文档。

发表评论 取消回复