$RodHat_
Console Tips

rseq: per-CPU data without a kernel call

Published by · Updated

A bare server motherboard with a multicore processor and separate memory modules
Photo: AI-generated — no human photographer / RodHat AI Cover

Every call to malloc() under load does the same thing before it picks an arena: it figures out which CPU you’re on. Before Linux 4.18 that meant a getcpu(2) call, which is fast (the vDSO version skips the syscall overhead), but it’s still a function call with overhead that adds up at scale. Since glibc 2.35, malloc skips it entirely. It reads a field the kernel maintains in your thread’s own memory. That mechanism is rseq(2), and most people running Linux have never heard of it.

The problem rseq solves

Per-CPU data structures are a standard kernel trick for scalability: instead of one shared counter with a lock, you keep one counter per CPU and sum them only when you need the total. No contention, no cache-line bouncing. The catch in userspace is the same catch it’s always been: you can read your CPU ID, start writing to that CPU’s slot, and get migrated to a different CPU before you finish. Now you’ve corrupted the wrong slot.

The naive fix is to pin yourself to a CPU (sched_setaffinity), do the update, unpin. That works and it’s ugly. The lock fix is to grab a mutex, which defeats the whole point of per-CPU data. rseq is the third option: do the update without locks, and let the kernel tell you whether you got migrated before the update committed.

Registration

One call per thread, at thread startup:

#include <linux/rseq.h>
#include <sys/syscall.h>
#include <unistd.h>

/* x86-64 signature; other arches differ, see the man page */
#define RSEQ_SIG 0x53053053

static __thread struct rseq rseq_abi = {
    .cpu_id = RSEQ_CPU_ID_UNINITIALIZED,
};

void rseq_register(void)
{
    syscall(SYS_rseq, &rseq_abi, sizeof(rseq_abi), 0, RSEQ_SIG);
}

After that, rseq_abi.cpu_id is an integer the kernel keeps accurate on every context switch. Reading it costs one memory load:

int cpu = rseq_abi.cpu_id;  /* no syscall, no atomic op */

That is the cheap getcpu replacement. Everything else is the critical-section mechanism.

Critical sections

The rseq_cs field in your struct rseq holds a pointer to a struct rseq_cs, which describes a window of code:

struct rseq_cs {
    __u32 version;           /* must be 0 */
    __u32 flags;             /* must be 0 */
    __u64 start_ip;          /* first instruction of the critical section */
    __u64 post_commit_offset;/* length in bytes */
    __u64 abort_ip;          /* where to jump on preemption */
} __attribute__((aligned(32)));

You set rseq_abi.rseq_cs before entering the critical section, do your per-CPU update inside [start_ip, start_ip + post_commit_offset), then clear rseq_abi.rseq_cs on the way out. If the kernel preempts or migrates your thread while you’re inside that window, it clears rseq_cs for you and jumps execution to abort_ip. Your per-CPU update never committed; your abort handler retries.

Fast path (no migration): pure userspace, zero kernel overhead. Slow path (you got migrated): the kernel handles the abort, you loop back and try again with the new CPU ID. For workloads where migrations are rare, this is a measurable win.

The abort stub needs a signature

The 4 bytes immediately before abort_ip in memory must match the RSEQ_SIG you passed to sys_rseq. The kernel checks this before jumping. The point is to prevent a crafted exploit from aiming the abort handler at arbitrary code; the signature acts as a guard token. On x86-64, 0x53053053 happens to disassemble as push rbx if executed in the forward direction, so the stub can safely sit inline in a code section.

glibc 2.35 registers it for you

Since glibc 2.35 (March 2022), the library calls sys_rseq during thread startup. The per-thread struct sits at a fixed offset from the pthread structure. glibc exports __rseq_offset and __rseq_size so library code can find it without registering its own struct:

#include <sys/rseq.h>    /* glibc 2.35+ */
#include <pthread.h>

struct rseq *r = (struct rseq *)((char *)pthread_self() + __rseq_offset);
int cpu = r->cpu_id;     /* still just a load */

musl does not register rseq or expose the struct. On musl, sched_getcpu(3) calls getcpu(2) and you pay the vDSO cost. That’s fine for almost everything; it’s only high-frequency allocator and profiling code where the difference shows up.

FreeBSD

No equivalent. The BSDs have excellent userspace primitives in other areas (kqueue edges out epoll for my money, and ZFS jails leave Linux namespaces looking like scaffolding), but per-CPU userspace data structures at this layer are a Linux-specific thing. If you’re writing portable code, stick with sched_getcpu() on Linux and thread-local storage elsewhere. If you’re writing a Linux-only allocator or a high-frequency tracing library, rseq is the right tool and the infrastructure is already there in your threads.

When to care

Most application code never needs to touch rseq directly. The interesting cases are:

  • Custom allocators that shard by CPU to avoid lock contention (this is what glibc does)
  • Per-CPU counters in tracing libraries where atomic_add would create too much cache traffic
  • Lock-free queue implementations where the “which shard” decision needs to stay stable across the enqueue

For those cases, on Linux 4.18+ with glibc 2.35+, the per-thread struct already exists and the kernel is already updating cpu_id. You’re just not reading it yet.

Related: futex: raw Linux mutex primitive, taskset and numactl: CPU and NUMA pinning, io_uring: zero-syscall I/O.

Sources

  1. rseq(2) man page (man7.org)
  2. Linux restartable sequences (technical overview) (EfficiOS (Mathieu Desnoyers, original RFC author))