Linux 内核 Restartable Sequences (rseq):用户态无锁数据结构的工程化革命
当你在高并发场景下遇到性能瓶颈时,锁竞争往往是第一嫌疑人。Lock-free 方案、RCU、甚至 io_uring 都提供了各自的解决思路。然而 Linux 内核还提供了一个被严重低估的系统调用——
rseq()(Restartable Sequences),它能在用户态实现真正的无锁、无系统调用的"原子操作"区域,本文深入剖析其原理、实现与生产级代码实战。
一、为什么需要 rseq:无锁编程的阿喀琉斯之踵
在深入 rseq 之前,先理解传统无锁方案的痛点:
CAS(Compare-and-Swap)循环 是最基础的原子操作,但在高竞争下会引发缓存行乒乓(cache-line bouncing)和活锁问题:
// 传统 CAS 计数的悲剧
void increment_atomic(int *counter) {
int old, new;
do {
old = *counter;
new = old + 1;
} while (!__atomic_compare_exchange_n(counter, &old, new,
1, __ATOMIC_RELAXED, __ATOMIC_RELAXED));
}
当 64 个线程同时在同一缓存行上竞争 CAS 时,吞吐可能退化到单线程的 1/10——这是因为每次 CAS 失败都会触发缓存一致性协议(MESI)的缓存行作废风暴。
RCU(Read-Copy-Update) 虽然在读多写少场景下表现优雅,但写侧开销大(需要 synchronize_rcu() 等待 grace period),且无法解决"读取-修改-写入"的原子性本质。
rseq 的核心思路则是更彻底的:让一个代码段runnable"语义上原子"执行——如果被抢占或中断,整个段被重启而不是重试。这与数据库的乐观并发控制有异曲同工之妙。
二、rseq 系统调用:API 与核心数据结构
rseq 首先通过 prctl() 或 rseq() 系统调用(系统调用号 386/334 视架构而定)向内核注册一个 per-thread 的描述符。核心结构体定义如下:
/* 内核 ABI 结构体(来自 linux/rseq.h) */
struct rseq {
uint32_t cpu_id_start; /* 运行起始 CPU */
uint32_t cpu_id; /* 当前 CPU */
uint64_t rseq_cs; /* 指向 rseq_cs 的指针 */
uint32_t flags; /* 标志位 */
uint32_t padding;
} __attribute__((aligned(32)));
struct rseq_cs {
uint32_t version; /* ABI 版本 */
uint32_t flags; /* 标志 */
uint64_t start_ip; /* 可重启段起始地址 */
uint64_t post_commit_offset; /* commit 指令偏移 */
uint64_t abort_ip; /* 中止处理地址 */
} __attribute__((aligned(32)));
- start_ip:可重启段的入口地址
- post_commit_offset:commit 指令相对于 start_ip 的偏移
- abort_ip:如果段被中断/抢占,跳转到 abort handler 执行回退逻辑
- commit:段的"提交"指令(通常是写入结果的单条原子指令)
关键限制:commit 必须是单条写入指令,rseq 只能保护"读-计算-写"这个整体不被中间打断。
三、用户态实现:无锁计数器
下面是一个完整的无锁计数器实现,对比 rseq 方案与 CAS 方案:
#define _GNU_SOURCE
#include <linux/rseq.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
#include <pthread.h>
#include <sched.h>
#include <stdint.h>
static __thread struct rseq *rseq_tls;
/* 每个 CPU 的计数器 */
static __thread int cpu_counter_rseq[];
/*
* rseq CAS 操作的核心:通过 rseq 保证原子性
* abort 标签是必须的,内核会在此注册 abort_ip
*/
#define RSEQ_CS_ABORTIP \
"nop\n\t" \
".long 0xd4200000\n\t" /* brk 0 (abort 中断) */
static inline int rseq_cas(volatile int *ptr, intptr_t expect, intptr_t value) {
int ret;
__asm__ __volatile__ (
".long 0x%c[cs_bitmap] \n\t" /* rseq_cs 引用 */
"ldr %w[ret], [%[ptr]] \n\t" /* 加载旧值 */
"cmp %w[ret], %w[expect] \n\t" /* 比较 */
"b.ne 66f \n\t" /* 不等则 abort */
"str %w[value], [%[ptr]] \n\t" /* 写入新值(commit) */
"66: \n\t"
: [ret] "=r" (ret)
: [ptr] "r" (ptr), [expect] "r" (expect),
[value] "r" (value), [cs_bitmap] "i" (offsetof(struct rseq, rseq_cs))
: "memory", "cc");
return ret;
}
/* glibc 风格的 rseq 计数器 */
static inline long rseq_cpu_counter_inc(volatile long *per_cpu_counters) {
long result;
__asm__ __volatile__ (
"lea %[result], [%%rip + 99f] \n\t" /* start_ip */
/* rseq_cs 注册与执行 */
"99: \n\t"
"ldr %w[result], [%[base], %[cpu], lsl #3] \n\t" /* 加载 */
"add %w[result], %w[result], #1 \n\t" /* 计算 */
"str %w[result], [%[base], %[cpu], lsl #3] \n\t" /* 提交(commit) */
: [result] "=r" (result)
: [base] "r" (per_cpu_counters),
[cpu] "r" ((long)cpu_current())
: "memory", "cc");
return result;
}
上面的代码展示了 rseq 的核心模式:如果线程在执行 str(commit 指令)之前被抢占,内核会将其 PC 重置到 start_ip 重新执行整个段。这意味着用户态看到的"原子性"实际上是内核保证的——无需任何锁或 CAS 循环。
四、内核侧实现:寄存器与信号的精密配合
理解 rseq 必须了解内核如何工作。关键逻辑位于 kernel/rseq.c:
// kernel/rseq.c(简化版核心逻辑)
void __rseq_handle_notify_resume(struct ksignal *sig, struct pt_regs *regs) {
struct task_struct *t = current;
struct rseq *rseq = t->rseq;
/* 不在 rseq 临界区?跳过 */
if (!rseq || !t->rseq_cs)
return;
/* 读取当前 CPU,与起始 CPU 比对 */
int cpu = smp_processor_id();
if (cpu != t->rseq_cpu ||
(regs->ip >= t->rseq_cs->start_ip &&
regs->ip < t->rseq_cs->start_ip + t->rseq_cs->post_commit_offset)) {
/*
* CPU 不匹配,或执行未到达 commit 指令
* 跳转到 abort handler 执行回退
*/
regs->ip = t->rseq_cs->abort_ip;
t->rseq_cs = NULL;
t->rseq_sig = 0;
}
/* 已到达 commit 指令 = 原子性完成,不需要干预 */
}
内核的关键判断流程:
1. 线程注册 rseq,标记当前 CPU id
2. 内核在 thread-switch/signal-delivery 时检查:
- 当前 CPU != 起始 CPU? → abort
- 当前 EIP 在 [start_ip, start_ip + post_commit_offset) 之间? → abort(commit 未到达)
- 否则 → 正常执行
abort 语义:内核直接修改 regs->ip = abort_ip,将执行流转到用户态注册的 abort handler,在那里可以执行回滚逻辑或重试。
这与硬件事务内存(TSX/RTM)的 abort 机制很相似,但 rseq 没有容量限制(TSX 受 L1 Cache 大小约束),且不会触发事务回滚的性能惩罚。
五、生产级案例:tcmalloc 的 rseq 实战
Google 的 tcmalloc 是 rseq 最知名的生产级应用。在 glibc 下,malloc() 的 fast-path 完全依赖 rseq 实现 per-CPU slab 分配,免除了锁竞争和系统调用开销。
tcmalloc 的核心策略是:每个 CPU 有独立的小对象缓存,通过 rseq 在 CPU 间快速分流。
// tcmalloc 模式的简化实现(基于 Google 开源实现改编)
class RseqPerCpuCache {
public:
// Fast-path:使用 rseq 无锁访问
void* Allocate(size_t size) {
const int cpu = GetCurrentCpu();
// rseq 保护区域:几乎零开销
void* obj = rseq_pop_free_list(cpu, size);
if (likely(obj != nullptr)) return obj;
// Slow-path:系统分配(有锁)
return CentralAlloc(cpu, size);
}
void Deallocate(void* ptr, size_t size) {
const int cpu = GetCurrentCpu();
// rseq 保护区域:直接归还到 per-CPU free-list
rseq_push_free_list(cpu, ptr, size);
}
private:
// 每个 CPU 头节点数组
alignas(64) FreeList cpu_free_lists_[kMaxCpus];
void* rseq_pop_free_list(int cpu, size_t size) {
FreeList* list = &cpu_free_lists_[cpu];
void* result;
__asm__ __volatile__(
".Lstart_%=: \n\t"
/* 加载 list->head */
"ldr %[dst], [%[list]] \n\t"
"cbz %[dst], .Lcommit_fail_%=\n\t" /* head == NULL → abort到slow path */
/* 加载 next(head->next)*/
"ldr %[tmp], [%[dst]] \n\t"
/* 提交:原子更新 list->head */
"str %[tmp], [%[list]] \n\t" /* ✅ commit 指令 */
".Lcommit_fail_%=: \n\t"
: [dst] "=&r"(result), [tmp] "=&r"(tmp)
: [list] "r"(&list->head)
: "memory");
return result;
}
};
这里的技巧是:pop 操作只需要一条 str 作为 commit。如果执行过程中被抢占——线程迁到了另一个 CPU——内核检测到 CPU 不匹配,跳转到 abort 标签;用户态 abort handler 会设置一个 "fallback" 标志,然后 slow-path 使用锁或信号处理来完成操作。
六、与 TSX、Intel MPX 和 io_uring 的比较
rseq vs TSX(Transactional Memory)
| 维度 | rseq | TSX/RTM |
|---|
| 原子性保证 | 内核重启 | 硬件事务 |
|---|
| 容量限制 | 无(仅代码段大小) | L1 Cache 大小 |
|---|
| 失败处理 | abort handler | XABORT + fallback |
|---|
| 跨 CPU 自动支持 | ✅ | ❌(即失败) |
|---|
| 内核支持 | 4.18+ | Haswell+ |
|---|

发表评论 取消回复