在高并发场景下,per-CPU 数据结构的访问优化是性能调优的关键环节。传统方案依赖互斥锁或原子指令,但前者在竞争激烈时产生大量等待,后者即使在单线程场景下也会引入不必要的内存屏障开销。Linux 4.18 引入的 restartable sequences(rseq)系统调用,提供了一种全新的思路:允许用户态定义临界区,由内核保证其原子性,在发生抢占时自动回滚到指定的 abort handler。
问题背景:per-CPU 访问的性能瓶颈
per-CPU 数据的设计初衷是消除缓存行竞争 —— 每个 CPU 核心操作自己的私有数据,无需跨核同步。然而,获取当前 CPU 编号并访问对应数据的过程本身可能成为瓶颈。sched_getcpu() 在 ARM 上需要系统调用,在 x86 上虽走 VDSO 但仍有一定开销。更关键的是,确认 CPU 编号后、执行内存操作前的窗口期,线程可能被抢占或迁移,导致访问了错误 CPU 的数据。
传统解决方案是使用原子指令(如 x86 的 lock 前缀或 ARM 的 LL/SC),但这违背了 per-CPU 设计的初衷:原子指令会触发缓存一致性协议,增加延迟。据 EfficiOS 的测试,x86 的 lock 前缀可为指令增加数十个周期的开销。
rseq 的核心机制
rseq 的本质是一种 "乐观执行" 模型。用户态向内核注册一个线程局部的 struct rseq 结构,其中包含当前 CPU 编号和指向临界区描述符的指针。临界区由三个地址界定:
- start_ip:临界区起始指令地址
- post_commit_ip:commit 指令之后的地址(临界区结束)
- abort_ip:abort handler 的起始地址
当线程进入临界区时,内核监控其执行状态。如果发生以下任一情况,内核将指令指针重置到 abort_ip:
- 线程被迁移到另一个 CPU
- 信号被投递到该线程
- 线程被抢占
这种设计的关键在于:commit 步骤必须是单条指令。在乐观情况下(无中断),执行路径完全在用户态,无需系统调用、无需原子指令,仅涉及普通的内存访问。
数据结构详解
struct rseq 是用户态与内核的共享 ABI:
struct rseq {
uint32_t cpu_id_start; // 进入临界区时的 CPU 编号
uint32_t cpu_id; // 当前 CPU 编号(内核更新)
uint64_t rseq_cs; // 指向当前临界区描述符
// ... V2 扩展字段
};
struct rseq_cs 描述单个临界区:
struct rseq_cs {
uint64_t version;
uint64_t flags;
uint64_t start_ip;
uint64_t post_commit_offset;
uint64_t abort_ip;
};
用户态在进入临界区前需将 rseq_cs 的地址写入 rseq.rseq_cs,内核在检测到中断时会将其清零,并跳转到 abort_ip。
实现示例:per-CPU 计数器
以下展示 rseq 实现 per-CPU 计数器的核心逻辑。首先通过 rseq() 系统调用注册线程:
static __thread volatile struct rseq __rseq_abi;
#define RSEQ_SIG 0x53053053
static void register_thread(void) {
syscall(__NR_rseq, &__rseq_abi, sizeof(struct rseq), 0, RSEQ_SIG);
}
计数器递增的临界区需用汇编定义,确保 commit 是单条指令:
// 临界区入口
leaq rseq_cs(%%rip), %%rax
movq %%rax, %[rseq_cs] // 注册临界区
cmpl %[cpu_id], %[current_cpu_id]
jnz abort // CPU 已变化,跳转 abort
addq %[count], %[v] // commit:单条指令完成递增
// 临界区出口
若被中断,控制流转至 abort handler,通常实现为重试逻辑。
性能对比
EfficiOS 的基准测试显示了 rseq 的显著优势:
| 操作 | x86 加速比 | ARM 加速比 |
|---|---|---|
| 获取 CPU 编号 | 20x | 35x |
| per-CPU 计数器递增 | 7.7x | 11x |
| LTTng-UST 事件写入 | 1.2x | 1.1x |
| liburcu 读操作 | 1.9x | 5.8x |
获取 CPU 编号的 20-35 倍加速源于避免了 VDSO 或系统调用开销;计数器递增的 7-11 倍加速则来自消除了原子指令的缓存一致性流量。
使用限制与注意事项
rseq 并非万能方案,使用时需严格遵守以下约束:
临界区限制:
- commit 必须是单条指令
- 禁止在临界区内调用系统调用(会触发 SIGSEGV)
- abort handler 必须位于临界区外
正确性保证:
- 必须使用
volatile或内存屏障确保 CPU 编号只读一次 - 需处理 abort 后的重试逻辑
- 单步调试会导致无限循环(需配合 cpu_opv 系统调用解决)
平台限制:
- 仅 Linux 4.18+ 支持
- 需检查内核配置
CONFIG_RSEQ
实践建议
对于大多数应用,直接使用 librseq 库是最佳选择,它封装了寄存器、CPU 查询和常用 per-CPU 操作。自研实现时,建议:
- 使用
gcc asm goto语法定义临界区,便于与 C 代码交互 - 通过
getauxval(AT_RSEQ_FEATURE_SIZE)检测 V2 优化模式支持 - 考虑时间片扩展(time slice extension)功能,在关键临界区请求内核延长调度时间片
rseq 代表了操作系统向用户态下放更多控制权的趋势。通过将原子性保证的复杂度从硬件级原子指令转移到内核态的抢占检测,它在保持正确性的同时获得了接近裸机指令的性能。对于 per-CPU 计数器、无锁队列、事件追踪等场景,rseq 已成为 Linux 高性能编程的重要工具。
参考来源
- The 5-year journey to bring restartable sequences to Linux — EfficiOS
- Restartable Sequences - The Linux Kernel documentation
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。