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%),说明瓶颈可能在:

  1. NUMA 调度过于积极(禁用 numactl --cpunodebind)
  2. 临界区跨越了页面边界(检查 rseq_cs 的 offset)
  3. 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 项目文档。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部