RSEQ (Restartable Sequences)
Linux 4.18에서 도입된 RSEQ(Restartable Sequences)는 사용자 공간(User Space) 스레드(Thread)가 글로벌 락이나 원자 명령어(Atomic Instruction) 없이 per-CPU 자료구조를 안전하게 갱신하는 메커니즘입니다. 선점(Preemption)이나 CPU 마이그레이션(Migration)이 임계 구간(Critical Section) 내에서 발생하면 커널이 사용자 복귀 직전에 중단 지점(abort IP)으로 명령어 포인터를 강제로 점프시켜 구간을 처음부터 재실행하게 합니다.
핵심 요약
- 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+)
단계별 이해
- 등록
rseq(2)시스템 콜로 TLS에 배치한struct rseq의 주소와RSEQ_SIG를 커널에 등록합니다. glibc 2.35+는 스레드 생성 시 자동으로 처리합니다. - 임계 구간 진입·실행
임계 구간 직전에rseq_cs포인터를 설정합니다. 구간 내 코드를 실행하고, 마지막 커밋 명령어까지 완료하면rseq_cs를 0으로 지워 구간 종료를 알립니다. - 중단 시 재시도(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 필드, 커널 복귀 경로 훅.
중단·재시도 흐름
- 사용자 스레드가 임계 구간에 진입하기 직전,
__rseq_abi.rseq_cs를 현재 구간의 기술자(struct rseq_cs)로 설정합니다. __rseq_abi.cpu_id_start에서 현재 CPU 번호를 읽어 어느 CPU에서 실행 중인지 기록합니다.- 임계 구간 본문을 실행합니다 (일반 메모리 읽기/쓰기).
- 임계 구간 마지막 명령(커밋 포인트)을 실행한 뒤
rseq_cs를 0으로 지웁니다. - 선점, 시그널, CPU 마이그레이션이 발생하면 커널은 유저 공간으로 복귀하기 전에
rseq_handle_notify_resume()을 호출합니다. - 이 함수는 현재 IP가
[start_ip, start_ip + post_commit_offset)범위 안에 있는지 확인하고, 맞으면 IP를abort_ip로 강제 재기록합니다. - 사용자 공간으로 복귀하면 abort 핸들러(Handler) 코드가 실행되어 임계 구간을 처음부터 재시도합니다.
흐름 다이어그램
rseq_cs: 임계 구간 기술자
struct rseq_cs는 임계 구간의 범위와 중단 시 점프 대상을 기술하는 읽기 전용 구조체입니다. 사용자 공간에서 정적(static) 또는 TLS(Thread Local Storage)에 배치하며, 임계 구간 실행 전에 struct rseq의 rseq_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_id와 mm_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()이 호출됩니다. 이 함수는 다음을 수행합니다:
task_struct::rseq가 NULL이면 즉시 반환합니다.- 사용자 공간에서
rseq_cs포인터를 읽습니다(0이면 반환). rseq_cs.start_ip와post_commit_offset으로 구간 범위를 계산합니다.- 저장된 명령어 포인터(pt_regs::ip)가 해당 범위 안에 있으면
abort_ip로 재기록합니다. cpu_id,node_id,mm_cid를 현재 값으로 갱신합니다.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_start나 mm_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_64 | 0x53053053 | nopl 0x53053053(%rax) (인코딩상 4바이트 상수) |
| ARM64 (AArch64) | 0xd428bc00 | BRK #0x45E0 (디버그 예외, 실행 시 trap) |
| RISC-V | 0xf1401073 | csrr x0, mhartid (비법적 CSR 접근, 모든 모드에서 예외) |
| PowerPC | 0x0fe5000b | twui 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 임계 구간은 세 가지 조건을 만족해야 합니다:
- 짧음 (Short) — 재시도 비용이 있으므로 가능한 한 짧게 유지합니다. 수십 나노초 이내가 이상적입니다.
- 멱등성 (Idempotent) — 부분 실행 후 재시도(Retry)해도 안전해야 합니다. 구간 내에서 외부에 관찰 가능한 부작용(Side Effect)이 없어야 합니다.
- 단일 커밋 포인트 (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로 줄어듭니다.
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 = 79는 include/uapi/linux/prctl.h에 정의된 확정 값입니다. PR_RSEQ_SLICE_EXTENSION_GET/SET과 PR_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_cid 및 node_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 성능 저하 해결책으로 제안됨. |
참고자료
- docs.kernel.org — Restartable sequences — 커널 공식 RSEQ 문서. 설계 철학, UAPI 구조, 아키텍처별 시그니처를 정리합니다.
- kernel/rseq.c — Bootlin Elixir — RSEQ 핵심 구현 소스.
rseq_handle_notify_resume()과sys_rseq()구현을 포함합니다. - include/uapi/linux/rseq.h — Bootlin Elixir —
struct rseq와struct rseq_csUAPI 정의 원본입니다. - tools/testing/selftests/rseq/ — Bootlin Elixir — 커널 공식 RSEQ 셀프테스트. 아키텍처별 어셈블리 패턴 참조에 유용합니다.
- LWN: Restartable sequences — RSEQ 최초 설계 제안 (2015). 설계 동기와 원자 연산 대비 이점을 설명합니다.
- LWN: Restartable sequences land in 4.18 — Linux 4.18 병합 시점의 LWN 기사. 구현 세부사항과 초기 사용 사례를 다룹니다.
- LWN: Concurrent-ID memory mapping — mm_cid 도입 배경과 TCMalloc 성능 향상 분석을 다루는 기사입니다.
- LWN: Scheduler time slice extension — RSEQ 기반 타임슬라이스 연장 메커니즘의 설계와 구현을 설명하는 기사입니다.
- git: rseq: Initial implementation — RSEQ 최초 병합 커밋. Mathieu Desnoyers 작성.
- GitHub: librseq — Mathieu Desnoyers(RSEQ 제안자)의 사용자 공간 RSEQ 헬퍼 라이브러리. 아키텍처별 어셈블리 매크로(Macro)를 포함합니다.
- EfficiOS: Linux Restartable Sequences — RSEQ 설계자의 상세 블로그 포스트. per-CPU 알고리즘 구현 방법론을 심층적으로 다룹니다.