RSEQ (Restartable Sequences)

Linux 4.18에서 도입된 RSEQ(Restartable Sequences)는 사용자 공간(User Space) 스레드(Thread)가 글로벌 락이나 원자 명령어(Atomic Instruction) 없이 per-CPU 자료구조를 안전하게 갱신하는 메커니즘입니다. 선점(Preemption)이나 CPU 마이그레이션(Migration)이 임계 구간(Critical Section) 내에서 발생하면 커널이 사용자 복귀 직전에 중단 지점(abort IP)으로 명령어 포인터를 강제로 점프시켜 구간을 처음부터 재실행하게 합니다.

전제 조건: 컨텍스트 스위치(Context Switch) 문서를 먼저 읽으세요. RSEQ를 이해하려면 CPU 스케줄러가 선점 시 어떻게 실행 흐름을 바꾸는지 알아야 합니다.
일상 비유: RSEQ는 은행 번호표 시스템과 비슷합니다. 창구 직원(CPU)이 손님(스레드)의 처리 도중 다른 업무(선점)로 자리를 비우면, 손님은 번호표를 다시 뽑아 처음부터 기다립니다. 짧은 작업은 거의 중단되지 않고, 중단되더라도 다시 시도하면 그만이어서 락 없이도 안전합니다.

핵심 요약

  • RSEQ — 원자 명령어(Atomic Instruction) 없이 per-CPU 자료구조를 안전하게 갱신하는 커널 메커니즘 (Linux 4.18+)
  • rseq_cs — 임계 구간의 시작 주소·범위·abort 복귀 주소를 기술하는 구조체. 구간 진입 전에 포인터를 설정하고, 완료 후 0으로 지웁니다
  • RSEQ_SIG — abort 핸들러 직전에 삽입하는 4바이트 매직 워드. 커널이 abort_ip - 4 위치를 검증해 임의 주소로의 점프를 방지합니다
  • abort_ip — 임계 구간 중단 시 커널이 명령어 포인터를 강제로 재기록하는 재시도(Retry) 핸들러 주소
  • mm_cid — 프로세스 내 동시 실행 스레드 수에 비례하는 조밀한 ID. per-CPU 배열 대신 캐시 낭비를 줄입니다 (Linux 6.2+)

단계별 이해

  1. 등록
    rseq(2) 시스템 콜로 TLS에 배치한 struct rseq의 주소와 RSEQ_SIG를 커널에 등록합니다. glibc 2.35+는 스레드 생성 시 자동으로 처리합니다.
  2. 임계 구간 진입·실행
    임계 구간 직전에 rseq_cs 포인터를 설정합니다. 구간 내 코드를 실행하고, 마지막 커밋 명령어까지 완료하면 rseq_cs를 0으로 지워 구간 종료를 알립니다.
  3. 중단 시 재시도(Retry)
    구간 실행 도중 선점·시그널·CPU 마이그레이션이 발생하면, 커널 복귀 경로(rseq_handle_notify_resume())가 IP를 abort_ip로 재기록합니다. 사용자 공간은 abort 핸들러에서 상태를 초기화하고 구간을 처음부터 재실행합니다.

개요

멀티코어 시스템에서 per-CPU 카운터나 프리 리스트(Free List) 같은 자료구조를 갱신할 때 가장 단순한 방법은 원자 연산(Atomic Operation)을 사용하는 것입니다. 그러나 lock xadd(x86) 같은 원자 명령어는 캐시 라인(Cache Line) 소유권 협상에 수십 나노초를 씁니다. RSEQ는 다른 접근을 취합니다: 갱신을 그냥 수행하되, 도중에 다른 CPU로 이동하거나 선점됐다면 처음부터 다시 시도합니다.

이 방식이 올바른 이유는 "같은 CPU에 있는 동안은 다른 스레드와 경쟁하지 않는다"는 per-CPU 설계 전제 덕분입니다. 재시도(Retry) 횟수는 실제로 매우 낮고, 재시도가 없는 정상 경로는 원자 명령어 없이 처리되므로 락 경합(Lock Contention)이 완전히 사라집니다.

기법경합 없는 경로CPU 간 이동 처리슬립(Sleep) 가능 여부
spinlock락 획득 + 해제 (원자 명령어)자동 직렬화(Serialization)불가 (busy-wait)
atomic_add원자 명령어 1회자동 직렬화불가
per-CPU (this_cpu_add)일반 메모리 쓰기preempt_disable 필요불가
RSEQ일반 메모리 쓰기abort_ip로 점프 후 재시도가능 (임계 구간 밖)

핵심 동작 원리

RSEQ의 동작은 세 가지 요소로 구성됩니다: 임계 구간 기술자(rseq_cs), CPU ID 필드, 커널 복귀 경로 훅.

중단·재시도 흐름

  1. 사용자 스레드가 임계 구간에 진입하기 직전, __rseq_abi.rseq_cs를 현재 구간의 기술자(struct rseq_cs)로 설정합니다.
  2. __rseq_abi.cpu_id_start에서 현재 CPU 번호를 읽어 어느 CPU에서 실행 중인지 기록합니다.
  3. 임계 구간 본문을 실행합니다 (일반 메모리 읽기/쓰기).
  4. 임계 구간 마지막 명령(커밋 포인트)을 실행한 뒤 rseq_cs를 0으로 지웁니다.
  5. 선점, 시그널, CPU 마이그레이션이 발생하면 커널은 유저 공간으로 복귀하기 전에 rseq_handle_notify_resume()을 호출합니다.
  6. 이 함수는 현재 IP가 [start_ip, start_ip + post_commit_offset) 범위 안에 있는지 확인하고, 맞으면 IP를 abort_ip로 강제 재기록합니다.
  7. 사용자 공간으로 복귀하면 abort 핸들러(Handler) 코드가 실행되어 임계 구간을 처음부터 재시도합니다.

흐름 다이어그램

RSEQ 임계 구간 중단·재시작 메커니즘 사용자 공간 (User Space) rseq_cs = &cs (구간 기술자 설정) cpu = cpu_id_start (현재 CPU 읽기) start_ip ▼ 임계 구간 (Critical Section) per_cpu_data[cpu] += value; (일반 메모리 쓰기) commit_ip ▲ rseq_cs = 0 (커밋 완료) abort_ip: 재시도 핸들러 goto retry; (처음부터 재시도) ⚡ 중단 이벤트 • 선점 (Preemption) • CPU 마이그레이션 • 시그널 (Signal) 커널 (Kernel) rseq_handle_notify_resume() exit_to_user_mode_loop() 내부 IP ∈ [start_ip, start_ip + post_commit_offset)? YES IP ← abort_ip (명령어 포인터 강제 재기록) NO 정상 복귀 (임계 구간 아님)

rseq_cs: 임계 구간 기술자

struct rseq_cs는 임계 구간의 범위와 중단 시 점프 대상을 기술하는 읽기 전용 구조체입니다. 사용자 공간에서 정적(static) 또는 TLS(Thread Local Storage)에 배치하며, 임계 구간 실행 전에 struct rseqrseq_cs 포인터 필드에 해당 주소를 씁니다.

/* include/uapi/linux/rseq.h */
struct rseq_cs {
    __u32  version;           /* 현재 0 */
    __u32  flags;             /* 예약 (0) */
    __u64  start_ip;          /* 임계 구간 첫 명령어 주소 */
    __u64  post_commit_offset; /* 구간 길이 (바이트), commit_ip = start_ip + post_commit_offset */
    __u64  abort_ip;          /* 중단 시 점프 대상 주소 */
} __attribute__((aligned(32)));
ℹ️

post_commit_offset 계산: 커밋 포인트(commit IP)는 start_ip + post_commit_offset - 1까지의 마지막 명령어입니다. 범위는 반개구간 [start_ip, start_ip + post_commit_offset)이므로, 커밋 명령어 바로 다음 주소까지 보호됩니다. 어셈블리(Assembly)로 직접 계산할 때는 post_commit_offset = commit_label - start_label을 사용합니다.

RSEQ_CS 플래그

UAPI에는 enum rseq_cs_flags가 정의되어 있습니다. RSEQ_CS_FLAG_NO_RESTART_ON_* 계열은 초기 설계 의도로만 존재하며 커널이 실제로 처리하지 않습니다. ABI 호환성 유지를 위해 헤더에 남아 있을 뿐이므로 설정해도 동작에 영향을 주지 않습니다. 기능적으로 유효한 플래그는 Linux 7.0+에서 추가된 타임슬라이스 연장 관련 두 가지입니다.

/* include/uapi/linux/rseq.h */
enum rseq_cs_flags {
    /* ⚠ 정의만 존재, 커널이 처리하지 않음 (ABI 호환 유지 전용) */
    RSEQ_CS_FLAG_NO_RESTART_ON_PREEMPT = (1U << 0),
    RSEQ_CS_FLAG_NO_RESTART_ON_SIGNAL  = (1U << 1),
    RSEQ_CS_FLAG_NO_RESTART_ON_MIGRATE = (1U << 2),

    /* Linux 7.0+ — 기능적으로 유효한 플래그 */
    RSEQ_CS_FLAG_SLICE_EXT_AVAILABLE   = (1U << 3), /* 커널이 설정: 타임슬라이스 연장 기능 사용 가능 */
    RSEQ_CS_FLAG_SLICE_EXT_ENABLED     = (1U << 4), /* 사용자가 설정: 임계 구간 진입 시 연장 요청 활성화 */
};
⚠️

NO_RESTART_ON_* 플래그 주의: 커널은 RSEQ_CS_FLAG_NO_RESTART_ON_PREEMPT, _SIGNAL, _MIGRATE를 무시합니다. 선점이나 마이그레이션 발생 시 플래그 설정 여부와 무관하게 항상 abort_ip로 점프합니다. 이 플래그들은 초기 설계에서 "중단 없이 계속 실행"을 의도했지만 결코 구현되지 않았습니다.

커널 내부 구조

struct rseq — UAPI 공유 구조체(Struct)

struct rseq는 커널과 사용자 공간이 공유하는 per-스레드 구조체입니다. 커널은 컨텍스트 스위치(Context Switch) 및 시그널(Signal) 복귀마다 cpu_idmm_cid 필드를 최신값으로 갱신합니다. 사용자 공간은 이 필드들을 읽기 전용(Read-Only)으로 사용하고, rseq_cs 필드만 쓰기 접근합니다.

/* include/uapi/linux/rseq.h (Linux 6.3+) */
struct rseq_slice_ctrl {
    union {
        __u32 all;
        struct {
            __u8  request;     /* 1: 선점 연장 요청 */
            __u8  granted;     /* 1: 커널이 연장 승인 (커널이 설정) */
            __u16 __reserved;
        };
    };
};

struct rseq {
    __u32  cpu_id_start;   /* 임계 구간 시작 시 CPU 번호 (커널이 갱신) */
    __u32  cpu_id;         /* 현재 CPU 번호, 유효하지 않으면 RSEQ_CPU_ID_UNINITIALIZED */
    __u64  rseq_cs;        /* 현재 활성 임계 구간 기술자 포인터 (없으면 0) */
    __u32  flags;          /* RSEQ_FLAG_* */
    __u32  node_id;        /* NUMA 노드 ID (커널이 갱신) */
    __u32  mm_cid;         /* 동시 실행 ID: 캐시 지역성 최적화 (커널이 갱신) */
    struct rseq_slice_ctrl slice_ctrl; /* 타임슬라이스 연장 제어 (Linux 7.0+) */
    /* (이후 필드 예약) */
} __attribute__((aligned(32)));
필드쓰는 주체읽는 주체설명
cpu_id_start커널사용자임계 구간 시작 직전 CPU 번호. 구간 내에서 마이그레이션이 없었는지 확인에 사용
cpu_id커널사용자현재 CPU 번호 (임계 구간 밖에서 참조)
rseq_cs사용자커널현재 활성 임계 구간 기술자 포인터. 0이면 비활성
flags사용자커널등록 플래그 (현재 예약)
node_id커널사용자NUMA 노드 ID (Linux 6.3+)
mm_cid커널사용자프로세스 내 동시 실행 스레드 수에 비례하는 조밀한 ID (Linux 6.3+)
slice_ctrl사용자/커널사용자/커널타임슬라이스 연장 요청(request)·승인(granted) 필드 (Linux 7.0+)

task_struct 연동

각 태스크(Task)는 task_struct::rseq에 사용자 공간의 struct rseq 포인터를 저장합니다. rseq(2) 시스템 콜(System Call)로 등록이 완료되면 task_struct::rseq_sig에 등록 시 전달한 시그니처 값을 기록하고, 이후 모든 커널→유저 복귀 경로에서 rseq 처리가 활성화됩니다.

/* include/linux/sched.h (요약) */
struct task_struct {
    /* ... */
#ifdef CONFIG_RSEQ
    struct rseq __user  *rseq;          /* 사용자 공간의 struct rseq 포인터 */
    u32                  rseq_sig;       /* 등록 시 전달한 RSEQ_SIG */
    u32                  rseq_len;       /* struct rseq 크기 */
#endif
    /* ... */
};

rseq_handle_notify_resume()

커널이 사용자 공간으로 복귀하는 공용 경로 exit_to_user_mode_loop()에서 _TIF_NOTIFY_RESUME 플래그가 설정되어 있으면 resume_user_mode_work()를 통해 rseq_handle_notify_resume()이 호출됩니다. 이 함수는 다음을 수행합니다:

  1. task_struct::rseq가 NULL이면 즉시 반환합니다.
  2. 사용자 공간에서 rseq_cs 포인터를 읽습니다(0이면 반환).
  3. rseq_cs.start_ippost_commit_offset으로 구간 범위를 계산합니다.
  4. 저장된 명령어 포인터(pt_regs::ip)가 해당 범위 안에 있으면 abort_ip로 재기록합니다.
  5. cpu_id, node_id, mm_cid를 현재 값으로 갱신합니다.
  6. rseq_cs 포인터를 0으로 지웁니다.
/* kernel/rseq.c (흐름 요약, 실제 코드는 더 복잡) */
void rseq_handle_notify_resume(struct ksignal *ksig, struct pt_regs *regs)
{
    struct task_struct *t = current;
    struct rseq_cs __user *urseq_cs;
    u64 rseq_cs_ptr, start_ip, post_commit_offset, abort_ip;
    unsigned long ip = instruction_pointer(regs);

    if (!t->rseq)
        return;

    /* rseq_cs 포인터 읽기 */
    if (get_user(rseq_cs_ptr, &t->rseq->rseq_cs))
        goto error;
    if (!rseq_cs_ptr)
        goto update;  /* 임계 구간 없음, cpu_id 갱신만 */

    urseq_cs = (struct rseq_cs __user *)(uintptr_t)rseq_cs_ptr;
    get_user(start_ip,          &urseq_cs->start_ip);
    get_user(post_commit_offset, &urseq_cs->post_commit_offset);
    get_user(abort_ip,           &urseq_cs->abort_ip);

    /* IP가 임계 구간 내에 있으면 abort_ip로 리다이렉트 */
    if (ip >= start_ip && ip < start_ip + post_commit_offset) {
        instruction_pointer_set(regs, abort_ip);
        put_user(0ULL, &t->rseq->rseq_cs); /* rseq_cs 클리어 */
    }

update:
    /* cpu_id, node_id, mm_cid 갱신 */
    put_user(raw_smp_processor_id(),    &t->rseq->cpu_id);
    put_user(cpu_to_node(smp_processor_id()), &t->rseq->node_id);
    /* mm_cid는 별도 갱신 경로 */
    return;
error:
    force_sigsegv(SIGSEGV);
}

등록 및 초기화

rseq(2) 시스템 콜

RSEQ를 사용하려면 먼저 rseq(2) 시스템 콜로 struct rseq의 사용자 공간 주소와 RSEQ_SIG를 커널에 등록해야 합니다. 해제는 RSEQ_FLAG_UNREGISTER 플래그를 사용합니다.

#define _GNU_SOURCE
#include <sys/syscall.h>
#include <linux/rseq.h>
#include <errno.h>

/* TLS에 배치: 각 스레드마다 독립적인 struct rseq */
static __thread struct rseq __rseq_abi
    __attribute__((aligned(32))) = {
    .cpu_id = RSEQ_CPU_ID_UNINITIALIZED,
};

int rseq_register(void)
{
    return syscall(__NR_rseq,
                   &__rseq_abi,        /* struct rseq 포인터 */
                   sizeof(__rseq_abi), /* 구조체 크기 */
                   0,                  /* flags (등록: 0) */
                   RSEQ_SIG);          /* 아키텍처별 시그니처 매직 워드 */
}

int rseq_unregister(void)
{
    return syscall(__NR_rseq,
                   &__rseq_abi,
                   sizeof(__rseq_abi),
                   RSEQ_FLAG_UNREGISTER,
                   RSEQ_SIG);
}

glibc 2.35 자동 등록

glibc 2.35(2022년 2월)부터 스레드 생성 시 rseq(2)를 자동으로 호출합니다. 덕분에 TCMalloc, jemalloc, liburcu 등 라이브러리는 직접 등록 코드 없이 RSEQ를 활용할 수 있습니다. 단, glibc가 사용하는 __rseq_abi TLS 변수의 오프셋(Offset)을 __rseq_offset__rseq_size 심볼로 노출하므로, 라이브러리는 이를 통해 올바른 struct rseq 주소를 찾을 수 있습니다.

/* glibc가 노출하는 심볼로 struct rseq 접근 */
#include <sys/rseq.h>  /* glibc 2.35+ */

extern __thread struct rseq __rseq_abi;
extern ptrdiff_t            __rseq_offset;    /* TLS 오프셋 */
extern unsigned int         __rseq_size;      /* struct rseq 크기 (0이면 RSEQ 미지원) */
extern unsigned int         __rseq_flags;     /* 등록 플래그 */

static inline int rseq_available(void)
{
    return __rseq_size != 0;
}

librseq 접근 매크로

librseq 및 glibc 내부에서는 컴파일러 최적화(Compiler Optimization)로 인한 값 캐싱을 방지하기 위해 volatile 접근 매크로를 제공합니다. 임계 구간 내에서 cpu_id_startmm_cid를 읽을 때는 반드시 이 매크로를 사용해야 합니다.

/* librseq 접근 매크로 — 컴파일러 재배치(Reorder) 방지 */
#define RSEQ_ACCESS_ONCE(x)    (*(__volatile__ typeof(x) *)&(x))
#define RSEQ_READ_ONCE(x)     RSEQ_ACCESS_ONCE(x)
#define RSEQ_WRITE_ONCE(x, v) (RSEQ_ACCESS_ONCE(x) = (v))

/* 현재 CPU 번호 빠른 읽기 (임계 구간 밖에서 사용) */
static inline int rseq_current_cpu_raw(void)
{
    return RSEQ_ACCESS_ONCE(__rseq_abi.cpu_id);
}

/* 현재 mm_cid 빠른 읽기 — per-CPU 대신 조밀한 인덱스로 캐시 절약 */
static inline int rseq_current_mm_cid(void)
{
    return RSEQ_ACCESS_ONCE(__rseq_abi.mm_cid);
}

RSEQ_SIG — 아키텍처별 시그니처

RSEQ_SIG는 abort 핸들러 코드 직전에 삽입되는 4바이트 매직 워드입니다. 커널은 abort_ip - 4 위치의 값을 읽어 등록 시 전달한 RSEQ_SIG와 비교한 후 일치할 때만 IP를 재기록합니다. 이는 공격자가 임의의 코드 위치를 abort_ip로 가리키는 것을 방지하는 보안 장치입니다.

아키텍처RSEQ_SIG 값abort 직전 명령어 형태
x86_640x53053053nopl 0x53053053(%rax) (인코딩상 4바이트 상수)
ARM64 (AArch64)0xd428bc00BRK #0x45E0 (디버그 예외, 실행 시 trap)
RISC-V0xf1401073csrr x0, mhartid (비법적 CSR 접근, 모든 모드에서 예외)
PowerPC0x0fe5000btwui r5,11 (트랩 명령어)
/* x86_64 abort 핸들러 앞 시그니처 삽입 예 */
/* RSEQ_SIG = 0x53053053 */
    .byte 0x0f, 0x1f, 0x40, 0x00   /* nop DWORD PTR [rax+0x00] */
    .long 0x53053053                /* 매직 워드 (시그니처) */
rseq_abort:
    jmp   rseq_retry               /* 커널이 이 주소로 IP를 재기록 */

임계 구간 구현 패턴

설계 원칙

RSEQ 임계 구간은 세 가지 조건을 만족해야 합니다:

  1. 짧음 (Short) — 재시도 비용이 있으므로 가능한 한 짧게 유지합니다. 수십 나노초 이내가 이상적입니다.
  2. 멱등성 (Idempotent) — 부분 실행 후 재시도(Retry)해도 안전해야 합니다. 구간 내에서 외부에 관찰 가능한 부작용(Side Effect)이 없어야 합니다.
  3. 단일 커밋 포인트 (Single Commit) — 마지막 쓰기 하나가 커밋 포인트가 됩니다. 이 점프 이후에는 재시도 없이 정상 진행합니다.

예제 1 — per-CPU 카운터 증가 (C 인라인 asm)

가장 교과서적인 RSEQ 사용 예시: per-CPU 배열의 원소를 락 없이 증가시킵니다.

#include <linux/rseq.h>
#include <stdint.h>

#define NR_CPUS 256
static long per_cpu_counter[NR_CPUS] __attribute__((aligned(64)));

/* rseq_cs: 임계 구간 기술자 (static: 수명이 스레드보다 길어야 함) */
static struct rseq_cs __percpu_counter_cs
    __attribute__((section("__rseq_cs"), aligned(32)));

void percpu_counter_inc(void)
{
    int cpu;
    long *slot;

rseq_retry:
    cpu  = __rseq_abi.cpu_id_start;  /* 시작 CPU 스냅샷 */
    slot = &per_cpu_counter[cpu];

    /* rseq_cs 포인터 설정 — 이 쓰기 이후가 임계 구간 */
    WRITE_ONCE(__rseq_abi.rseq_cs,
               (uint64_t)(uintptr_t)&__percpu_counter_cs);

    asm volatile goto(
        /* --- 임계 구간 본문 (start_ip 레이블부터) --- */
        "1:\n\t"                   /* start_ip */
        "addq $1, %[slot]\n\t"    /* *slot += 1  (커밋 포인트) */
        "movq $0, %[rseq_cs]\n"  /* rseq_cs = 0 (구간 종료) */
        "2:\n\t"                   /* post_commit: start_ip + (2 - 1) */

        /* --- 시그니처 + abort 핸들러 --- */
        ".pushsection __rseq_failure, \"ax\"\n\t"
        ".byte 0x0f,0x1f,0x40,0x00\n\t" /* nop padding */
        ".long 0x53053053\n\t"          /* RSEQ_SIG (x86_64) */
        "3:\n\t"                        /* abort_ip */
        "jmp %l[rseq_abort]\n\t"
        ".popsection\n\t"

        /* rseq_cs 기술자 초기화 (링커가 자동 채움) */
        ".pushsection __rseq_cs, \"aw\"\n\t"
        ".long 0, 0\n\t"               /* version, flags */
        ".quad 1b, 2b-1b, 3b\n\t"     /* start_ip, post_commit_offset, abort_ip */
        ".popsection\n\t"

        : [rseq_cs] "=m" (__rseq_abi.rseq_cs)
        : [slot]    "m"  (*slot)
        : "memory"
        : rseq_abort
    );
    return;

rseq_abort:
    goto rseq_retry;
}

예제 2 — per-CPU 프리 리스트 pop (고수준 패턴)

TCMalloc이나 jemalloc의 스레드 캐시(Thread Cache)와 유사한 패턴: per-CPU 프리 리스트에서 원소를 하나 꺼냅니다.

struct freelist_head {
    void  *next;
    int    count;
};

static struct freelist_head per_cpu_freelist[NR_CPUS];

void *percpu_freelist_pop(void)
{
    int cpu;
    struct freelist_head *head;
    void *item, *next;
    int count;

retry:
    cpu   = __rseq_abi.cpu_id_start;
    head  = &per_cpu_freelist[cpu];
    item  = READ_ONCE(head->next);   /* 첫 원소 스냅샷 */
    count = READ_ONCE(head->count);

    if (!item || !count)
        return NULL;

    next = *((void **)item);           /* next 포인터 미리 읽기 */

    /* 임계 구간 시작 */
    WRITE_ONCE(__rseq_abi.rseq_cs, (uint64_t)(uintptr_t)&pop_cs);

    /* 커밋: head를 원자적(in-CPU)으로 갱신 */
    WRITE_ONCE(head->next,  next);
    WRITE_ONCE(head->count, count - 1); /* ← 이 줄이 커밋 포인트 */
    WRITE_ONCE(__rseq_abi.rseq_cs, 0);  /* 구간 종료 */

    return item;

abort:
    goto retry;
}
⚠️

멱등성 주의: 커밋 포인트 이후의 쓰기는 재시도 없이 진행됩니다. 따라서 단일 쓰기를 마지막 커밋으로 설계해야 합니다. 위 예제에서는 count 감소가 커밋 포인트입니다. 만약 next만 갱신하고 선점되면 재시도 시 next는 다시 덮어씌워지므로 안전합니다.

mm_cid — 동시 실행 ID와 캐시 지역성

per-CPU 배열의 희박성 문제

전통적인 per-CPU 접근에서는 CPU 번호를 배열 인덱스로 사용합니다. 8개 스레드 프로세스(Process)가 256코어 시스템에서 실행된다면, 8개 슬롯만 사용하면서 256 크기의 배열을 할당해야 합니다. 이는 캐시 공간을 낭비하고 NUMA 지역성을 해칩니다.

mm_cid(Memory-Map Concurrent ID)는 이 문제를 해결합니다. 같은 mm_struct(프로세스 주소 공간(Address Space))를 공유하는 스레드들에게 현재 동시 실행 수에 비례하는 조밀한 ID를 부여합니다. 8개 스레드면 0~7만 사용되고, 배열 크기도 8로 줄어듭니다.

per-CPU 배열(CPU 번호 기준) vs mm_cid 배열 캐시 효율 비교 전통적 per-CPU 배열 (희박) 크기: NR_CPUS = 256, 사용: 8 슬롯 [0] CPU0 ← 사용 중 [1] 미사용 [2] 미사용 ... (빈 슬롯 247개) ... [CPU 43] CPU43 ← 사용 중 [CPU 44..] 미사용 캐시 라인 낭비 → false sharing 위험↑ mm_cid 배열 (조밀) 크기: 동시 실행 스레드 수 (예: 8) [0] 스레드 A ← 활성 [1] 스레드 B ← 활성 [2] 스레드 C ← 활성 ... (스레드 수만큼만) ... [7] 스레드 H ← 활성 캐시 라인 효율 ↑ → TCMalloc 수배~십여배 향상

mm_cid 활용 패턴

/* mm_cid 기반 per-스레드 슬롯 접근 (TCMalloc 스타일) */
struct thread_cache_slot *get_my_slot(void)
{
    uint32_t cid;
    struct thread_cache_slot *slot;

retry:
    cid  = __rseq_abi.mm_cid;         /* 임계 구간 진입 전 mm_cid 스냅샷 */

    /* mm_cid 범위 검증 (동적으로 변경될 수 있음) */
    if (unlikely(cid >= max_cid))
        goto slow_path;

    slot = &thread_cache[cid];

    /* 임계 구간: 해당 슬롯의 데이터 읽기/쓰기 */
    WRITE_ONCE(__rseq_abi.rseq_cs, (uint64_t)(uintptr_t)&slot_cs);
    /* ... 슬롯 갱신 ... */
    WRITE_ONCE(__rseq_abi.rseq_cs, 0);

    return slot;

abort:
    goto retry;

slow_path:
    return slow_path_alloc_slot();
}
ℹ️

mm_cid 재사용 최적화 (Linux 6.3+): 간헐적으로 실행되는 스레드가 잠깐 블록되었다가 돌아올 때 이전에 사용하던 mm_cid를 가능하면 재할당합니다. 이 최적화로 캐시 라인(Cache Line)에 담긴 스레드 로컬 데이터가 여전히 따뜻한(warm) 상태로 유지될 가능성이 높아집니다. TCMalloc 벤치마크에서 최대 16.7배 처리량(Throughput) 향상이 보고되었습니다.

타임슬라이스 연장 (Linux 7.0)

Linux 7.0에서 RSEQ의 두 번째 주요 기능이 병합되었습니다: RSEQ Time Slice Extension(타임슬라이스 연장)입니다. 약 10년간 개발된 이 기능은 사용자 공간 스레드가 임계 구간 진입 시 스케줄러(Scheduler)에게 "지금 선점하지 말라"는 힌트를 전달할 수 있게 합니다.

도입 배경

RSEQ의 재시도(Retry) 방식은 대부분의 경우 잘 동작하지만, 타임슬라이스(Time Slice) 만료 직전의 선점에 취약합니다. 스핀락(Spinlock) 집약적인 워크로드(예: PostgreSQL 버퍼(Buffer) 풀)는 임계 구간이 선점될 확률이 낮지 않으며, 재시도가 반복되면 오히려 원자 연산보다 느려질 수 있습니다. Linux 7.0에서 PREEMPT_LAZY 모델이 기본화되면서 이 문제가 더 부각되었습니다.

동작 방식

RSEQ Time Slice Extension을 활성화하면, 스케줄러가 해당 태스크를 선점하려 할 때 rseq_cs가 설정되어 있으면 타임슬라이스를 짧게 연장합니다. 기본 연장값은 5µs, 최대 50µs입니다. RT(Real-Time) 또는 DEADLINE 클래스의 더 높은 우선순위(Priority) 태스크가 대기 중이라면 연장 없이 즉시 선점됩니다.

/* Linux 7.0+: struct rseq_slice_ctrl 구조체 */
struct rseq_slice_ctrl {
    union {
        __u32 all;
        struct {
            __u8  request;    /* 사용자 설정: 1이면 임계 구간 선점 연장 요청 */
            __u8  granted;    /* 커널 설정: 1이면 스케줄러가 연장 승인 */
            __u16 __reserved;
        };
    };
};
/* 타임슬라이스 연장 활성화 (prctl로 태스크에 적용) */
#include <sys/prctl.h>
#include <linux/prctl.h>

/* PR_RSEQ_SLICE_EXTENSION = 79: 타임슬라이스 연장 기능 활성화 */
if (prctl(PR_RSEQ_SLICE_EXTENSION,
          PR_RSEQ_SLICE_EXTENSION_SET,
          PR_RSEQ_SLICE_EXT_ENABLE, 0, 0) < 0) {
    /* 커널 미지원 (Linux 7.0 미만) 또는 권한 부족 */
    perror("prctl PR_RSEQ_SLICE_EXTENSION");
}

/* 임계 구간 진입 시 연장 요청: request=1 설정 후 rseq_cs 등록 */
WRITE_ONCE(__rseq_abi.slice_ctrl.request, 1);
WRITE_ONCE(__rseq_abi.rseq_cs, (uint64_t)(uintptr_t)&my_cs);
/* ... 임계 구간 ... */
WRITE_ONCE(__rseq_abi.slice_ctrl.request, 0);
WRITE_ONCE(__rseq_abi.rseq_cs, 0);
⚠️

참고: PR_RSEQ_SLICE_EXTENSION = 79include/uapi/linux/prctl.h에 정의된 확정 값입니다. PR_RSEQ_SLICE_EXTENSION_GET/SETPR_RSEQ_SLICE_EXT_ENABLE 서브 상수도 동일 파일에서 확인할 수 있습니다. slice_ctrl.granted는 커널이 연장을 실제로 승인했는지 확인할 때 사용합니다.

Linux 7.0부터: RSEQ Time Slice Extension 병합. 기본 확장값 5µs, 최대 50µs. 이 값을 크게 설정하면 시스템 전체의 최소 스케줄링 지연(minimum scheduling latency)에 영향을 줄 수 있으므로 측정 후 조정하십시오.

실전 사용 사례

TCMalloc (Google)

TCMalloc은 각 스레드마다 전용 프리 리스트(Thread Cache)를 유지합니다. 기존에는 __thread TLS로 구현했으나, RSEQ + mm_cid로 전환하면서 캐시 풋프린트가 동시 실행 스레드 수에 비례하도록 줄어들었습니다. CPU 선점이 발생해도 재시도만 하면 되므로 disable_preempt() 없이도 안전합니다.

jemalloc

jemalloc 5.3+에서 RSEQ 기반 Thread Cache를 실험적으로 지원합니다. tcache flush 경로에서 per-CPU 빈 리스트를 관리할 때 spinlock 대신 RSEQ 임계 구간을 사용하여 멀티소켓(Multi-Socket) 시스템에서의 락 경합을 없앴습니다.

liburcu (Userspace RCU)

Userspace RCU 라이브러리는 QSBR(Quiescent-State Based Reclamation) 모드에서 RSEQ로 구현된 per-CPU 콜백(Callback) 큐를 사용합니다. RSEQ 덕분에 콜백 enqueue가 원자 연산 없이 수행되며, 이는 고빈도 RCU 갱신이 필요한 네트워크 패킷(Packet) 처리 등에서 유용합니다.

glibc pthread 내부

glibc 2.35 이후 pthread_mutex_lock()의 fast path에서 RSEQ를 활용합니다. 잠금 해제된 뮤텍스(Mutex)를 획득하는 경로가 완전한 락 없이 처리됩니다. 또한 malloc() 내부의 arena 선택 로직도 RSEQ를 활용해 arena 인덱스 계산 시 스레드 마이그레이션을 감지합니다.

흔한 실수 (Common Pitfalls)

실수 1 — rseq_cs 구조체 수명 관리

struct rseq_cs는 반드시 사용 중인 임계 구간보다 수명이 길어야 합니다. 스택에 배치하면 함수 반환 후 커널이 해제된 메모리를 역참조(Dereference)하여 SIGSEGV가 발생합니다.

/* ❌ 잘못된 예: 스택에 rseq_cs 배치 */
void bad_example(void)
{
    struct rseq_cs cs = { ... };   /* 스택 할당 — 위험! */
    WRITE_ONCE(__rseq_abi.rseq_cs, (uint64_t)&cs);
    /* 선점 발생 시 커널이 cs에 접근 — 함수 반환 후라면 UAF */
}

/* ✅ 올바른 예: 정적 또는 TLS에 배치 */
static struct rseq_cs my_cs
    __attribute__((section("__rseq_cs"), aligned(32))) = {
    .version           = 0,
    .flags             = 0,
    .start_ip          = (uint64_t)&critical_start,
    .post_commit_offset = (uint64_t)(&critical_end - &critical_start),
    .abort_ip          = (uint64_t)&critical_abort,
};

실수 2 — 시그널 핸들러 내부의 RSEQ

시그널 핸들러는 임계 구간 도중에도 실행될 수 있습니다. 커널은 시그널 전달 전 rseq_handle_notify_resume()을 호출하므로, 핸들러 진입 시점에는 임계 구간이 이미 중단된 상태(rseq_cs = 0)입니다. 시그널 핸들러 안에서 새로운 RSEQ 임계 구간을 시작하는 것은 이론적으로 가능하지만, SA_NODEFER와 재귀 문제를 함께 고려해야 합니다.

/* ⚠️ 시그널 핸들러에서 RSEQ 사용 시 주의사항 */
void sighandler(int sig)
{
    /* 이 시점에서 __rseq_abi.rseq_cs는 이미 0 (커널이 클리어함) */
    /* 새 임계 구간 시작은 가능하나, 핸들러 반환 후 원래 임계 구간은 abort로 점프 */

    /* sigreturn 이후 커널이 다시 rseq_handle_notify_resume()를 호출 */
    /* 이때 rseq_cs가 0이면 abort 점프 없이 cpu_id만 갱신 */
}

실수 3 — RSEQ_SIG 위치 오류

RSEQ_SIG는 abort_ip - 4(또는 아키텍처마다 다른 오프셋) 위치에 정확히 배치해야 합니다. 오프셋이 틀리면 커널이 시그니처 검증에 실패하고 프로세스에 SIGSEGV를 보냅니다. 특히 어셈블리 코드를 수동으로 작성할 때 패딩(Padding) 바이트 수를 잘못 계산하는 경우가 많습니다.

실수 4 — rseq_cs 쓰기와 임계 구간 사이의 메모리 순서

rseq_cs 포인터 쓰기와 첫 번째 임계 구간 명령어 사이에 smp_mb() 또는 barrier()를 삽입해야 합니다. 컴파일러 또는 CPU가 rseq_cs 설정 이전에 임계 구간 내부의 로드를 이동시키면 올바른 보호가 이루어지지 않습니다. 인라인 어셈블리를 사용하는 경우 "memory" 클로버(clobber)가 이를 방지합니다.

/* ❌ 위험: 컴파일러가 순서를 재배열할 수 있음 */
__rseq_abi.rseq_cs = (uint64_t)&my_cs;
*slot = value;   /* rseq_cs 쓰기 전으로 이동될 수 있음 */

/* ✅ 안전: WRITE_ONCE + asm volatile("" ::: "memory") */
WRITE_ONCE(__rseq_abi.rseq_cs, (uint64_t)&my_cs);
asm volatile("" ::: "memory");   /* 컴파일러 배리어 */
WRITE_ONCE(*slot, value);

실수 5 — 중첩 임계 구간

RSEQ는 중첩 임계 구간을 지원하지 않습니다. 하나의 임계 구간이 활성화된 상태에서 rseq_cs를 다시 쓰면 이전 구간 정보가 사라집니다. 라이브러리 코드에서 RSEQ를 사용한다면 호출자의 임계 구간과 겹치지 않도록 설계해야 합니다.

대안 기술 비교

기법정상 경로 비용CPU 마이그레이션슬립 허용주요 제약
RSEQ 메모리 쓰기 + rseq_cs 설정/해제 자동 재시도 불가 (임계 구간 내) 임계 구간은 짧고 멱등적이어야 함
this_cpu_add() 메모리 쓰기 (preempt 비활성 시) preempt_disable 필요 불가 커널 공간(Kernel Space) 전용, 인터럽트(Interrupt) 컨텍스트 제약
atomic_add() 원자 명령어 1회 (lock prefix 등) 자동 직렬화 가능 버스(Bus) 잠금(Bus Lock) 비용, NUMA 캐시 바운싱
seqcount 쓰기: seqcount 증가 2회 독자가 재시도 가능 (독자) 단일 저자(writer) 전제
spinlock 락 획득/해제 (원자 명령어) 자동 직렬화 불가 busy-wait, 선점 불가
mutex fast path: 원자 명령어 1회 자동 직렬화 가능 (슬립) 슬립 가능 컨텍스트 전용, 높은 비용
💡

언제 RSEQ를 선택할까요? per-CPU 자료구조 갱신 빈도가 매우 높고, 임계 구간이 10~50 명령어 이내로 짧으며, 재시도 시 부작용(Side Effect)이 없는 경우에 최적입니다. 할당자 fast path, 카운터/히스토그램 갱신, 링 버퍼(Ring Buffer) enqueue가 대표적인 적용처입니다.

디버깅(Debugging)

strace로 등록 흐름 추적

# rseq 등록/해제 이벤트만 추적
strace -e trace=rseq ./my_program

# 출력 예:
# rseq(0x7f3a1c0b3680, 0x20, 0, 0x53053053) = 0   (등록 성공)
# rseq(0x7f3a1c0b3680, 0x20, 1, 0x53053053) = 0   (해제, flag=1=UNREGISTER)

bpftrace 트레이스포인트

# abort_ip 점프 이벤트 관측 (임계 구간 중단 빈도 측정)
bpftrace -e 'tracepoint:rseq:rseq_ip_fixup {
    @abort_count[comm] = count();
    @abort_ip[comm]    = hist(args->ip);
}'

# cpu_id 갱신 빈도 (CPU 마이그레이션 관측)
bpftrace -e 'tracepoint:rseq:rseq_update {
    @update_count[comm] = count();
}'

perf stat 성능 카운터

# RSEQ abort 이벤트 수 측정
perf stat -e 'syscalls:sys_enter_rseq' -p <pid>

# rseq tracepoint 기반 이벤트 집계
perf stat -e 'rseq:rseq_ip_fixup,rseq:rseq_update' ./my_program

procfs / sys 관찰

# 스레드의 rseq 등록 상태 확인 (/proc에 직접 노출되지 않음)
# 대신 /proc/<pid>/status에서 CPU 정보 참고
cat /proc/<pid>/status | grep -i cpu

# 커널 빌드 시 CONFIG_RSEQ 활성화 여부
grep CONFIG_RSEQ /boot/config-$(uname -r)
# CONFIG_RSEQ=y  (활성화 확인)

# CRIU와 함께 사용 시: rseq 상태를 ptrace로 덤프/복원
# /proc/<pid>/mem과 ptrace(PTRACE_GET_RSEQ_CONFIGURATION)로 접근

커널 셀프테스트

# Linux 커널 소스 트리의 RSEQ 셀프테스트
cd tools/testing/selftests/rseq
make
./rseq_test              # 기본 등록/임계 구간 테스트
./basic_percpu_ops_test  # per-CPU 연산 패턴 테스트
./param_test             # 파라미터 변형 테스트

버전 이력

커널 버전날짜주요 변경 사항
4.18 2018-08 RSEQ 최초 도입. rseq(2) 시스템 콜, struct rseq/struct rseq_cs UAPI, rseq_handle_notify_resume() 기본 구현. x86_64, ARM64, PowerPC 지원.
5.x 2019~2022 RISC-V, MIPS, s390 아키텍처 지원 추가. 시그널 처리 경로 안정화.
6.2 2023-02 mm_cidnode_id 필드 도입. 동시 실행 ID 기반 캐시 지역성 최적화 시작 및 NUMA 인식 개선.
6.7 2024-01 간헐적 워크로드에서 mm_cid 재사용 최적화. TCMalloc 벤치마크 최대 16.7배 향상.
6.19 2025-05 exit_to_user_mode_loop()에서 불필요한 rseq_handle_notify_resume() 호출 제거. glibc가 모든 스레드에 rseq를 등록하면서 발생하던 공용 경로 오버헤드(Overhead) 감소. 고빈도 syscall 워크로드에서 측정 가능한 성능 향상.
7.0 2026-04 RSEQ Time Slice Extension 병합. 임계 구간 진입 시 스케줄러에 선점 연장 힌트 전달 가능. 기본 연장값 5µs, 최대 50µs. PostgreSQL 스핀락 집약 워크로드의 PREEMPT_LAZY 성능 저하 해결책으로 제안됨.

참고자료