RCU (Read-Copy-Update)
Linux 커널의 RCU(Read-Copy-Update)를 대상으로 read-side 임계구역, 포인터 발행 규칙(rcu_assign_pointer/rcu_dereference), grace period, call_rcu/kfree_rcu 기반 회수 모델을 코드 흐름 중심으로 설명합니다. 또한 Tree RCU와 SRCU의 선택 기준, 흔한 오류(UAF/RCU stall), 운영 환경에서의 디버깅 절차까지 심층적으로 다룹니다.
핵심 요약
- 핵심 순서 — Copy 후
rcu_assign_pointer(), 이후 grace period 대기, 마지막 해제가 기본 패턴입니다. - 읽기 측 규칙 —
rcu_read_lock()구간에서rcu_dereference()로 접근해야 합니다. - 해제 규칙 — 즉시
kfree()대신call_rcu()/kfree_rcu()를 사용합니다. - 변형 선택 — read-side에서 슬립이 필요하면 SRCU를 사용합니다.
- 운영 리스크 — RCU stall과 모듈 언로드 시 콜백 잔존 문제를 반드시 점검합니다.
단계별 이해
- 읽기/쓰기 책임 분리
reader는 빠른 조회, writer는 포인터 교체와 회수를 담당한다는 구조를 먼저 고정합니다. - 포인터 발행 규칙 학습
rcu_assign_pointer()와rcu_dereference()쌍을 먼저 익힙니다. - 회수 타이밍 검증
grace period 완료 전에 메모리를 해제하지 않는지 체크합니다. - 디버깅 루틴 확보
stall 로그와/sys/kernel/debug/rcu정보를 함께 보는 절차를 마련합니다.
RCU 개요
RCU란? 도서관 도서 교체에 비유
일상 비유: 도서관에서 인기 있는 백과사전의 새 버전이 나왔습니다. 사서는 어떻게 해야 할까요?
-
옛날 방식 (Lock): 도서관 문을 잠그고(lock), 모든 사람을 내보낸 뒤,
책을 교체하고, 다시 문을 연다(unlock).
❌ 문제: 책 1권 교체하는 동안 도서관 전체가 폐쇄됨 (읽기도 막힘)
-
RCU 방식 (Read-Copy-Update):
1. 새 책을 옆 선반에 조용히 놓는다 (Copy)
2. 표지판을 "새 책은 옆 선반에 있음"으로 바꾼다 (Update)
3. 현재 옛날 책을 읽고 있는 사람들이 다 나갈 때까지 기다린다 (Grace Period)
4. 모두 나가면 옛날 책을 치운다 (kfree_rcu)✅ 장점: 독자들은 기다릴 필요 없이 계속 읽을 수 있음!
3가지 핵심 개념:
| 개념 | 의미 | API |
|---|---|---|
| Read | 잠금 없이 읽기 (매우 빠름) | rcu_read_lock() / rcu_read_unlock() |
| Copy | 원본을 복사하고 수정 | kmalloc() + 데이터 복사 |
| Update | 포인터를 새 데이터로 교체 | rcu_assign_pointer() |
| Grace Period | 모든 독자가 읽기를 끝낼 때까지 대기 | synchronize_rcu() / call_rcu() |
언제 RCU를 사용하나요?
- 읽기가 90% 이상 (쓰기는 드묾)
- 데이터가 포인터로 참조됨
- 읽기가 매우 빨라야 함
- 예: 라우팅 테이블, 프로세스 리스트
- 읽기와 쓰기가 비슷한 비율
- 데이터가 크고 복사 비용이 높음
- 즉시 메모리 해제가 필요
- → spinlock이나 mutex 사용
RCU(Read-Copy-Update)는 읽기 작업이 대부분인 자료구조에 최적화된 동기화 메커니즘입니다. reader는 잠금 없이 자료구조에 접근하고, writer는 데이터의 복사본을 수정한 뒤 포인터를 원자적으로 교체합니다. 이전 데이터는 모든 reader가 종료한 후(grace period) 안전하게 해제됩니다.
RCU 메모리 순서 보장
RCU의 정확성은 메모리 순서(memory ordering) 보장에 의존합니다. Writer가 새 데이터를 발행하고 Reader가 이를 참조할 때, CPU와 컴파일러의 재배치로 인해 불완전한 데이터가 보일 수 있습니다. RCU는 rcu_assign_pointer()와 rcu_dereference()라는 두 API로 이 문제를 해결합니다.
LKMM 연계: Linux Kernel Memory Model(LKMM)은 RCU의 메모리 순서 보장을 공식적으로 정의합니다. rcu_assign_pointer()는 LKMM의 release 의미론에, rcu_dereference()는 address dependency(C11의 consume에 해당)에 매핑됩니다. 자세한 내용은 tools/memory-model/ 디렉토리를 참고하세요.
Writer 측: store-release 의미론
rcu_assign_pointer(p, v)는 내부적으로 smp_store_release()를 사용합니다. 이는 포인터 저장 이전의 모든 메모리 쓰기가 포인터 저장보다 먼저 다른 CPU에 보이도록 보장합니다.
/* include/linux/rcupdate.h — rcu_assign_pointer 내부 */
#define rcu_assign_pointer(p, v) \
do { \
smp_store_release(&(p), (typeof(p))(v)); \
} while (0)
/* 즉, Writer 코드에서: */
new_entry->field_a = 42; /* ① 데이터 초기화 */
new_entry->field_b = "hello"; /* ② 데이터 초기화 */
rcu_assign_pointer(gptr, new_entry); /* ③ store-release: ①②가 반드시 ③ 전에 보임 */
Reader 측: address dependency 보장
rcu_dereference(p)는 포인터를 읽은 후 그 포인터를 통한 후속 접근이 재배치되지 않도록 보장합니다. 이는 하드웨어 address dependency와 컴파일러 배리어를 결합하여 구현됩니다.
/* include/linux/rcupdate.h — rcu_dereference 내부 */
#define rcu_dereference(p) \
rcu_dereference_check(p, 0)
/* → READ_ONCE(p) + 컴파일러 배리어
* READ_ONCE는 volatile 시맨틱으로 컴파일러 최적화(값 캐싱) 방지
* 하드웨어 address dependency는 대부분 아키텍처에서 자동 보장 */
/* Reader 코드에서: */
rcu_read_lock();
p = rcu_dereference(gptr); /* ① 포인터 읽기 (address dependency 시작) */
if (p) {
x = p->field_a; /* ② dependency chain: ①의 값에 의존 → 재배치 불가 */
y = p->field_b; /* ③ 마찬가지로 ① 이후 보장 */
}
rcu_read_unlock();
아키텍처별 차이
| 아키텍처 | rcu_assign_pointer() 구현 | rcu_dereference() 구현 | 비고 |
|---|---|---|---|
| x86/x86_64 | 컴파일러 배리어만 (TSO 보장) | 컴파일러 배리어만 | TSO(Total Store Order)로 store-release가 자연 보장 |
| ARM64 | stlr (store-release 명령) | address dependency + 컴파일러 배리어 | weak ordering이므로 명시적 release 필요 |
| ARM32 | dmb (데이터 메모리 배리어) | address dependency + 컴파일러 배리어 | full barrier 사용 (store-release 명령 없음) |
| PowerPC | lwsync (lightweight sync) | address dependency + 컴파일러 배리어 | weak ordering, dependency ordering은 하드웨어 보장 |
| RISC-V | fence rw,w | address dependency + 컴파일러 배리어 | RVWMO 모델, release fence 사용 |
배리어 비교: RCU vs 범용 배리어
| 메커니즘 | Writer 비용 | Reader 비용 | 보장 범위 | 용도 |
|---|---|---|---|---|
rcu_assign_pointer() + rcu_dereference() | store-release (경량) | address dependency (거의 0) | 포인터와 포인터가 가리키는 데이터 간 순서 | RCU 포인터 발행/참조 |
smp_store_release() + smp_load_acquire() | store-release | load-acquire | release 이전 모든 쓰기 ↔ acquire 이후 모든 읽기 | 범용 단방향 배리어 쌍 |
smp_wmb() + smp_rmb() | write barrier | read barrier | 쓰기 순서 / 읽기 순서 각각 보장 | 전통적 배리어 쌍 |
smp_mb() | full barrier | full barrier | 모든 방향 순서 보장 | 최후 수단 (비용 큼) |
흔한 실수: rcu_dereference() 없이 RCU 보호 포인터를 직접 읽으면(p = gptr;), 컴파일러가 값을 레지스터에 캐싱하거나 접근 순서를 변경할 수 있습니다. 이는 CONFIG_PROVE_RCU가 켜져 있으면 lockdep 경고로 잡히지만, 꺼져 있으면 간헐적 데이터 손상으로 나타나 디버깅이 매우 어렵습니다.
RCU 핵심 API
#include <linux/rcupdate.h>
/* === Reader Side === */
rcu_read_lock(); /* RCU read-side critical section 시작 */
p = rcu_dereference(gbl_ptr); /* 포인터 읽기 (memory barrier 포함) */
if (p)
do_something(p->field);
rcu_read_unlock(); /* RCU read-side critical section 종료 */
/* === Writer Side === */
struct my_data *old, *new;
new = kmalloc(sizeof(*new), GFP_KERNEL);
*new = *old; /* 복사 */
new->field = new_value; /* 수정 */
rcu_assign_pointer(gbl_ptr, new); /* 포인터 원자적 교체 */
synchronize_rcu(); /* grace period 대기 (블로킹) */
kfree(old); /* 이전 데이터 해제 */
/* 또는 비동기 해제 */
kfree_rcu(old, rcu_head); /* grace period 후 자동 해제 */
Read-Side API 상세
RCU read-side critical section은 rcu_read_lock()과 rcu_read_unlock() 쌍으로 구성됩니다. 내부 구현은 커널 선점 설정에 따라 다릅니다.
rcu_read_lock() 내부 동작
/* include/linux/rcupdate.h */
/* non-preemptible 커널 (CONFIG_PREEMPT_COUNT=n):
* rcu_read_lock()은 컴파일러 배리어(barrier())만 삽입.
* 선점이 불가하므로 컨텍스트 스위치 자체가 QS 신호가 됨.
*/
static inline void rcu_read_lock(void)
{
__rcu_read_lock(); /* non-preempt: preempt_disable() */
__acquire(RCU); /* sparse 어노테이션 */
rcu_lock_acquire(&rcu_lock_map); /* lockdep */
RCU_LOCKDEP_WARN(!rcu_is_watching(),
"rcu_read_lock() used illegally while idle");
}
/* preemptible 커널 (CONFIG_PREEMPT_RCU=y):
* per-task nesting 카운터를 증감.
* 선점되어도 RCU read-side를 추적할 수 있음.
*/
void __rcu_read_lock(void)
{
current->rcu_read_lock_nesting++;
if (IS_ENABLED(CONFIG_PROVE_RCU))
WARN_ON_ONCE(current->rcu_read_lock_nesting > RCU_NEST_MAX);
barrier(); /* 컴파일러 재배치 방지 */
}
rcu_read_lock_bh() / rcu_read_unlock_bh()
Bottom-half(softirq)를 비활성화하는 RCU read-side 진입점입니다. 최신 커널(v5.0+)에서는 RCU-bh가 통합 RCU로 합쳐졌지만, API 자체는 하위 호환성을 위해 유지됩니다.
/* rcu_read_lock_bh(): softirq 비활성화 + RCU read-side 진입 */
static inline void rcu_read_lock_bh(void)
{
local_bh_disable();
__acquire(RCU_BH);
rcu_lock_acquire(&rcu_bh_lock_map);
}
/* rcu_read_unlock_bh(): softirq 재활성화 + RCU read-side 퇴장 */
static inline void rcu_read_unlock_bh(void)
{
rcu_lock_release(&rcu_bh_lock_map);
__release(RCU_BH);
local_bh_enable();
}
/* 사용 예: 네트워크 패킷 수신 경로 */
rcu_read_lock_bh();
hlist_for_each_entry_rcu(sk, &udp_table->hash[slot], node) {
if (udp_match(sk, saddr, daddr, sport, dport))
udp_queue_rcv_skb(sk, skb);
}
rcu_read_unlock_bh();
rcu_read_lock_sched() / rcu_read_unlock_sched()
선점을 비활성화하여 RCU-sched 보호를 제공합니다. 최신 커널에서는 rcu_read_lock()과 동일한 효과이나, 명시적으로 선점 비활성화 의미를 전달할 때 사용합니다.
static inline void rcu_read_lock_sched(void)
{
preempt_disable();
__acquire(RCU_SCHED);
rcu_lock_acquire(&rcu_sched_lock_map);
}
static inline void rcu_read_unlock_sched(void)
{
rcu_lock_release(&rcu_sched_lock_map);
__release(RCU_SCHED);
preempt_enable();
}
rcu_read_lock_held() 패밀리
현재 컨텍스트가 올바른 RCU read-side critical section 내부에 있는지 검증하는 함수들입니다. CONFIG_PROVE_RCU=y 빌드에서 lockdep 검증에 사용됩니다.
| 함수 | 검증 대상 | 반환값 |
|---|---|---|
rcu_read_lock_held() | 일반 RCU read-side | 1: 보유 중, 0: 미보유 |
rcu_read_lock_bh_held() | RCU-bh read-side (또는 softirq 비활성) | 1: 보유 중, 0: 미보유 |
rcu_read_lock_sched_held() | RCU-sched read-side (또는 선점 비활성) | 1: 보유 중, 0: 미보유 |
rcu_read_lock_any_held() | 위 3가지 중 하나라도 보유 | 1: 하나 이상 보유, 0: 전부 미보유 |
rcu_read_lock_trace_held() | RCU-Tasks-Trace read-side | 1: 보유 중, 0: 미보유 |
srcu_read_lock_held(sp) | 특정 SRCU 도메인 read-side | 1: 보유 중, 0: 미보유 |
/* rcu_dereference_check()와 결합한 사용 예 */
p = rcu_dereference_check(gbl_ptr,
rcu_read_lock_held() ||
lockdep_is_held(&my_mutex));
/* RCU_LOCKDEP_WARN: 조건이 참이면 경고 출력 */
RCU_LOCKDEP_WARN(!rcu_read_lock_held(),
"RCU read lock not held");
guard(rcu) / scoped_guard(rcu)
C scope 기반 자동 언락 패턴입니다. 블록을 벗어나면 자동으로 rcu_read_unlock()이 호출됩니다 (Linux 6.5+).
#include <linux/cleanup.h>
/* guard(rcu): 현재 scope 종료 시 자동 rcu_read_unlock() */
{
guard(rcu)();
p = rcu_dereference(gbl_ptr);
if (!p)
return -ENOENT; /* 자동으로 rcu_read_unlock() 호출됨 */
val = p->field;
} /* ← 여기서도 자동 rcu_read_unlock() */
/* scoped_guard(rcu): 블록 내에서만 RCU 보호 */
scoped_guard(rcu) {
p = rcu_dereference(gbl_ptr);
process(p);
} /* ← rcu_read_unlock() 자동 */
/* guard(rcu)는 rcu_read_lock_bh, rcu_read_lock_sched에도 적용 가능 */
guard(rcu_bh)(); /* rcu_read_lock_bh() + 자동 unlock */
guard(rcu_sched)(); /* rcu_read_lock_sched() + 자동 unlock */
guard(rcu) 장점: 조기 리턴(return), goto, 예외 경로에서 rcu_read_unlock() 누락을 원천 차단합니다. 새 코드에서는 적극 활용을 권장합니다.
포인터 접근 API 상세
RCU 포인터 접근 API는 컴파일러/CPU 재배치를 방지하여 올바른 의존성 순서를 보장합니다. 컨텍스트에 따라 올바른 변형을 선택해야 합니다.
| 함수 | 사용 컨텍스트 | lockdep 검증 | 의존성 순서 |
|---|---|---|---|
rcu_dereference(p) | RCU read-side CS 내부 | rcu_read_lock_held() | O |
rcu_dereference_bh(p) | RCU-bh read-side CS 내부 | rcu_read_lock_bh_held() | O |
rcu_dereference_sched(p) | RCU-sched read-side CS 내부 | rcu_read_lock_sched_held() | O |
rcu_dereference_protected(p, c) | Writer (잠금 보유 상태) | 조건 c 검증 | 불필요 (단일 writer) |
rcu_dereference_check(p, c) | Reader 또는 Writer | rcu_read_lock_held() || c | O |
rcu_dereference_raw(p) | 특수 상황 (부팅 초기 등) | 없음 | O |
rcu_dereference_raw_check(p) | NULL 허용 raw | 없음 | O |
srcu_dereference(p, sp) | SRCU read-side CS 내부 | srcu_read_lock_held(sp) | O |
srcu_dereference_check(p, sp, c) | SRCU Reader 또는 Writer | srcu_read_lock_held(sp) || c | O |
rcu_dereference_protected_check(p, c) | Writer 전용 (조건부) | c 검증 | 불필요 |
/* rcu_dereference() 변형 — 컨텍스트에 따라 올바른 함수 선택 */
/* 1. 일반 RCU read-side critical section 내에서 */
p = rcu_dereference(gbl_ptr);
/* 2. Writer 경로 — 이미 다른 잠금으로 보호된 경우 */
/* rcu_read_lock 없이도 사용 가능 (lockdep 검증 포함) */
spin_lock(&my_lock);
p = rcu_dereference_protected(gbl_ptr,
lockdep_is_held(&my_lock)); /* 잠금 보유 증명 */
spin_unlock(&my_lock);
/* 3. SRCU read-side critical section 내에서 */
idx = srcu_read_lock(&my_srcu);
p = srcu_dereference(gbl_ptr, &my_srcu);
srcu_read_unlock(&my_srcu, idx);
/* 4. 디버그 빌드 전용 — rcu_read_lock 없이 접근 (테스트용) */
p = rcu_dereference_raw(gbl_ptr); /* 배리어만, lockdep 검증 없음 */
/* 5. Reader 또는 Writer 양쪽에서 사용 — 조건부 검증 */
p = rcu_dereference_check(gbl_ptr,
lockdep_is_held(&my_lock)); /* RCU lock 또는 my_lock 보유 시 OK */
rcu_access_pointer()
rcu_access_pointer()는 의존성 순서(dependency ordering) 없이 RCU 포인터의 값만 확인합니다. 포인터가 가리키는 데이터에 접근하지 않고, NULL 여부나 변경 여부만 체크할 때 사용합니다.
/* rcu_access_pointer(): 포인터 값만 확인, 역참조 금지 */
if (rcu_access_pointer(callback) != NULL) {
/* 콜백이 등록되어 있음 — 이 시점에서 callback 역참조 금지! */
/* 실제 사용은 rcu_read_lock() 내에서 rcu_dereference()로 */
}
/* 올바른 사용 패턴: NULL 체크 후 별도로 역참조 */
if (rcu_access_pointer(dev->rx_handler)) {
rcu_read_lock();
handler = rcu_dereference(dev->rx_handler);
if (handler)
ret = handler(&skb);
rcu_read_unlock();
}
rcu_access_pointer vs rcu_dereference 구분: rcu_access_pointer()는 의존성 배리어가 없으므로 반환된 포인터를 역참조하면 안 됩니다. 단지 "포인터가 NULL인가?", "포인터가 변경되었는가?" 같은 질문에만 사용합니다. 데이터를 읽으려면 반드시 rcu_dereference()를 사용하세요.
rcu_replace_pointer()
rcu_replace_pointer()는 RCU 포인터를 원자적으로 교체하고 이전 값을 반환합니다. rcu_assign_pointer() + 이전 값 저장을 한 번에 수행합니다.
/* rcu_replace_pointer(rcu_ptr, new, condition)
* = rcu_dereference_protected() + rcu_assign_pointer() 원자 결합
* 반환값: 이전 포인터 값 */
spin_lock(&my_lock);
old = rcu_replace_pointer(gbl_ptr, new_data,
lockdep_is_held(&my_lock));
spin_unlock(&my_lock);
/* old를 grace period 후 해제 */
if (old)
kfree_rcu(old, rcu_head);
/* 매크로 내부 구현 */
#define rcu_replace_pointer(rcu_ptr, ptr, c) \
({ \
typeof(ptr) __tmp = rcu_dereference_protected((rcu_ptr), (c)); \
rcu_assign_pointer((rcu_ptr), (ptr)); \
__tmp; \
})
선택 기준: Reader 경로는 rcu_dereference(), Writer가 이미 잠금을 보유한 상태에서 포인터를 읽어야 하면 rcu_dereference_protected()를 사용하세요. 후자는 불필요한 rcu_read_lock()을 추가하지 않아도 되며, CONFIG_PROVE_RCU 빌드에서 lockdep이 잠금 보유 여부를 자동 검증합니다.
포인터 발행 API 상세
포인터 발행(publish) API는 writer가 새 데이터를 reader에게 안전하게 공개하는 메커니즘입니다. store-release 의미론으로 데이터 초기화가 포인터 공개 전에 완료됨을 보장합니다.
rcu_assign_pointer() 심층
/* rcu_assign_pointer(p, v): RCU 포인터 발행
*
* 내부 구현: smp_store_release() 기반
* - 이전의 모든 메모리 쓰기가 완료된 후 포인터를 공개
* - reader가 rcu_dereference()로 읽을 때 초기화된 데이터를 보장
*
* 순서 보장:
* new->field = value; ← ① 데이터 초기화
* rcu_assign_pointer(gbl, new); ← ② 포인터 공개 (store-release)
* // reader는 ②를 통해 포인터를 얻으면 ①이 완료된 상태임을 보장받음
*/
#define rcu_assign_pointer(p, v) \
do { \
uintptr_t _r_a_p__v = (uintptr_t)(v); \
rcu_check_sparse(p, __rcu); \
if (__builtin_constant_p(v) && (_r_a_p__v) == (uintptr_t)NULL) \
WRITE_ONCE((p), (typeof(p))(_r_a_p__v)); \
else \
smp_store_release(&(p), RCU_INITIALIZER((typeof(p))_r_a_p__v)); \
} while (0)
RCU_INIT_POINTER()
RCU_INIT_POINTER()는 메모리 배리어 없이 RCU 포인터를 설정합니다. 초기화 시점에 아직 reader가 접근할 수 없는 상황이거나, NULL로 설정할 때 사용합니다.
/* RCU_INIT_POINTER(p, v): 배리어 없는 RCU 포인터 초기화
*
* 사용 조건 (하나 이상 만족해야 함):
* 1. 다른 CPU가 아직 포인터에 접근할 수 없는 초기화 단계
* 2. NULL로 설정 (데이터가 없으므로 의존성 순서 불필요)
* 3. 구조체가 아직 공개되지 않은 상태
*
* 위 조건을 만족하지 않으면 반드시 rcu_assign_pointer() 사용!
*/
#define RCU_INIT_POINTER(p, v) \
WRITE_ONCE((p), RCU_INITIALIZER(v))
/* 올바른 사용 예 */
struct my_struct *obj = kzalloc(sizeof(*obj), GFP_KERNEL);
obj->callback = my_func;
RCU_INIT_POINTER(obj->rcu_ptr, NULL); /* OK: NULL 설정 */
/* 초기화 완료 후 공개 시에는 rcu_assign_pointer() 사용 */
rcu_assign_pointer(global_obj, obj);
/* ✗ 잘못된 사용: 이미 공개된 포인터를 non-NULL로 변경 */
RCU_INIT_POINTER(global_obj, new_obj); /* BUG: 배리어 없음! */
RCU_INIT_POINTER vs rcu_assign_pointer: RCU_INIT_POINTER()는 배리어가 없어 빠르지만, 이미 reader가 접근 가능한 포인터를 non-NULL 값으로 변경하는 데 사용하면 reader가 초기화되지 않은 데이터를 볼 수 있습니다. 확신이 없으면 항상 rcu_assign_pointer()를 사용하세요.
Grace Period API 상세
synchronize_rcu() 내부 동작
/* synchronize_rcu(): 현재 진행 중인 모든 RCU read-side CS가
* 종료될 때까지 블로킹 대기.
*
* 내부 흐름:
* 1. 새 grace period 시작 요청 (또는 이미 진행 중인 GP에 합류)
* 2. rcu_gp_kthread가 모든 CPU에 QS 보고 요청
* 3. 모든 CPU가 QS를 보고하면 GP 완료
* 4. 호출자에게 제어 반환
*
* 주의: process 컨텍스트에서만 호출 가능 (블로킹 함수)
* 인터럽트, softirq, atomic 컨텍스트에서 호출 금지
*/
synchronize_rcu();
/* synchronize_net(): synchronize_rcu()의 별칭
* 네트워크 서브시스템에서 의미론적 명확성을 위해 사용 */
static inline void synchronize_net(void)
{
synchronize_rcu();
}
call_rcu() 내부
/* call_rcu(&obj->rcu, callback): 비동기 RCU 콜백 등록
*
* 내부 동작:
* 1. 현재 CPU의 rcu_data->cblist (segmented callback list)에 추가
* 2. segcb 리스트는 4개 세그먼트로 분류:
* - RCU_DONE_TAIL: GP 완료된 콜백 (실행 대기)
* - RCU_WAIT_TAIL: 현재 GP 대기 중
* - RCU_NEXT_READY_TAIL: 다음 GP에 할당 예정
* - RCU_NEXT_TAIL: 새로 등록된 콜백
* 3. GP 완료 시 콜백이 RCU_DONE_TAIL로 이동
* 4. softirq(RCU_SOFTIRQ) 또는 rcuo kthread에서 실행
*/
void call_rcu(struct rcu_head *head,
rcu_callback_t func)
{
__call_rcu_common(head, func, false);
}
/* call_rcu_hurry(): 긴급 콜백 — GP 시작을 즉시 요청 */
void call_rcu_hurry(struct rcu_head *head,
rcu_callback_t func)
{
__call_rcu_common(head, func, true); /* lazy=false 강제 */
}
kfree_rcu 변형
| API | rcu_head 필요 | sleep 가능 | 설명 |
|---|---|---|---|
kfree_rcu(ptr, rhf) | O (필드명 지정) | X | GP 후 kfree. rcu_head 오프셋으로 container_of 역산 |
kfree_rcu_mightsleep(ptr) | X | O | rcu_head 없는 구조체. 내부적으로 GP 대기 후 kfree |
kvfree_rcu(ptr, rhf) | O | X | kvmalloc으로 할당된 메모리 해제 (kmalloc/vmalloc 자동 구분) |
kvfree_rcu_mightsleep(ptr) | X | O | kvfree 버전의 mightsleep 변형 |
/* kfree_rcu: rcu_head 필드가 있는 경우 */
struct my_data {
int value;
struct rcu_head rcu; /* 16바이트 추가 */
};
kfree_rcu(old, rcu);
/* kfree_rcu_mightsleep: rcu_head 없이 사용 (process 컨텍스트 전용) */
struct small_data {
int key;
int val;
/* rcu_head 없음 — 구조체가 작아서 16바이트 낭비 방지 */
};
kfree_rcu_mightsleep(old); /* 내부: synchronize_rcu() + kfree() */
/* kvfree_rcu: kvmalloc()으로 할당한 메모리에 사용 */
buf = kvmalloc(large_size, GFP_KERNEL);
/* ... 사용 후 ... */
kvfree_rcu(buf, rcu); /* kmalloc이면 kfree, vmalloc이면 vfree */
rcu_barrier() 심층
/* rcu_barrier(): 모든 CPU에 등록된 call_rcu() 콜백이
* 완료될 때까지 블로킹 대기.
*
* synchronize_rcu()와의 차이:
* - synchronize_rcu(): 현재 GP만 대기 (콜백 실행은 보장 안 함)
* - rcu_barrier(): 현재 등록된 모든 콜백의 실행 완료까지 대기
*
* 내부 동작:
* 1. 각 CPU에 sentinel 콜백 등록
* 2. 모든 sentinel 콜백이 실행될 때까지 대기
* 3. sentinel 실행 = 그 CPU의 이전 콜백 모두 완료
*
* 주요 사용처: module_exit()에서 모듈 코드를 참조하는
* 콜백이 모두 완료된 것을 보장
*/
rcu_barrier();
/* 관련 API */
srcu_barrier(&my_srcu); /* SRCU 콜백 대기 */
rcu_barrier_tasks(); /* RCU-Tasks 콜백 대기 */
rcu_barrier_tasks_trace(); /* RCU-Tasks-Trace 콜백 대기 */
Polled Grace Period API
Polled GP API는 synchronize_rcu()처럼 블로킹하지 않고, GP 완료 여부를 비블로킹으로 폴링할 수 있는 API입니다. 비동기적으로 GP 완료를 확인해야 하는 상황에서 유용합니다.
| 함수 | 동작 | 반환값 |
|---|---|---|
get_state_synchronize_rcu() | 현재 GP 상태 스냅샷 획득 (GP 시작 요청 안 함) | GP 쿠키 (unsigned long) |
start_poll_synchronize_rcu() | 새 GP 시작 요청 + 상태 스냅샷 획득 | GP 쿠키 |
poll_state_synchronize_rcu(cookie) | GP 완료 여부 비블로킹 확인 | true: 완료, false: 미완료 |
cond_synchronize_rcu(cookie) | GP 미완료 시 블로킹, 이미 완료면 즉시 반환 | void |
get_completed_synchronize_rcu() | 이미 완료된 GP를 나타내는 쿠키 반환 | GP 쿠키 (항상 "완료") |
same_state_synchronize_rcu(c1, c2) | 두 쿠키가 같은 GP를 참조하는지 확인 | true: 같은 GP |
/* Polled GP 사용 패턴: 비블로킹 GP 대기 */
unsigned long cookie;
/* 1단계: 업데이트 후 GP 시작 요청 + 쿠키 획득 */
rcu_assign_pointer(gbl_ptr, new_data);
cookie = start_poll_synchronize_rcu();
/* 2단계: 다른 작업 수행 (블로킹 없음) */
do_other_work();
/* 3단계: GP 완료 여부 폴링 */
if (poll_state_synchronize_rcu(cookie)) {
/* GP 완료 — old_data 안전하게 해제 가능 */
kfree(old_data);
} else {
/* 아직 미완료 — 나중에 다시 확인하거나 cond_synchronize_rcu() */
cond_synchronize_rcu(cookie); /* 미완료시만 블로킹 */
kfree(old_data);
}
/* 활용 예: 이미 완료된 GP 쿠키로 즉시 처리 */
cookie = get_completed_synchronize_rcu();
cond_synchronize_rcu(cookie); /* 항상 즉시 반환 */
Polled GP 사용 시점: (1) GP 대기 중 CPU를 낭비하고 싶지 않을 때, (2) 여러 객체의 해제를 배치 처리할 때, (3) 상태 머신에서 비동기적으로 GP 완료를 확인해야 할 때 유용합니다. 단순 해제는 kfree_rcu()가 더 편리합니다.
Expedited Grace Period 제어 API
Expedited GP는 IPI를 사용해 모든 CPU에 강제 QS를 유발하여 GP를 빠르게 완료합니다. 시스템 전체에 영향을 주므로 제어 API가 제공됩니다.
| 함수 | 동작 | 사용 시점 |
|---|---|---|
synchronize_rcu_expedited() | IPI로 강제 GP 완료 (수 ms) | 모듈 로드/언로드, 초기화 |
rcu_expedite_gp() | 이후 synchronize_rcu()를 expedited로 전환 | 부팅 초기, 일시적 가속 |
rcu_unexpedite_gp() | expedited 모드 해제 (중첩 카운터 감소) | 가속 구간 종료 후 |
rcu_gp_is_expedited() | 현재 expedited 모드 여부 확인 | 디버깅/로깅 |
/* Expedited GP: IPI로 모든 CPU에 강제 QS 유발 */
synchronize_rcu_expedited(); /* 빠르지만 CPU간 IPI 비용 발생 */
/* expedited 모드 전환: 부팅 초기 가속 예 */
rcu_expedite_gp(); /* 이후 synchronize_rcu()가 expedited로 동작 */
/* ... 부팅 초기화 작업 ... */
synchronize_rcu(); /* 내부적으로 expedited로 실행됨 */
rcu_unexpedite_gp(); /* 정상 모드 복귀 (중첩 카운터 기반) */
/* 콜백 버전: expedited GP 불필요 */
call_rcu(&p->rcu, my_callback); /* 비동기: 정상 GP 사용 */
Expedited GP 사용 제한:
- 허용: 모듈 로드/언로드, 시스템 초기화, 일회성 관리 경로 (
MODULE_INIT,sysctl변경 등) - 금지: 핫 경로(hot path), 반복 호출 루프 — 모든 CPU에 IPI를 전송하므로 실시간 응답성이 저하되고 대규모 시스템에서 성능 병목이 됩니다.
커널은 CONFIG_RCU_EXP_KTHREAD=y를 통해 expedited GP를 전용 kthread로 오프로드할 수 있습니다.
__rcu Sparse Annotation
__rcu 어노테이션은 RCU 포인터임을 명시하는 sparse 정적 분석 마커입니다. 컴파일러에게는 영향을 주지 않지만, sparse 도구로 RCU API 오용을 감지합니다.
/* __rcu 어노테이션 사용 */
struct my_struct {
struct data __rcu *ptr; /* RCU 보호 포인터임을 명시 */
int value;
};
/* sparse 검사가 감지하는 오류들:
* 1. __rcu 포인터를 rcu_dereference() 없이 직접 역참조
* 2. rcu_assign_pointer() 대신 직접 대입
* 3. rcu_dereference()에 non-__rcu 포인터 전달
*/
/* ✗ sparse 경고 발생 */
p = obj->ptr; /* warning: dereference of noderef */
obj->ptr = new_data; /* warning: assignment to noderef */
/* ✓ 올바른 사용 */
p = rcu_dereference(obj->ptr);
rcu_assign_pointer(obj->ptr, new_data);
/* sparse 실행: */
/* make C=2 CF="-D__CHECK_ENDIAN__" drivers/my_driver.o */
Grace Period
Grace period는 rcu_assign_pointer() 호출 시점에 이미 진행 중인 모든 RCU read-side critical section이 종료될 때까지의 기간입니다. Grace period가 끝나면 이전 데이터에 접근하는 reader가 없음이 보장됩니다.
rcu_read_lock()은 선점만 비활성화하며 (non-preemptible 커널에서는 no-op), 매우 가벼운 연산입니다. spinlock이나 mutex와 달리 캐시라인 바운싱이 없어 확장성이 뛰어납니다.
Expedited Grace Period 개념
synchronize_rcu_expedited()는 IPI를 사용해 모든 CPU에 강제 QS를 유발하여 GP를 수 밀리초 이내로 단축합니다. 상세 API와 제어 함수는 Expedited Grace Period 제어 API 섹션을 참고하세요.
Grace Period Kthread 동작 상세
RCU grace period는 전용 커널 스레드 rcu_gp_kthread()가 관리합니다. 이 kthread는 GP 시작, FQS(Force-Quiescent-State) 스캔, GP 정리를 반복하는 상태 머신입니다.
gp_seq 시퀀스 번호 진행
Grace period는 gp_seq 카운터의 하위 2비트로 4단계를 구분합니다:
| gp_seq & 0x3 | 단계 | 설명 |
|---|---|---|
0b00 | idle | GP가 진행 중이지 않음 |
0b01 | GP started | GP 시작됨, qsmask 초기화 진행 중 |
0b10 | GP in progress | 모든 노드의 qsmask 설정 완료, QS 대기 중 |
0b11 | GP cleanup | 모든 QS 수집 완료, 콜백 실행 준비 |
/* kernel/rcu/tree.c — rcu_gp_kthread() 메인 루프 (간략화) */
static int rcu_gp_kthread(void *unused)
{
for (;;) {
/* ① GP 대기: 콜백이 등록될 때까지 sleep */
swait_event_idle_exclusive(rsp->gp_wq,
READ_ONCE(rsp->gp_flags) & RCU_GP_FLAG_INIT);
/* ② GP 초기화: rcu_node 트리의 qsmask 설정 */
rcu_gp_init(); /* gp_seq += 1 (idle→started) */
/* ③ FQS 루프: 주기적으로 미보고 CPU 확인 */
do {
rcu_gp_fqs(); /* force_qs_rnp()로 각 rcu_node 스캔 */
cond_resched();
msleep(jiffies_till_next_fqs);
} while (!rcu_gp_fqs_check_wake());
/* ④ GP 정리: 콜백 큐 진행, gp_seq 완료 */
rcu_gp_cleanup(); /* gp_seq += 1 (cleanup→idle) */
}
}
Force-Quiescent-State (FQS) 메커니즘
FQS는 GP가 지연될 때 미보고 CPU를 능동적으로 확인하는 메커니즘입니다:
/* kernel/rcu/tree.c — FQS 스캔 (간략화) */
static void force_qs_rnp(int (*f)(struct rcu_data *rdp))
{
rcu_for_each_leaf_node(rnp) {
mask = rnp->qsmask;
for_each_leaf_node_cpu_mask(rnp, cpu, mask) {
rdp = per_cpu_ptr(&rcu_data, cpu);
if (f(rdp)) /* dynticks 카운터로 QS 여부 확인 */
mask &= ~rdp->grpmask; /* QS 통과한 CPU 클리어 */
}
if (mask != rnp->qsmask)
rcu_report_qs_rnp(mask ^ rnp->qsmask, rnp);
}
}
Expedited GP: IPI 기반 강제 QS
Expedited grace period(synchronize_rcu_expedited())는 일반 FQS 대신 IPI(Inter-Processor Interrupt)를 사용하여 non-idle CPU에 즉시 QS를 강제합니다:
- idle CPU: dynticks 카운터가 짝수 → 이미 QS, IPI 불필요
- non-idle CPU: IPI 전송 →
rcu_exp_handler()실행 → 즉시 QS 보고 call_rcu_hurry()(v6.5+): lazy callback을 강제로 즉시 실행 대상으로 승격
FQS 주기 튜닝: rcutree.jiffies_till_first_fqs와 rcutree.jiffies_till_next_fqs 부트 파라미터로 FQS 스캔 주기를 조정할 수 있습니다. 값이 작으면 GP가 빨리 완료되지만 CPU 오버헤드가 증가합니다.
RCU 보호 연결 리스트
list_for_each_entry_rcu()는 doubly-linked list(struct list_head)를 순회하며, hlist_for_each_entry_rcu()는 단방향 해시 버킷(struct hlist_head)에 사용합니다. 해시 테이블처럼 버킷이 많고 각 체인이 짧을 때는 hlist가 메모리 효율적입니다.
리스트 변경 API
모든 RCU 리스트 변경 함수는 writer 측에서 호출하며, 외부 잠금(spinlock 등)으로 보호해야 합니다. 내부적으로 rcu_assign_pointer() 의미론을 사용하여 포인터 공개 전 데이터 초기화를 보장합니다.
| 함수 | 동작 | 비고 |
|---|---|---|
list_add_rcu(new, head) | head 다음에 new 삽입 | smp_store_release 사용 |
list_add_tail_rcu(new, head) | 리스트 끝에 new 삽입 | tail 삽입 |
list_del_rcu(entry) | entry 제거 (next 포인터 유지) | entry->next 건드리지 않음 |
list_replace_rcu(old, new) | old를 new로 원자적 교체 | old->next 유지 |
list_splice_init_rcu(list, head, sync) | 리스트 합치기 (RCU 안전) | sync에 synchronize_rcu 전달 |
INIT_LIST_HEAD_RCU(ptr) | RCU 리스트 헤드 초기화 | WRITE_ONCE 기반 |
/* 리스트에 추가 */
spin_lock(&list_lock);
list_add_rcu(&new->list, &my_list); /* rcu_assign_pointer() 의미론 */
spin_unlock(&list_lock);
/* 리스트 끝에 추가 */
spin_lock(&list_lock);
list_add_tail_rcu(&new->list, &my_list);
spin_unlock(&list_lock);
/* 리스트에서 삭제: list_del_rcu + kfree_rcu 패턴 */
spin_lock(&list_lock);
list_del_rcu(&old->list); /* 순회 중 reader에게는 여전히 유효 */
spin_unlock(&list_lock);
kfree_rcu(old, rcu_head); /* Grace Period 후 자동 해제 */
/* list_del_rcu 주의: 삭제 후 해당 노드를 즉시 kfree()하면 UAF!
* 반드시 kfree_rcu() 또는 call_rcu()를 사용해야 합니다. */
/* list_replace_rcu: 원자적 교체 */
spin_lock(&list_lock);
list_replace_rcu(&old->list, &new->list);
spin_unlock(&list_lock);
kfree_rcu(old, rcu_head);
/* list_splice_init_rcu: 두 리스트를 RCU 안전하게 합치기 */
spin_lock(&lock);
list_splice_init_rcu(&src_list, &dst_list, synchronize_rcu);
spin_unlock(&lock);
리스트 순회 API
| 매크로 | 동작 | 조건 |
|---|---|---|
list_for_each_entry_rcu(pos, head, member) | RCU read-side에서 리스트 순회 | rcu_read_lock() 필요 |
list_for_each_entry_rcu(pos, head, member, cond) | 조건부 lockdep 검증 순회 (4인자) | cond으로 잠금 보유 증명 |
list_for_each_entry_lockless(pos, head, member) | lockless 순회 (READ_ONCE 기반) | RCU/lock 불필요, 배리어 없음 |
list_entry_rcu(ptr, type, member) | 리스트 노드에서 구조체 포인터 획득 | rcu_dereference 내장 |
list_first_or_null_rcu(ptr, type, member) | 첫 번째 요소 또는 NULL 반환 | rcu_read_lock() 필요 |
list_next_or_null_rcu(head, ptr, type, member) | 다음 요소 또는 NULL (끝이면) | rcu_read_lock() 필요 |
list_entry_lockless(ptr, type, member) | lockless 버전의 list_entry | 배리어 없음 |
/* 기본 순회 */
rcu_read_lock();
list_for_each_entry_rcu(entry, &my_list, list) {
process(entry);
}
rcu_read_unlock();
/* 4인자 변형: Writer가 잠금 보유 중 순회 */
list_for_each_entry_rcu(entry, &my_list, list,
lockdep_is_held(&my_lock)) {
modify(entry);
}
/* list_first_or_null_rcu: 첫 요소 안전 접근 */
rcu_read_lock();
entry = list_first_or_null_rcu(&my_list,
struct my_struct, list);
if (entry)
val = entry->value;
rcu_read_unlock();
/* list_next_or_null_rcu: 안전한 다음 요소 접근 */
rcu_read_lock();
next = list_next_or_null_rcu(&my_list, ¤t->list,
struct my_struct, list);
rcu_read_unlock();
RCU hlist 연산
hlist(hash list)는 list_head보다 메모리 효율적인 단방향 연결 리스트입니다. 해시 테이블의 버킷처럼 헤드가 많고 각 체인이 짧을 때 최적입니다. hlist_head는 포인터 1개(8바이트), list_head는 포인터 2개(16바이트)입니다.
hlist 변경 API
| 함수 | 동작 |
|---|---|
hlist_add_head_rcu(n, h) | hlist 헤드에 노드 삽입 |
hlist_add_before_rcu(n, next) | next 앞에 삽입 |
hlist_add_behind_rcu(n, prev) | prev 뒤에 삽입 |
hlist_add_tail_rcu(n, h) | hlist 끝에 삽입 |
hlist_del_rcu(n) | 노드 제거 (pprev 포이즌, next 유지) |
hlist_del_init_rcu(n) | 노드 제거 + 재초기화 (재삽입 가능) |
hlist_replace_rcu(old, new) | old를 new로 원자적 교체 |
/* hlist 기본 연산 */
struct my_entry {
int key;
int value;
struct hlist_node node;
struct rcu_head rcu;
};
/* 삽입 */
spin_lock(&bucket_lock);
hlist_add_head_rcu(&entry->node, &bucket->head);
spin_unlock(&bucket_lock);
/* 삭제 */
spin_lock(&bucket_lock);
hlist_del_rcu(&entry->node);
spin_unlock(&bucket_lock);
kfree_rcu(entry, rcu);
/* 교체 */
spin_lock(&bucket_lock);
hlist_replace_rcu(&old->node, &new->node);
spin_unlock(&bucket_lock);
kfree_rcu(old, rcu);
/* hlist_del_init_rcu: 삭제 후 재삽입이 필요할 때 */
spin_lock(&lock);
hlist_del_init_rcu(&entry->node); /* 노드를 빈 상태로 초기화 */
spin_unlock(&lock);
/* GP 후 다른 버킷에 재삽입 가능 */
synchronize_rcu();
spin_lock(&new_lock);
hlist_add_head_rcu(&entry->node, &new_bucket->head);
spin_unlock(&new_lock);
hlist 순회 API
/* 기본 hlist RCU 순회 */
rcu_read_lock();
hlist_for_each_entry_rcu(entry, &bucket->head, node) {
if (entry->key == target_key) {
result = entry->value;
break;
}
}
rcu_read_unlock();
/* 조건부 lockdep 변형 */
hlist_for_each_entry_rcu(entry, &bucket->head, node,
lockdep_is_held(&bucket_lock)) {
/* Writer 경로에서 잠금 보유 중 순회 */
}
/* hlist 헬퍼 매크로 */
first = hlist_entry_safe(
rcu_dereference_raw(hlist_first_rcu(&head)),
struct my_entry, node);
/* hlist_next_rcu: 다음 노드 접근 */
next_node = hlist_next_rcu(node); /* rcu_dereference 내장 */
hlist_nulls 연산
hlist_nulls는 리스트 끝을 NULL 대신 NULL 마커 값으로 표시하는 변형입니다. 해시 테이블에서 노드가 순회 도중 다른 버킷으로 이동했을 때 이를 감지할 수 있습니다.
/* hlist_nulls: 버킷 이동 감지가 필요한 해시 테이블에 사용
*
* 일반 hlist: 리스트 끝 = NULL
* hlist_nulls: 리스트 끝 = (bucket_id << 1) | 1
*
* 순회 완료 시 끝 마커의 bucket_id를 확인하여
* 순회 중 노드가 다른 버킷으로 이동했는지 감지 가능
*/
/* hlist_nulls 순회 패턴 (네트워크 소켓 lookup 등) */
unsigned int slot = hash(key) % HASH_SIZE;
struct hlist_nulls_node *node;
struct my_entry *entry;
begin:
rcu_read_lock();
hlist_nulls_for_each_entry_rcu(entry, node,
&hash_table[slot], node) {
if (entry->key == target_key) {
if (!refcount_inc_not_zero(&entry->refcnt))
goto begin;
rcu_read_unlock();
return entry;
}
}
rcu_read_unlock();
/* 끝 마커 확인: 순회 중 버킷 이동이 발생했는지 감지 */
if (get_nulls_value(node) != slot)
goto begin; /* 버킷 이동 감지 → 재시도 */
/* hlist_nulls 변경 API */
hlist_nulls_add_head_rcu(&entry->node, &hash_table[slot]);
hlist_nulls_del_rcu(&entry->node);
hlist_bl (Bit-Locked hlist) 연산
hlist_bl은 hlist 헤드 포인터의 최하위 비트를 스핀락으로 사용하는 공간 최적화 구조입니다. 별도의 spinlock 없이 리스트 헤드 자체에 잠금을 내장하여 해시 테이블의 메모리 사용을 절반으로 줄입니다.
/* hlist_bl: 디렉토리 엔트리 캐시(dcache) 등에서 사용
* 헤드 포인터의 bit 0 = lock bit
* 나머지 비트 = 실제 포인터
*/
#include <linux/list_bl.h>
/* 잠금 + 순회 */
hlist_bl_lock(&head);
hlist_bl_for_each_entry(entry, node, &head, d_hash) {
if (dentry_match(entry, name))
found = entry;
}
hlist_bl_unlock(&head);
/* RCU 순회 (잠금 없이) */
rcu_read_lock();
hlist_bl_for_each_entry_rcu(entry, node, &head, d_hash) {
if (dentry_match(entry, name)) {
dget(entry); /* 참조 카운트 증가 */
rcu_read_unlock();
return entry;
}
}
rcu_read_unlock();
/* hlist_bl 변경 (bit lock 보유 상태에서) */
hlist_bl_lock(&head);
hlist_bl_add_head_rcu(&new->d_hash, &head);
hlist_bl_del_rcu(&old->d_hash);
hlist_bl_unlock(&head);
hlist_bl 사용처: Linux dcache(디렉토리 엔트리 캐시)가 대표적입니다. 수백만 개의 해시 버킷마다 별도의 spinlock을 두면 메모리 낭비가 심하므로, 포인터 최하위 비트를 활용하여 lock을 내장합니다.
SRCU (Sleepable RCU)
표준 RCU의 read-side critical section에서는 슬립할 수 없습니다. 슬립이 필요한 경우 SRCU를 사용합니다:
DEFINE_SRCU(my_srcu);
int idx = srcu_read_lock(&my_srcu);
/* can sleep here */
srcu_read_unlock(&my_srcu, idx);
/* Writer */
synchronize_srcu(&my_srcu);
SRCU 심화 — 비용과 제한사항
SRCU는 srcu_struct 인스턴스를 독립 도메인으로 관리하므로 Grace Period와 메모리 비용 면에서 일반 RCU와 다른 특성을 가집니다.
| 항목 | 일반 RCU | SRCU |
|---|---|---|
| Grace Period 지연 | 수십 ms (자연 QS 대기) | 수 ms ~ 수 초 (슬립 가능 reader 대기) |
| 메모리 비용 | 전역 per-CPU rcu_data | srcu_struct 당 per-CPU 카운터 배열 |
| read-side 비용 | 거의 무비용 | per-CPU 카운터 증감 (캐시 지역성 활용) |
| 도메인 분리 | 불가 (전역 GP) | 가능 (srcu_struct 별 독립 GP) |
/* SRCU 핵심 규칙: srcu_read_lock() 반환값을 srcu_read_unlock()에 전달 */
/* ✓ 올바른 사용 */
int idx = srcu_read_lock(&my_srcu); /* idx = 현재 GP 카운터 인덱스 (0 또는 1) */
/* ... sleep 가능 작업 ... */
srcu_read_unlock(&my_srcu, idx); /* idx로 올바른 카운터 감소 */
/* ✗ 잘못된 패턴: idx 분실 → GP 추적 불가 */
srcu_read_lock(&my_srcu); /* BUG: 반환값 무시 */
srcu_read_unlock(&my_srcu, 0); /* BUG: 하드코딩된 인덱스 → 잘못된 카운터 감소 */
/* call_srcu vs synchronize_srcu 선택 */
synchronize_srcu(&my_srcu); /* 블로킹: 현재 컨텍스트에서 GP 완료 대기 */
call_srcu(&my_srcu, &p->rcu, my_cb); /* 비동기: GP 후 콜백 실행 (process 컨텍스트) */
/* 모듈 언로드 시 */
srcu_barrier(&my_srcu); /* call_srcu 콜백 완료 대기 */
SRCU 인스턴스 공유 금지: 서로 무관한 서브시스템이 하나의 srcu_struct를 공유하면 Grace Period가 불필요하게 길어집니다. 용도별로 별도의 DEFINE_SRCU() 또는 DEFINE_STATIC_SRCU() 인스턴스를 사용하십시오.
init/cleanup_srcu_struct
동적으로 srcu_struct를 할당/해제할 때 사용합니다. 정적 선언(DEFINE_SRCU)이 불가능한 경우에 필요합니다.
/* 동적 SRCU 초기화/해제 */
struct srcu_struct my_srcu;
/* 초기화 (모듈 init 등에서) */
ret = init_srcu_struct(&my_srcu);
if (ret)
return ret; /* 메모리 부족 시 실패 가능 */
/* 사용 */
idx = srcu_read_lock(&my_srcu);
/* ... */
srcu_read_unlock(&my_srcu, idx);
/* 해제 (모듈 exit 등에서) */
cleanup_srcu_struct(&my_srcu);
/* 주의: 모든 SRCU reader가 종료되고 콜백이 완료된 후에만 호출 */
/* cleanup_srcu_struct_quiesced(): GP 완료 후에만 호출 가능
* cleanup_srcu_struct()보다 빠르지만 GP 미완료 시 BUG() */
synchronize_srcu(&my_srcu);
cleanup_srcu_struct_quiesced(&my_srcu);
/* 정적 선언 변형들 */
DEFINE_SRCU(global_srcu); /* 전역 SRCU (exported) */
DEFINE_STATIC_SRCU(local_srcu); /* static SRCU (파일 스코프) */
SRCU Fast 변형
DEFINE_SRCU_FAST와 srcu_read_lock_fast()는 NMI-safe하지 않지만 더 빠른 SRCU 변형입니다. per-CPU 카운터 대신 task-local 카운터를 사용하여 read-side 오버헤드를 줄입니다.
/* SRCU Fast 변형 (Linux 6.5+) */
DEFINE_SRCU_FAST(my_fast_srcu);
/* srcu_read_lock_fast: task-local 카운터 사용 (더 빠름) */
int idx = srcu_read_lock_fast(&my_fast_srcu);
/* ... sleep 가능 ... */
srcu_read_unlock_fast(&my_fast_srcu, idx);
/* 주의: SRCU Fast는 NMI-safe하지 않음
* NMI 컨텍스트에서는 srcu_read_lock_nmisafe() 사용 */
srcu_read_lock_nmisafe
NMI(Non-Maskable Interrupt) 컨텍스트에서 안전한 SRCU read-side 진입점입니다. 원자적 카운터 연산을 사용하여 NMI 재진입에도 안전합니다.
/* NMI-safe SRCU: 원자적 카운터 사용 */
int idx = srcu_read_lock_nmisafe(&my_srcu);
/* NMI 핸들러 내에서도 안전 */
srcu_read_unlock_nmisafe(&my_srcu, idx);
/* 일반 srcu_read_lock()은 this_cpu_inc()를 사용하여
* NMI에서 재진입 시 카운터가 손상될 수 있음.
* nmisafe 변형은 atomic_inc()를 사용하여 이를 방지 */
synchronize_srcu_expedited
/* synchronize_srcu_expedited: SRCU GP를 빠르게 완료 */
synchronize_srcu_expedited(&my_srcu);
/* 일반 synchronize_srcu()보다 빠르지만 CPU 비용 높음
* 모든 CPU에 IPI를 전송하여 SRCU reader 존재 여부를 즉시 확인 */
Polled SRCU Grace Period
/* Polled SRCU GP: 일반 RCU polled GP와 동일한 패턴 */
unsigned long cookie;
cookie = start_poll_synchronize_srcu(&my_srcu);
/* 다른 작업 수행 */
if (poll_state_synchronize_srcu(&my_srcu, cookie)) {
/* GP 완료 */
kfree(old);
}
/* 관련 API */
cookie = get_state_synchronize_srcu(&my_srcu); /* GP 시작 안 함 */
cond_synchronize_srcu(&my_srcu, cookie); /* 미완료시만 블로킹 */
list/hlist_for_each_entry_srcu
/* SRCU read-side에서 리스트 순회 */
int idx = srcu_read_lock(&my_srcu);
list_for_each_entry_srcu(entry, &my_list, list,
srcu_read_lock_held(&my_srcu)) {
/* sleep 가능 처리 */
process(entry);
}
srcu_read_unlock(&my_srcu, idx);
/* hlist SRCU 순회 */
idx = srcu_read_lock(&my_srcu);
hlist_for_each_entry_srcu(entry, &head, node,
srcu_read_lock_held(&my_srcu)) {
process(entry);
}
srcu_read_unlock(&my_srcu, idx);
smp_mb__after_srcu_read_unlock
/* smp_mb__after_srcu_read_unlock(): SRCU unlock 후 배리어
*
* srcu_read_unlock() 자체는 메모리 배리어를 포함하지 않을 수 있음.
* unlock 후 메모리 순서가 필요하면 이 배리어를 명시적으로 삽입.
*/
srcu_read_unlock(&my_srcu, idx);
smp_mb__after_srcu_read_unlock();
/* 이후의 메모리 접근이 unlock 전 접근 이후로 보장됨 */
guard(srcu) / scoped_guard(srcu)
/* guard(srcu): scope 기반 자동 SRCU unlock (Linux 6.5+) */
{
guard(srcu)(&my_srcu);
p = srcu_dereference(gbl_ptr, &my_srcu);
if (!p)
return -ENOENT; /* 자동 srcu_read_unlock() */
result = p->field;
} /* ← srcu_read_unlock() 자동 */
/* scoped_guard(srcu): 블록 범위 SRCU 보호 */
scoped_guard(srcu, &my_srcu) {
p = srcu_dereference(gbl_ptr, &my_srcu);
result = process(p);
}
RCU 변형 비교
| 변형 | Reader 슬립 | 용도 |
|---|---|---|
RCU (Classic) | 불가 | 일반적인 커널 자료구조 |
SRCU | 가능 | 슬립이 필요한 reader |
RCU-bh (역사적 구분) | 불가 | 과거 softirq 중심 사용. 최신 커널에서는 통합된 RCU 문맥으로 이해 |
RCU-sched (역사적 구분) | 불가 | 과거 선점 비활성 문맥 중심 구분. 최신 커널에서는 개념적으로 통합 |
Tasks RCU | 가능 | trampoline, BPF |
RCU는 커널에서 가장 널리 사용되는 동기화 메커니즘 중 하나입니다. 네트워크 라우팅 테이블, 파일시스템 dentry 캐시, 모듈 리스트 등 읽기 중심 자료구조에 광범위하게 사용됩니다.
동기화 메커니즘 성능 특성 비교
아래 차트는 Spinlock, RWLock, Seqlock, Mutex, RCU의 성능 특성을 정성적으로 비교합니다. 막대가 길수록 해당 항목의 부담이 큰 것입니다.
RCU 내부 구현
Tree RCU
대규모 SMP(Symmetric Multi-Processing, 대칭형 다중 처리) 시스템에서 grace period 감지를 확장 가능하게 만드는 트리 기반 구현입니다. CPU들을 rcu_node 트리로 계층화하여 quiescent state를 집계합니다.
/* kernel/rcu/tree.c - quiescent state 보고 */
/*
* rcu_node 트리 구조 (64 CPU 예시):
*
* [root rcu_node] ← fanout = 16
* / | \
* [node0] [node1] [node2] [node3]
* / \ / \ / \ / \
* CPUs CPUs CPUs CPUs CPUs CPUs CPUs CPUs
*/
/* 각 CPU가 quiescent state 통과 시 */
static void rcu_report_qs_rnp(unsigned long mask,
struct rcu_node *rnp)
{
rnp->qsmask &= ~mask; /* 해당 CPU 비트 클리어 */
if (rnp->qsmask == 0)
/* 모든 자식 CPU가 QS 통과 → 상위 노드에 보고 */
rcu_report_qs_rnp(rnp->grpmask, rnp->parent);
}
Quiescent State
Quiescent State(QS)는 "해당 CPU가 현재 어떤 RCU read-side critical section에도 있지 않음"을 나타내는 상태입니다. RCU는 모든 CPU가 최소 한 번 QS를 통과하면 grace period를 완료합니다. 이를 효율적으로 추적하기 위해 dynticks 카운터를 활용합니다 — 짝수 값은 CPU가 idle(확장 QS)임을, 홀수 값은 커널 코드 실행 중임을 나타냅니다.
| 컨텍스트 | Quiescent State 인식 |
|---|---|
| 유저모드 진입 | CPU가 유저 공간에 있으면 RCU read-side에 없음 |
| idle 루프 | CPU가 idle이면 RCU critical section 밖 |
| 컨텍스트 스위치 | 스케줄링 발생 = 이전 RCU 구간 종료 |
| softirq 완료 | RCU-bh에서의 quiescent state |
PREEMPT_RCU 동작 원리
커널 선점 설정(CONFIG_PREEMPT / CONFIG_PREEMPT_RT)에 따라 RCU의 Quiescent State 판별 방식과 rcu_read_lock() 비용이 달라집니다.
| 항목 | non-PREEMPT | PREEMPT_RCU |
|---|---|---|
rcu_read_lock() 비용 | barrier() 전용 (no-op에 가까움) | nesting 카운터 증감 (원자 연산) |
| QS 판별 기준 | 컨텍스트 스위치 / idle / 유저모드 | rcu_read_unlock() 시 카운터 == 0 |
| 선점된 reader | 선점 자체가 QS → 추적 불필요 | rcu_node preempted 목록에 등록 |
| 설정 확인 | grep CONFIG_PREEMPT /boot/config-$(uname -r) | CONFIG_PREEMPT=y 또는 CONFIG_PREEMPT_RT=y |
실무 선택 기준: 데스크톱/RT 커널(CONFIG_PREEMPT_RT)은 PREEMPT_RCU를 사용합니다. 서버/임베디드에서 read-side 성능이 최우선이라면 CONFIG_PREEMPT=n (non-preemptible)을 검토하세요. 현재 커널 설정은 grep -E "CONFIG_PREEMPT" /boot/config-$(uname -r)로 확인합니다.
rcu_node / rcu_data 구조체 상세
Tree RCU의 핵심 자료구조인 struct rcu_data(per-CPU)와 struct rcu_node(계층 노드)의 주요 필드를 상세히 살펴봅니다.
struct rcu_data 주요 필드
/* kernel/rcu/tree.h — struct rcu_data (per-CPU, 간략화) */
struct rcu_data {
/* GP 추적 */
unsigned long gp_seq; /* 이 CPU가 인지한 최신 GP 번호 */
unsigned long gp_seq_needed; /* 콜백 처리에 필요한 GP 번호 */
bool cpu_no_qs; /* true = 아직 QS 미보고 */
bool core_needs_qs; /* GP kthread가 QS 요청 중 */
/* dynticks 추적 (EQS: Extended Quiescent State) */
int dynticks_nesting; /* 짝수=idle(EQS), 홀수=커널 실행 */
int dynticks_nmi_nesting; /* NMI 중첩 카운터 */
/* 콜백 리스트 (4-tail segmented) */
struct rcu_segcblist cblist; /* 세그멘트화된 콜백 큐 */
/* 소속 정보 */
struct rcu_node *mynode; /* 이 CPU가 속한 리프 rcu_node */
unsigned long grpmask; /* mynode에서의 비트 위치 */
int cpu; /* CPU 번호 */
};
struct rcu_node 주요 필드
/* kernel/rcu/tree.h — struct rcu_node (간략화) */
struct rcu_node {
raw_spinlock_t lock; /* 노드 보호 락 */
unsigned long gp_seq; /* 이 노드의 GP 시퀀스 */
/* QS 추적 비트마스크 */
unsigned long qsmask; /* 아직 QS 미보고 자식 비트맵 */
/* 리프: 각 비트 = CPU, 내부 노드: 각 비트 = 자식 rcu_node */
unsigned long qsmaskinit; /* GP 시작 시 qsmask 초기값 */
unsigned long grpmask; /* 부모 노드에서의 비트 위치 */
/* PREEMPT_RCU: 선점된 reader 추적 */
struct list_head blkd_tasks; /* 선점된 RCU reader 태스크 목록 */
unsigned long expmask; /* expedited GP 미보고 CPU */
/* 트리 구조 */
struct rcu_node *parent; /* 부모 노드 (root는 NULL) */
int level; /* 트리 깊이 (0 = root) */
int grplo, grphi; /* 관할 CPU 범위 */
};
Tree Fanout 계산
| CONFIG 옵션 | 기본값 | 의미 |
|---|---|---|
CONFIG_RCU_FANOUT_LEAF | 16 | 리프 rcu_node가 관할하는 최대 CPU 수 |
CONFIG_RCU_FANOUT | 64 (64bit) / 32 (32bit) | 내부 노드의 자식 수 |
예시: 256 CPU 시스템, FANOUT_LEAF=16, FANOUT=64
Level 0 (Root): 1개 rcu_node (최대 64 자식)
Level 1 (Leaf): 16개 rcu_node (각 16 CPU 관할)
→ 총 17개 rcu_node, 트리 깊이 2
예시: 4096 CPU 시스템, FANOUT_LEAF=16, FANOUT=64
Level 0 (Root): 1개 rcu_node
Level 1 (Inner): 4개 rcu_node (각 64 자식)
Level 2 (Leaf): 256개 rcu_node (각 16 CPU 관할)
→ 총 261개 rcu_node, 트리 깊이 3
Per-CPU rcu_data 메모리 풋프린트
struct rcu_data는 CPU당 약 300~500바이트를 차지합니다 (커널 버전과 CONFIG에 따라 변동). 콜백 큐(cblist)의 엔트리는 별도 할당이므로, 콜백이 폭증하면 총 메모리 사용량이 크게 증가합니다.
PREEMPT_RCU의 blkd_tasks: 선점된 RCU reader는 rcu_node->blkd_tasks 리스트에 등록됩니다. GP가 완료되려면 이 리스트가 비어야 합니다. 선점된 reader가 장시간 스케줄링되지 않으면 GP가 지연되어 stall로 이어질 수 있습니다. CONFIG_RCU_BOOST를 활성화하면 이런 reader의 우선순위를 높여 GP 지연을 방지합니다.
RCU & CPU 핫플러그 상호작용
CPU가 online/offline 상태로 전환될 때 RCU는 qsmask 조정, 콜백 마이그레이션, rcu_data 초기화 등의 작업을 수행해야 합니다. 이 과정이 잘못되면 GP 무한 대기, 콜백 소실, 데드락이 발생합니다.
CPU Offline 경로
/* CPU offline 시 RCU 처리 흐름 (간략화) */
/* ① dying 단계: CPU가 아직 실행 중이지만 offline 진행 */
rcutree_dying_cpu(cpu)
{
rdp = per_cpu_ptr(&rcu_data, cpu);
/* 이 CPU를 QS 통과로 강제 보고 */
rcu_report_qs_rdp(rdp);
/* dynticks를 offline 상태로 설정 */
rdp->cpu_started = false;
}
/* ② dead 단계: CPU가 완전히 비활성화된 후 */
rcutree_dead_cpu(cpu)
{
rdp = per_cpu_ptr(&rcu_data, cpu);
rnp = rdp->mynode;
/* qsmask에서 이 CPU 비트 제거 */
rnp->qsmaskinit &= ~rdp->grpmask;
/* 콜백 마이그레이션: 이 CPU의 콜백 → 다른 CPU로 이동 */
rcu_migrate_callbacks(cpu);
}
CPU Online 경로
/* CPU online 시 RCU 처리 흐름 */
rcu_cpu_starting(cpu)
{
rdp = per_cpu_ptr(&rcu_data, cpu);
rnp = rdp->mynode;
/* qsmaskinit에 이 CPU 비트 추가 */
rnp->qsmaskinit |= rdp->grpmask;
/* rcu_data 초기화: GP 시퀀스, 콜백 큐 등 */
rdp->gp_seq = rnp->gp_seq;
rdp->cpu_no_qs = true;
rdp->cpu_started = true;
/* 현재 진행 중인 GP가 있으면 qsmask에도 추가 */
if (rcu_gp_in_progress())
rnp->qsmask |= rdp->grpmask;
}
콜백 마이그레이션 상세
| 상황 | 처리 | 주의사항 |
|---|---|---|
| CPU offline, 콜백 잔존 | 콜백을 surviving CPU로 이동 (rcu_migrate_callbacks()) | 이동 대상 CPU의 콜백 큐 급증 가능 |
| NOCB 모드 CPU offline | rcuo kthread가 콜백 계속 처리 | rcuo kthread가 중단되면 콜백 소실 |
| 선점된 reader의 CPU offline | blkd_tasks에 남아 있음, 다른 CPU에서 실행 후 QS 보고 | reader가 재스케줄되지 않으면 GP stall |
핫플러그 데드락 시나리오: RCU read-side critical section 내에서 CPU hotplug lock(cpus_read_lock())을 잡으면 데드락이 발생할 수 있습니다. CPU offline 경로가 synchronize_rcu()를 호출하고, 이 GP가 hotplug lock을 잡은 채로 sleep 중인 reader를 기다리게 되기 때문입니다. 항상 cpus_read_lock()을 rcu_read_lock() 바깥에서 잡으세요.
RCU 콜백 처리
/* 콜백 등록 방법들 */
/* 1. call_rcu: 비동기 콜백 등록 */
void my_free_callback(struct rcu_head *head)
{
struct my_data *p = container_of(head, struct my_data, rcu);
kfree(p);
}
call_rcu(&old->rcu, my_free_callback);
/* 2. kfree_rcu: 단순 kfree용 간편 API */
kfree_rcu(old, rcu); /* rcu_head 필드명 지정 */
/* rcu_head 필드가 없으면 call_rcu() + container_of() 콜백을 사용 */
/* 3. synchronize_rcu: 동기적 대기 (블로킹) */
synchronize_rcu(); /* grace period 완료까지 슬립 */
/* 4. rcu_barrier: 모든 call_rcu 콜백 완료 대기 */
rcu_barrier(); /* 모듈 언로드 시 필수 — 수 ms ~ 수 초 소요 가능 */
콜백 실행 컨텍스트: call_rcu() 콜백은 softirq 컨텍스트에서 실행됩니다. 구체적으로 RCU_SOFTIRQ를 통해 콜백을 등록한 CPU 또는 rcuo 오프로드 kthread에서 실행됩니다. 따라서 콜백 내에서는 GFP_ATOMIC만 사용 가능하며, sleep 불가합니다.
rcu_barrier() 소요 시간: 최소 수 ms(빠른 경우)에서 grace period 지연이 누적되면 수 초까지 걸릴 수 있습니다. 시스템 부하가 높거나 콜백이 많은 상황에서는 예상보다 오래 걸릴 수 있으므로 핫 경로에서 호출하지 마십시오.
Callback Flooding 주의: 짧은 시간에 call_rcu()를 대량으로 호출하면 RCU 콜백 큐가 폭증하여 rcu_barrier() 지연, 메모리 소비 급증, 시스템 응답성 저하로 이어집니다. 대량 삭제 시에는 가능하면 synchronize_rcu() 한 번 호출 후 일괄 kfree()하는 방식을 검토하십시오.
모듈 언로드 시 call_rcu()로 등록된 미처리 콜백이 있으면 rcu_barrier()를 호출해야 합니다. 콜백이 모듈 코드를 참조하는 경우 use-after-free가 발생할 수 있습니다.
콜백 세그멘테이션 (4-Tail Segmented List)
RCU 콜백 큐는 단순 연결 리스트가 아니라 4-tail 세그멘트 리스트(struct rcu_segcblist)로 관리됩니다. 각 세그멘트는 콜백의 GP 완료 상태를 나타내며, GP가 진행됨에 따라 콜백이 세그멘트 간 이동합니다.
| 세그멘트 | 상수 | 의미 | 상태 |
|---|---|---|---|
| DONE | RCU_DONE_TAIL | GP 완료, 실행 대기 | 즉시 실행 가능 |
| WAIT | RCU_WAIT_TAIL | 현재 GP 완료 대기 | 현재 GP가 끝나면 DONE으로 이동 |
| NEXT_READY | RCU_NEXT_READY_TAIL | 다음 GP에 참여 예정 | 다음 GP 시작 시 WAIT로 이동 |
| NEXT | RCU_NEXT_TAIL | 방금 등록됨 | GP 배정 대기, call_rcu() 시 여기에 추가 |
/* kernel/rcu/rcu_segcblist.h — 4-tail 구조 (간략화) */
struct rcu_segcblist {
struct rcu_head *head; /* 전체 리스트의 시작 */
struct rcu_head **tails[RCU_CBLIST_NSEGS]; /* 4개 tail 포인터 */
/* tails[DONE] → DONE 세그멘트의 끝
* tails[WAIT] → WAIT 세그멘트의 끝
* tails[NEXT_READY] → NEXT_READY 세그멘트의 끝
* tails[NEXT] → NEXT 세그멘트의 끝 (== 전체 리스트 끝) */
long len; /* 전체 콜백 수 */
long seglen[RCU_CBLIST_NSEGS]; /* 세그멘트별 콜백 수 */
};
단순 리스트 대비 4-tail의 이점
- O(1) 세그멘트 이동: GP 완료 시 tail 포인터만 재배치하면 WAIT 세그멘트 전체가 DONE으로 이동. 단순 리스트면 O(n) 순회가 필요합니다.
- O(1) 콜백 추가:
call_rcu()는 항상tails[NEXT]에 추가하므로 O(1)입니다. - GP 중첩 지원: WAIT/NEXT_READY/NEXT 세그멘트가 서로 다른 GP에 매핑되어, 새 콜백이 등록되는 동안에도 이전 GP의 콜백을 처리할 수 있습니다.
Lazy Callback (CONFIG_RCU_LAZY)
CONFIG_RCU_LAZY (v6.2+)를 활성화하면, call_rcu()로 등록된 콜백이 즉시 GP를 트리거하지 않고 배칭됩니다. 콜백이 일정량 쌓이거나 타이머가 만료되면 한꺼번에 GP를 시작합니다.
| 항목 | 일반 모드 | RCU_LAZY 모드 |
|---|---|---|
| call_rcu() 후 GP 시작 | 즉시 (콜백 등록 → wakeup) | 지연 (배칭 후 일괄 시작) |
| 전력 소비 | 잦은 wakeup으로 전력 소모 | wakeup 빈도 감소 → 절전 효과 |
| 메모리 해제 지연 | GP 완료 즉시 | 배칭 지연 + GP 완료 |
| 적합 환경 | 서버, 고성능 | 모바일, 임베디드, 저전력 |
| 강제 실행 | — | call_rcu_hurry()로 lazy → 즉시 승격 |
RCU 보호 해시 테이블
#include <linux/rhashtable.h>
/* rhashtable: 자동 리사이징되는 RCU 해시 테이블 */
struct my_entry {
int key;
char value[64];
struct rhash_head node;
struct rcu_head rcu;
};
static const struct rhashtable_params my_params = {
.key_len = sizeof(int),
.key_offset = offsetof(struct my_entry, key),
.head_offset = offsetof(struct my_entry, node),
};
/* 초기화 */
struct rhashtable ht;
rhashtable_init(&ht, &my_params);
/* 삽입 (writer, 잠금 필요) */
rhashtable_insert_fast(&ht, &entry->node, my_params);
/* 조회 (reader, RCU 보호) */
rcu_read_lock();
struct my_entry *e = rhashtable_lookup_fast(&ht, &key, my_params);
rcu_read_unlock();
/* 삭제 */
rhashtable_remove_fast(&ht, &entry->node, my_params);
kfree_rcu(entry, rcu);
SLAB_TYPESAFE_BY_RCU
SLAB_TYPESAFE_BY_RCU는 slab 캐시 플래그로, 해제된 슬랩 객체가 grace period 내에서 동일 타입으로 재사용될 수 있음을 보장합니다. kfree_rcu()와는 근본적으로 다른 메커니즘입니다.
kfree_rcu와의 차이
| 항목 | kfree_rcu() | SLAB_TYPESAFE_BY_RCU |
|---|---|---|
| 해제 시점 | GP 완료 후 | 즉시 (kfree) |
| 메모리 재사용 | GP 후 임의 타입 | 즉시, 동일 slab 캐시 타입만 |
| reader 안전성 | GP 내 접근 완전 안전 | 재사용된 객체 접근 가능 → 재검증 필수 |
| rcu_head 필요 | O (구조체에 포함) | X (불필요) |
| GP 지연 영향 | 메모리 점유 누적 | 없음 (즉시 반환) |
| 구현 난이도 | 단순 | 높음 (double-check 패턴) |
/* SLAB_TYPESAFE_BY_RCU slab 캐시 생성 */
struct kmem_cache *my_cache;
my_cache = kmem_cache_create("my_objects",
sizeof(struct my_obj), 0,
SLAB_TYPESAFE_BY_RCU, /* ← 핵심 플래그 */
NULL);
/* Writer: 즉시 해제 (GP 대기 불필요) */
spin_lock(&lock);
hlist_del_rcu(&obj->node);
spin_unlock(&lock);
kmem_cache_free(my_cache, obj); /* 즉시 반환! kfree_rcu 불필요 */
/* Reader: double-check 패턴 필수! */
rcu_read_lock();
hlist_for_each_entry_rcu(obj, &bucket, node) {
if (obj->key == target_key) {
/* ①: 키 일치 확인 */
spin_lock(&obj->lock);
/* ②: 재검증 — 해제 후 재사용된 객체일 수 있음 */
if (likely(obj->key == target_key &&
!obj->dead)) {
/* 유효한 객체 확인됨 */
refcount_inc(&obj->ref);
spin_unlock(&obj->lock);
rcu_read_unlock();
return obj;
}
spin_unlock(&obj->lock);
}
}
rcu_read_unlock();
SLAB_TYPESAFE_BY_RCU 핵심 규칙: reader가 찾은 객체는 이미 해제되고 재할당된 다른 인스턴스일 수 있습니다. 따라서 반드시 잠금을 획득한 후 키/상태를 재검증(double-check)해야 합니다. 이 재검증 없이 사용하면 논리적 오류가 발생합니다.
사용 사례
| 사용처 | 이유 |
|---|---|
dcache (dentry) | 수백만 개 dentry의 rcu_head 16바이트 절약, 빈번한 할당/해제 경로 최적화 |
| inode cache | inode 수가 많고 해제 빈도 높음, GP 지연 없는 즉시 재활용 |
PID 할당 (struct pid) | PID 구조체의 빈번한 할당/해제 |
| 네트워크 소켓 | 대량의 소켓 연결/해제 시 GP 지연 축소 |
VMA (vm_area_struct) | per-VMA lock과 결합한 메모리 관리 최적화 |
RCU 콜백 오프로딩 (NOCB)
RCU NOCB(No-Callback)는 RCU 콜백 처리를 softirq에서 전용 kthread로 오프로드하는 메커니즘입니다. 실시간(RT) 시스템이나 HPC 환경에서 특정 CPU의 지터(jitter)를 최소화하는 데 사용됩니다.
NOCB 아키텍처
NOCB 모드에서는 call_rcu()로 등록된 콜백이 해당 CPU의 softirq가 아닌 전용 rcuo kthread에서 실행됩니다.
/* NOCB 동작 흐름:
*
* 1. CPU N에서 call_rcu() 호출
* 2. 콜백이 per-CPU cblist에 등록
* 3. rcuog(RCU offload GP) kthread가 GP 완료 감지
* 4. rcuop(RCU offload process) kthread가 콜백 실행
* → CPU N의 softirq가 아닌 rcuop kthread에서 실행
*
* kthread 종류:
* rcuog/N — GP 감시 kthread (그룹 리더)
* rcuop/N — 콜백 처리 kthread
*/
rcuo kthreads
# NOCB kthread 확인
ps -eo pid,cls,pri,comm | grep rcu
# 출력 예:
# 12 TS 19 rcuog/0 ← GP 감시 (그룹 리더)
# 13 TS 19 rcuop/0 ← 콜백 처리 (CPU 0)
# 14 TS 19 rcuop/1 ← 콜백 처리 (CPU 1)
# ...
# rcuop kthread의 우선순위를 RT로 변경 (지터 최소화)
chrt -f -p 2 $(pgrep -x rcuop/0)
# NOCB CPU 확인
cat /sys/kernel/debug/rcu/rcu_preempt/rcudata | grep -i nocb
CONFIG_RCU_NOCB_CPU
| 설정 | 설명 |
|---|---|
CONFIG_RCU_NOCB_CPU=y | NOCB 지원 활성화 (커널 빌드) |
rcu_nocbs=0-3,8 | 부트 파라미터: 지정 CPU에서 콜백 오프로드 |
rcu_nocbs=all | 모든 CPU에서 콜백 오프로드 |
rcupdate.rcu_nocb_gp_stride=4 | rcuog 그룹당 CPU 수 (기본값: 자동) |
# 커널 부트 파라미터 예
# RT CPU 0-3에서 RCU 콜백 오프로드
GRUB_CMDLINE_LINUX="rcu_nocbs=0-3 isolcpus=0-3 nohz_full=0-3"
# 런타임 NOCB 전환 (Linux 5.18+, CONFIG_RCU_NOCB_CPU_CB_BOOST=y)
echo 1 > /sys/kernel/debug/rcu/rcu_preempt/nocb/0 # CPU 0 NOCB 활성화
echo 0 > /sys/kernel/debug/rcu/rcu_preempt/nocb/0 # CPU 0 NOCB 비활성화
RT/HPC 사용 사례
NOCB + isolcpus + nohz_full 조합: RT/HPC 환경에서 특정 CPU를 최대한 격리하려면 세 가지를 함께 사용합니다.
isolcpus=0-3: 스케줄러에서 CPU 격리nohz_full=0-3: 타이머 틱 제거 (adaptive-ticks)rcu_nocbs=0-3: RCU 콜백 오프로드
이 조합으로 CPU 0-3은 커널 간섭 없이 RT 태스크에 전념할 수 있습니다.
QS 보고 유틸리티 API
Quiescent State(QS) 유틸리티는 RCU에게 "이 CPU는 현재 RCU read-side에 없다"고 명시적으로 알리는 함수들입니다. 장시간 실행 루프에서 RCU stall을 방지하는 데 사용합니다.
| 함수 | 동작 | 사용 시점 |
|---|---|---|
cond_resched_rcu() | RCU unlock → 스케줄링 양보 → RCU lock | RCU read-side 내 장시간 루프 |
cond_resched_tasks_rcu_qs() | RCU-Tasks에게 QS 보고 | 커널 스레드 장시간 루프 |
rcu_all_qs() | 모든 RCU 유형에 QS 보고 (PREEMPT_RCU 전용) | PREEMPT_RCU에서 busy-wait 루프 |
rcu_softirq_qs_periodic(old) | 주기적 softirq QS 보고 | softirq 핸들러 내 장시간 처리 |
rcu_note_context_switch() | 스케줄러가 컨텍스트 스위치 시 RCU에 통지 | 스케줄러 내부 (직접 호출 불필요) |
rcu_cpu_stall_reset() | stall 감지 타이머 리셋 | 의도적 장시간 작업 전 |
/* cond_resched_rcu(): 장시간 RCU 순회에서 stall 방지 */
rcu_read_lock();
list_for_each_entry_rcu(item, &very_long_list, node) {
process(item);
cond_resched_rcu(); /* 내부: rcu_read_unlock → cond_resched → rcu_read_lock */
}
rcu_read_unlock();
/* cond_resched_tasks_rcu_qs(): 커널 스레드에서 RCU-Tasks QS */
while (!kthread_should_stop()) {
do_work();
cond_resched_tasks_rcu_qs(); /* RCU-Tasks GP 진행 허용 */
}
/* rcu_all_qs(): PREEMPT_RCU에서 명시적 QS */
/* non-preempt 커널에서는 no-op */
for (i = 0; i < LARGE_COUNT; i++) {
process_item(i);
if (!(i % 1024))
rcu_all_qs(); /* 1024 반복마다 QS 보고 */
}
/* rcu_cpu_stall_reset(): 의도적 장시간 작업 전 stall 타이머 리셋 */
rcu_cpu_stall_reset();
/* ... 수 초 걸리는 의도적 작업 ... */
cond_resched_rcu() vs rcu_all_qs(): cond_resched_rcu()는 RCU를 해제하고 스케줄링 양보까지 수행하므로 더 무겁지만 확실합니다. rcu_all_qs()는 PREEMPT_RCU에서만 효과가 있으며, RCU read-side CS 밖에서만 호출해야 합니다.
RCU 디버깅
디버깅 관련 CONFIG 옵션
| CONFIG 옵션 | 기능 |
|---|---|
CONFIG_PROVE_RCU | RCU 잠금 규칙 위반 감지 (lockdep 통합) |
CONFIG_RCU_TRACE | RCU 이벤트 tracepoint 활성화 |
CONFIG_RCU_CPU_STALL_TIMEOUT | RCU CPU stall 감지 타임아웃 (기본 21초) |
CONFIG_RCU_BOOST | RCU reader 우선순위 부스팅 (RT 커널) |
CONFIG_RCU_CPU_STALL_CPUTIME | stall 시 CPU 시간 통계 함께 출력 |
CONFIG_RCU_EXP_KTHREAD | expedited grace period 전용 kthread 사용 |
# 런타임에서 stall 타임아웃 조정 (초 단위)
echo 60 > /sys/module/rcupdate/parameters/rcu_cpu_stall_timeout
# stall 경고 억제 (디버깅 시 잠시 끄기)
echo 1 > /sys/module/rcupdate/parameters/rcu_cpu_stall_suppress
# /sys/kernel/debug/rcu/ 에서 RCU 상태 확인
cat /sys/kernel/debug/rcu/rcu_preempt/rcudata
디버깅/검증 API 상세
| 함수/매크로 | 동작 | 사용 시점 |
|---|---|---|
RCU_LOCKDEP_WARN(cond, msg) | cond이 참이면 lockdep 경고 출력 | RCU 규칙 위반 감지 |
rcu_sleep_check() | RCU read-side에서 sleep 시도 감지 | might_sleep() 내부에서 호출 |
rcu_is_watching() | 현재 CPU가 RCU 감시 중인지 확인 | idle/offline 상태 확인 |
rcu_head_init(head) | rcu_head를 디버그 모드로 초기화 | DEBUG_OBJECTS_RCU_HEAD 사용 시 |
rcu_head_after_call_rcu(head, func) | rcu_head가 call_rcu() 후 상태인지 확인 | double-free 감지 |
rcu_check_sparse(p, space) | sparse 타입 검증 | __rcu 어노테이션 검증 |
/* RCU_LOCKDEP_WARN 사용 예 */
RCU_LOCKDEP_WARN(!rcu_read_lock_held() &&
!lockdep_is_held(&my_mutex),
"must hold RCU read lock or my_mutex");
/* rcu_is_watching(): CPU가 RCU 감시 상태인지 확인
* idle이나 offline CPU에서는 false 반환 */
if (rcu_is_watching()) {
/* RCU 연산 안전 */
p = rcu_dereference(gbl_ptr);
}
/* rcu_head_init: DEBUG_OBJECTS_RCU_HEAD 활성화 시 사용 */
#ifdef CONFIG_DEBUG_OBJECTS_RCU_HEAD
rcu_head_init(&obj->rcu); /* 초기화 추적 시작 */
#endif
/* rcu_head_after_call_rcu: call_rcu() 후 중복 호출 감지 */
if (rcu_head_after_call_rcu(&obj->rcu, my_callback)) {
/* 이미 call_rcu()가 호출된 rcu_head — double-free 방지 */
WARN_ON(1);
}
ftrace/tracepoint 실습 예제
RCU 내부 동작을 실시간으로 추적하려면 ftrace tracepoint를 활용합니다. CONFIG_RCU_TRACE=y가 필요합니다.
Grace Period 추적
# trace-cmd로 RCU GP 이벤트 기록
trace-cmd record -e rcu:rcu_grace_period -e rcu:rcu_grace_period_init \
-e rcu:rcu_quiescent_state_report sleep 5
# 결과 확인
trace-cmd report | head -30
# 출력 예시:
# rcu_gp-18 [000] rcu_grace_period: rcu_preempt 12345 start
# <idle>-0 [003] rcu_quiescent_state: rcu_preempt 12345 cpu=3
# rcu_gp-18 [000] rcu_grace_period: rcu_preempt 12345 end
# GP 시작(start) → 각 CPU QS 보고 → GP 종료(end) 흐름 확인
콜백 실행 추적
# RCU 콜백 관련 이벤트
trace-cmd record -e rcu:rcu_callback -e rcu:rcu_batch_start \
-e rcu:rcu_batch_end -e rcu:rcu_invoke_callback sleep 5
# 출력 예시:
# ksoftirqd/2 [002] rcu_batch_start: rcu_preempt CBs=15 bl=10
# ksoftirqd/2 [002] rcu_invoke_callback: rcu_preempt rhp=ffff... func=kfree_rcu_work
# ksoftirqd/2 [002] rcu_batch_end: rcu_preempt CBs-invoked=10 idle=....
# CBs: 대기 콜백 수, bl: blimit, CBs-invoked: 실행된 콜백 수
RCU utilization 이벤트
# rcu_utilization: RCU 내부 함수 진입/탈출 추적
trace-cmd record -e rcu:rcu_utilization sleep 2
trace-cmd report | grep -c "Start" # GP kthread 활성 횟수
trace-cmd report | grep -c "End" # GP kthread 완료 횟수
# 비율이 불균형하면 GP kthread가 과부하 상태
perf 기반 GP 지연 분석
# perf로 RCU 이벤트 통계 수집
perf stat -e 'rcu:rcu_grace_period' -e 'rcu:rcu_batch_start' \
-e 'rcu:rcu_invoke_callback' -a sleep 10
# 출력 예시:
# 1,234 rcu:rcu_grace_period (GP 횟수)
# 5,678 rcu:rcu_batch_start (콜백 배치 횟수)
# 12,345 rcu:rcu_invoke_callback (개별 콜백 실행 횟수)
# GP당 평균 콜백 수 = invoke / grace_period ≈ 10
# 이 값이 blimit에 가까우면 콜백 배치가 제대로 동작하는 것
Stall 원인 축소: /proc/sched_debug 활용
# RCU stall 발생 시 런큐 상태 확인
cat /proc/sched_debug | grep -A 10 "cpu#3"
# 확인 포인트:
# - .nr_running: 실행 대기 태스크 수 (매우 높으면 CPU 포화)
# - .curr: 현재 실행 중인 태스크 (RCU reader가 점유 중인지)
# - .clock: 마지막 스케줄러 틱 시간 (오래되었으면 틱 중단)
# softirq 지연 확인
cat /proc/softirqs | grep RCU
# RCU_SOFTIRQ 카운터가 특정 CPU에서 멈춰 있으면 해당 CPU에서
# softirq가 실행되지 못하는 상황 (IRQ disabled 장시간 등)
실전 디버깅 순서: (1) /sys/kernel/debug/rcu/rcu_preempt/rcudata로 per-CPU 상태 확인 → (2) trace-cmd로 GP/QS 이벤트 기록 → (3) /proc/sched_debug로 stall CPU의 스케줄링 상태 확인 → (4) perf stat으로 전체 RCU 처리량 정량 평가.
RCU CPU Stall 경고 심화
심화 디버깅 가이드: 이 섹션은 앞서 다룬 RCU 메커니즘(read-side critical section, grace period, 콜백 처리)의 이해를 전제로 합니다. RCU stall은 grace period가 완료되지 못하는 상황이므로, RCU의 기본 동작 원리를 먼저 숙지한 후 읽으시기 바랍니다.
RCU CPU stall 경고는 grace period가 비정상적으로 오래 지속될 때 커널이 출력하는 진단 메시지입니다. 이 메시지는 시스템 행(hang), 성능 저하, 데드락의 근본 원인을 추적하는 핵심 단서입니다.
Stall 감지 메커니즘
RCU는 grace period 시작 후 일정 시간(CONFIG_RCU_CPU_STALL_TIMEOUT, 기본 21초) 내에 모든 CPU가 quiescent state를 보고하지 않으면 stall로 판단합니다:
/* kernel/rcu/tree_stall.h — stall 감지 흐름 */
/*
* 1. grace period 시작 (gp_seq 증가)
* 2. 타이머 설정: jiffies + rcu_cpu_stall_timeout
* 3. 타이머 만료 시점에 아직 미응답 CPU가 있으면:
* → rcu_check_gp_stall_expiry() 호출
* → print_cpu_stall() 또는 print_other_cpu_stall() 실행
* 4. 첫 경고 후 추가 타임아웃마다 반복 경고 출력
*/
static void check_cpu_stall(struct rcu_data *rdp)
{
unsigned long gs1, gs2, gps; /* grace-period 시퀀스 */
unsigned long j, js; /* jiffies, stall 시점 */
gs1 = READ_ONCE(rcu_state.gp_seq);
js = READ_ONCE(rcu_state.jiffies_stall);
gps = READ_ONCE(rcu_state.gp_start);
j = jiffies;
if (rcu_gp_in_progress() &&
time_after(j, js)) {
/* 자기 자신이 stall 중인지 확인 */
if (rcu_is_cpu_rrupt_from_idle()) {
/* idle → 정상, QS 보고 */
} else if (ULONG_CMP_GE(j, js + RCU_STALL_RAT_DELAY)) {
/* 자기 자신이 stall → print_cpu_stall() */
print_cpu_stall(gps, gs1);
} else {
/* 다른 CPU가 stall → print_other_cpu_stall() */
print_other_cpu_stall(gs2, gps);
}
}
}
Stall 유형
커널은 두 가지 유형의 stall 메시지를 출력합니다:
| 유형 | 의미 | 메시지 패턴 |
|---|---|---|
| Self-detected stall | 현재 CPU 자신이 QS를 보고하지 못함 | self-detected stall on CPU |
| Other CPU stall | 다른 CPU(들)가 QS를 보고하지 못함 | detected stalls on CPUs/tasks |
Stall 메시지 해부
실제 커널이 출력하는 RCU stall 메시지를 필드별로 분석합니다. 메시지의 각 부분이 무엇을 의미하는지 이해하면 근본 원인을 빠르게 좁힐 수 있습니다.
다른 CPU에서 감지된 stall
/* 실제 메시지 (줄 바꿈은 가독성을 위해 추가) */
rcu: INFO: rcu_sched detected stalls on CPUs/tasks:
2-...!: (1 GPs behind) idle=fb2/1/0x4000000000000000
softirq=1254/1280 fqs=14979
(detected by 0, t=21003 jiffies, g=300921, q=24)
rcu: rcu_sched kthread starved for 23001 jiffies!
이 메시지를 한 줄씩 해석합니다:
| 필드 | 값 | 의미 |
|---|---|---|
rcu_sched | - | stall이 발생한 RCU flavor (rcu_preempt, rcu_sched 등) |
2-...! | CPU 2 | stall 중인 CPU 번호. !는 해당 CPU가 오프라인 또는 softirq/hardirq 컨텍스트에 있음을 표시 |
(1 GPs behind) | 1 | 해당 CPU가 현재 grace period보다 몇 GP 뒤처져 있는지 |
idle=fb2/1/0x4... | 3개 값 | idle 상태 추적: dynticks nesting / dynticks nmi nesting / dynticks counter |
softirq=1254/1280 | 처리/발생 | softirq 카운터: 처리된 수 / 발생한 수. 차이가 크면 softirq 처리 지연 |
fqs=14979 | 14979 | force-quiescent-state 스캔 횟수. 높을수록 오래 기다린 것 |
detected by 0 | CPU 0 | stall을 감지(보고)한 CPU 번호 |
t=21003 | 21003 | grace period 시작 후 경과한 jiffies (≈21초) |
g=300921 | 300921 | stall 중인 grace period 번호 (gp_seq) |
q=24 | 24 | stall 감지 시점까지 QS를 보고한 CPU 수 |
Self-detected stall
/* self-detected stall 메시지 */
rcu: INFO: rcu_preempt self-detected stall on CPU
3-....: (21005 ticks this GP) idle=4ce/1/0x4000000000000002
(t=21031 jiffies g=41052 q=55 ncpus=8)
rcu: NMI backtrace for cpu 3
/* ... 스택 트레이스 출력 ... */
| 필드 | 의미 |
|---|---|
3-....: | CPU 3에서 self-detected. 접미사 .은 정상, !은 문제 있음 |
(21005 ticks this GP) | 이 grace period 동안 해당 CPU에서 경과한 틱 수 |
ncpus=8 | 시스템의 온라인 CPU 수 |
| NMI backtrace | stall 시점의 CPU 스택 트레이스 (NMI로 강제 덤프) |
CPU 상태 플래그 상세
CPU 번호 뒤의 플래그 문자(2-...!)는 해당 CPU의 상태를 나타냅니다:
/* kernel/rcu/tree_stall.h — CPU 상태 플래그 */
/*
* 포맷: CPU번호-FLAG1FLAG2FLAG3FLAG4FLAG5
*
* 위치 의미
* ---- -------------------------------------------
* 1번째 'O' = 오프라인, '.' = 온라인
* 2번째 'o' = RCU가 해당 CPU를 오프라인으로 인식, '.' = 아님
* 3번째 'N' = 틱 기반 QS 대기 중, '.' = 아님
* 4번째 'D' = dynticks 확장 QS, '.' = 아님
* 5번째 '!' = hardirq/softirq/NMI 컨텍스트, '.' = 아님
*/
/* 예시 해석 */
2-...!: /* CPU 2, 온라인, RCU온라인, 틱QS없음, dyntick없음,
irq 컨텍스트에서 멈춤 */
5-....: /* CPU 5, 모두 정상 — 일반 컨텍스트에서 stall */
7-O...: /* CPU 7, 오프라인 상태 — hotplug 관련 문제 */
idle 필드 상세 해석
/* idle=AAA/BBB/CCC 형식 */
idle=fb2/1/0x4000000000000000
/*
* AAA (fb2) = dynticks nesting 카운터
* - 짝수: CPU가 extended QS(idle/usermode) 안에 있음
* - 홀수: CPU가 커널 코드 실행 중
* - 값 자체는 nesting 깊이 추적용
*
* BBB (1) = dynticks NMI nesting 카운터
* - 0: NMI/IRQ가 아님
* - 양수: NMI 또는 hardirq nesting 레벨
*
* CCC (0x4000000000000000) = dynticks 상태 카운터
* - 짝수: idle/offline (RCU가 무시 가능)
* - 홀수: 활성 상태 (QS 보고 필요)
* - 비트 62: 0x4... → 해당 CPU가 grace period를 인식했음을 표시
*/
태스크 기반 stall (PREEMPT_RCU)
CONFIG_PREEMPT_RCU 커널에서는 특정 태스크가 RCU read-side critical section 안에서 선점된 채 stall될 수 있습니다:
/* 태스크 stall 메시지 예시 */
rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:
P3271 /* ← CPU가 아닌 PID 3271 태스크가 stall! */
(detected by 5, t=21007 jiffies, g=82150, q=30)
rcu: rcu_preempt kthread starved for 15000 jiffies!
/*
* P3271 → PID 3271이 rcu_read_lock() 안에서 선점되어
* grace period 완료를 막고 있음
*
* 확인 방법:
* cat /proc/3271/stack # 해당 태스크의 커널 스택
* cat /proc/3271/status # 태스크 상태 및 스케줄링 정보
*/
kthread starved 메시지
/* kthread starvation 메시지 */
rcu: rcu_sched kthread starved for 23001 jiffies!
last ran 23001 jiffies ago on CPU 4
with state 0x2
/*
* RCU grace-period kthread(rcuog/rcuop)가 스케줄링되지 못함
*
* state 값 해석 (task_state):
* 0x0 = TASK_RUNNING (실행 가능하지만 CPU 시간을 못 받음)
* 0x1 = TASK_INTERRUPTIBLE
* 0x2 = TASK_UNINTERRUPTIBLE (D 상태 — I/O 대기 등)
*
* state=0x0(RUNNING)인데 실행 못 함:
* → 높은 우선순위 태스크에 밀림 (RT 우선순위 문제)
* → CPU가 인터럽트 폭주 중
*
* state=0x2(UNINTERRUPTIBLE):
* → kthread가 I/O나 락 대기 중
* → 메모리 부족으로 할당 대기 가능
*/
주요 Stall 원인과 진단
| 원인 | 증상 | 진단 방법 |
|---|---|---|
| 인터럽트 비활성화 상태에서 긴 루프 | 단일 CPU stall, idle 값 홀수 | NMI backtrace에서 local_irq_disable() 추적 |
| preemption 비활성화 + 긴 연산 | self-detected, 틱 카운트 높음 | backtrace에서 preempt_disable() 호출 위치 확인 |
| softirq 폭주 | softirq 처리/발생 차이 큼 | /proc/softirqs 비교, NET_RX 등 확인 |
| RCU read-side critical section 너무 김 | 태스크 stall (Pnnnn) | /proc/PID/stack으로 rcu_read_lock() 위치 확인 |
| RCU kthread 스케줄링 불가 | kthread starved 메시지 | chrt -p $(pidof rcuog/0)으로 우선순위 확인 |
| 실시간(RT) 태스크가 CPU 독점 | 여러 CPU stall, kthread starved | ps -eo pid,cls,rtprio,comm | grep -E "FF|RR" |
| 하드웨어 문제 (NMI 폭주, 메모리 오류) | 불규칙한 stall, MCE 동반 | dmesg | grep -i mce, /proc/interrupts의 NMI 카운트 |
| 가상머신 vCPU steal time | 여러 CPU 동시 stall | /proc/stat의 steal 값, 호스트 과부하 확인 |
Stall 디버깅 순서
/* RCU stall 발생 시 단계별 디버깅 절차 */
/* 1단계: 메시지 유형 파악 */
# self-detected → 해당 CPU에서 원인 찾기
# detected on CPUs/tasks → 나열된 CPU/태스크 조사
# kthread starved → 스케줄링 문제 우선 의심
/* 2단계: NMI backtrace 분석 */
# stall 메시지 직후의 스택 트레이스를 확인
# → 어떤 함수에서 멈춰 있는지가 핵심 단서
/* 3단계: idle 필드로 CPU 상태 판단 */
# idle=짝수/... → CPU가 idle인데 QS 미보고 (RCU 버그?)
# idle=홀수/... → 커널 실행 중 (코드 경로 추적 필요)
/* 4단계: softirq 카운터 확인 */
$ cat /proc/softirqs # 각 CPU별 softirq 카운트
$ watch -n1 cat /proc/softirqs # 실시간 변화 관찰
# 특정 CPU의 특정 softirq(NET_RX 등)가 급증하면 폭주 의심
/* 5단계: CPU 스케줄링 상태 확인 */
$ cat /proc/sched_debug # 각 CPU 런큐 상태
$ cat /proc/PID/sched # 특정 태스크 스케줄링 통계
/* 6단계: RCU 내부 상태 확인 */
$ cat /sys/kernel/debug/rcu/rcu_preempt/rcudata
# cpu, ctw, gpc, tne 등 per-CPU RCU 상태
$ cat /sys/kernel/debug/rcu/rcu_preempt/rcugp
# grace period 진행 상태
실전 사례별 메시지 분석
사례 1: spinlock 데드락으로 인한 stall
/* 메시지 */
rcu: INFO: rcu_sched self-detected stall on CPU
3-....: (63015 ticks this GP) idle=9b6/0/0x1
NMI backtrace for cpu 3
Call Trace:
<IRQ>
native_queued_spin_lock_slowpath+0x1c5/0x200
_raw_spin_lock+0x30/0x40
my_driver_irq_handler+0x42/0x150 [my_driver]
__handle_irq_event_percpu+0x4c/0x1c0
/*
* 분석:
* - idle=9b6 (짝수가 아님 → 커널 코드 실행 중)
* - IRQ 컨텍스트에서 spinlock 획득 대기 중 (spin_lock_slowpath)
* - my_driver 인터럽트 핸들러가 이미 다른 컨텍스트에서
* 잡고 있는 락을 재요청 → ABBA 데드락 또는 irq-safe 미사용
*
* 해결:
* - spin_lock() → spin_lock_irqsave()로 변경
* - lockdep(CONFIG_PROVE_LOCKING)으로 데드락 패턴 확인
*/
사례 2: 커널 모듈의 무한 루프
/* 메시지 */
rcu: INFO: rcu_preempt detected stalls on CPUs/tasks:
5-....: (1 GPs behind) idle=d32/1/0x4000000000000001
softirq=8532/8532 fqs=10521
NMI backtrace for cpu 5
Call Trace:
buggy_poll_status+0x18/0x30 [my_module]
buggy_workqueue_fn+0x85/0xb0 [my_module]
process_one_work+0x1e5/0x3f0
worker_thread+0x50/0x3c0
/*
* 분석:
* - softirq=8532/8532 (차이 0 → softirq 폭주 아님)
* - fqs=10521 (매우 높음 → 오래 기다림)
* - idle의 마지막 값이 홀수(0x...1) → CPU가 활성 상태
* - backtrace: workqueue에서 실행 중인 buggy_poll_status가
* 하드웨어 상태를 무한 폴링 (타임아웃 없는 busy-wait)
*
* 해결:
* - 폴링 루프에 cond_resched() 또는 타임아웃 추가
* - 또는 wait_event_timeout()으로 이벤트 기반 대기로 전환
*/
사례 3: RT 태스크로 인한 kthread starvation
/* 메시지 */
rcu: INFO: rcu_sched detected stalls on CPUs/tasks:
0-....: (1 GPs behind) idle=21a/1/0x4000000000000001
1-....: (1 GPs behind) idle=b7e/1/0x4000000000000001
rcu: rcu_sched kthread starved for 52003 jiffies!
last ran 52003 jiffies ago on CPU 0
with state 0x0
/*
* 분석:
* - 여러 CPU가 동시에 stall (CPU 0, 1)
* - kthread starved: state=0x0 → TASK_RUNNING인데 실행 못 함
* → 높은 우선순위 태스크에 밀려서 스케줄링 안 됨
*
* 진단:
* # RCU kthread 우선순위 확인
* chrt -p $(pgrep rcu_sched)
* → SCHED_OTHER (일반 스케줄링)
*
* # RT 태스크 확인
* ps -eo pid,cls,rtprio,psr,comm | grep -E "FF|RR"
* → PID 1500 FIFO 99 0 stress-rt (CPU 0 독점)
* → PID 1501 FIFO 99 1 stress-rt (CPU 1 독점)
*
* 해결:
* - CONFIG_RCU_BOOST=y → RCU 우선순위 부스팅 활성화
* - RT 태스크에 sched_yield() 또는 주기적 sleep 추가
* - rcutree.kthread_prio=2 커널 파라미터로 RCU kthread RT 우선순위 부여
*/
사례 4: 가상머신 steal time
/* 메시지 */
rcu: INFO: rcu_sched detected stalls on CPUs/tasks:
0-....: (1 GPs behind) idle=c2e/1/0x4000000000000000
1-....: (1 GPs behind) idle=a14/1/0x4000000000000000
2-....: (1 GPs behind) idle=098/1/0x4000000000000000
3-....: (1 GPs behind) idle=f40/1/0x4000000000000000
rcu: rcu_sched kthread starved for 45002 jiffies!
/*
* 분석:
* - 모든 CPU가 동시에 stall → 호스트 레벨 문제 의심
* - idle 카운터의 마지막 값이 짝수(0x...0) → CPU들이 idle이었음!
* → vCPU가 호스트에서 스케줄링되지 못해 idle 탈출 불가
*
* 진단:
* # steal time 확인
* cat /proc/stat | head -5
* → cpu 1234 56 7890 12345 0 0 [steal] 0 0 0
* # steal 값이 높으면 호스트가 vCPU 시간을 빼앗은 것
*
* # VM 안에서 확인
* vmstat 1
* → st 컬럼(steal time %) 확인
*
* 해결:
* - 호스트 과부하 해소 (VM 밀도 감소)
* - RCU stall 타임아웃 증가:
* echo 60 > /sys/module/rcupdate/parameters/rcu_cpu_stall_timeout
* - rcupdate.rcu_cpu_stall_timeout=60 커널 파라미터
*/
Stall 예방 패턴
/* ✗ 나쁜 패턴: 긴 루프에서 RCU 차단 */
rcu_read_lock();
for (i = 0; i < 1000000; i++) {
process_item(items[i]); /* RCU critical section이 너무 김 */
}
rcu_read_unlock();
/* ✓ 좋은 패턴: 주기적으로 RCU 구간 재시작 */
for (i = 0; i < 1000000; i++) {
rcu_read_lock();
process_item(rcu_dereference(items[i]));
rcu_read_unlock();
if (need_resched())
cond_resched(); /* 선점 포인트 제공 */
}
/* ✓ 좋은 패턴: 긴 루프에서 cond_resched_rcu() */
rcu_read_lock();
list_for_each_entry_rcu(entry, &my_list, list) {
process(entry);
cond_resched_rcu(); /* RCU unlock → resched → lock */
}
rcu_read_unlock();
/* ✗ 나쁜 패턴: 인터럽트 핸들러에서 타임아웃 없는 폴링 */
while (!(readl(reg) & DONE_BIT))
; /* 하드웨어 응답 없으면 영원히 대기 */
/* ✓ 좋은 패턴: 타임아웃이 있는 폴링 */
unsigned long timeout = jiffies + msecs_to_jiffies(100);
while (!(readl(reg) & DONE_BIT)) {
if (time_after(jiffies, timeout)) {
dev_err(dev, "device timeout\\n");
return -ETIMEDOUT;
}
cpu_relax();
}
echo 1 > /sys/kernel/debug/tracing/events/rcu/enable로 RCU tracepoint를 활성화하면 grace period 진행, QS 보고, 콜백 실행 등을 실시간으로 추적할 수 있습니다.
RCU 관련 주요 버그 사례
RCU는 강력한 동기화 메커니즘이지만, 잘못 사용하면 use-after-free, CPU stall, 커널 크래시 등 심각한 버그로 이어집니다. 아래는 실전에서 자주 발생하는 RCU 관련 버그 패턴과 올바른 해결 방법입니다.
1. RCU Use-After-Free 클래식 패턴
rcu_read_lock() 없이 RCU 보호 구조체에 접근하거나, call_rcu() 콜백에서 객체를 해제한 뒤 다른 CPU에서 해당 객체를 참조하면 use-after-free가 발생합니다. 특히 list_for_each_entry_rcu()로 리스트를 순회하는 도중 다른 CPU가 요소를 해제하면 위험합니다.
rcu_dereference()를 호출하면 컴파일러 최적화로 인해 dangling pointer를 참조할 수 있습니다. 이는 KASAN으로도 재현이 어려운 간헐적 크래시를 유발합니다.
/* ✗ 잘못된 패턴: rcu_read_lock() 없이 RCU 보호 포인터 접근 */
struct my_data *p;
p = rcu_dereference(global_ptr); /* BUG: RCU read lock 없음 */
do_something(p->field); /* use-after-free 가능 */
/* ✗ 잘못된 패턴: 순회 중 요소 해제 */
rcu_read_lock();
list_for_each_entry_rcu(entry, &head, list) {
if (entry->should_delete) {
list_del_rcu(&entry->list);
kfree(entry); /* BUG: 다른 CPU가 아직 참조 중일 수 있음 */
}
}
rcu_read_unlock();
/* ✓ 올바른 패턴: RCU read lock + call_rcu()로 지연 해제 */
static void my_rcu_free(struct rcu_head *head)
{
struct my_data *p = container_of(head, struct my_data, rcu);
kfree(p);
}
/* 읽기 측: 반드시 rcu_read_lock() 안에서 접근 */
rcu_read_lock();
p = rcu_dereference(global_ptr);
if (p)
do_something(p->field);
rcu_read_unlock();
/* 업데이트 측: list_del_rcu() 후 call_rcu()로 지연 해제 */
spin_lock(&my_lock);
list_del_rcu(&entry->list);
spin_unlock(&my_lock);
call_rcu(&entry->rcu, my_rcu_free); /* grace period 후 안전하게 해제 */
CONFIG_PROVE_RCU=y를 활성화하면 lockdep 기반으로 RCU read-side critical section 밖에서의 rcu_dereference() 호출, 잘못된 RCU API 사용 등을 런타임에 감지합니다. 개발 및 테스트 환경에서는 반드시 활성화하십시오.
2. RCU CPU Stall 실전 디버깅
커널 로그에 "rcu: INFO: rcu_sched self-detected stall on CPU" 메시지가 출력되는 것은 특정 CPU가 RCU grace period 완료에 필요한 quiescent state를 보고하지 못하고 있다는 의미입니다. 주요 원인은 인터럽트 비활성화 상태에서의 장시간 실행, tight loop에서의 cond_resched() 누락 등입니다.
local_irq_disable() 또는 spin_lock_irqsave())한 상태에서 장시간 실행하면 해당 CPU는 RCU quiescent state를 보고할 수 없어 grace period가 차단됩니다. 이는 전체 시스템의 RCU 콜백 처리를 지연시키고, 메모리 사용량 급증으로 이어질 수 있습니다.
/* ✗ 잘못된 패턴: tight loop에서 cond_resched() 누락 */
rcu_read_lock();
list_for_each_entry_rcu(item, &very_long_list, node) {
expensive_processing(item); /* 수천 개 항목 처리 시 stall 발생 */
}
rcu_read_unlock();
/* ✓ 올바른 패턴: cond_resched_rcu()로 주기적 양보 */
rcu_read_lock();
list_for_each_entry_rcu(item, &very_long_list, node) {
expensive_processing(item);
cond_resched_rcu(); /* RCU unlock → 스케줄링 → RCU lock */
}
rcu_read_unlock();
/* RCU CPU Stall Timeout 튜닝 (기본값: 21초) */
/* 부팅 파라미터로 조정 */
rcupdate.rcu_cpu_stall_timeout=60 /* 60초로 확장 (디버깅 시 유용) */
/* 런타임 상태 확인 */
/* /sys/kernel/debug/rcu/rcu_preempt/rcudata 내용 예시: */
/* 0 c=12345 g=12346 pq=1 qp=1 dt=5231/1/0 dn=3 ... */
/* c: completed grace period, g: current grace period */
/* pq: passed quiescent state, qp: quiescent state pending */
/* dt: dyntick idle info, dn: dyntick nesting */
/sys/kernel/debug/rcu/rcu_preempt/rcudata에서 어떤 CPU가 quiescent state를 보고하지 않는지 확인합니다. (2) /proc/<pid>/stack으로 해당 CPU에서 실행 중인 태스크의 콜 스택을 확인합니다. (3) ftrace의 irqsoff tracer로 인터럽트 비활성화 구간을 측정합니다.
3. 모듈 언로드 시 RCU 콜백 미완료 문제
커널 모듈이 call_rcu()로 콜백을 등록한 후, 해당 콜백이 실행되기 전에 모듈이 언로드되면 콜백 함수의 코드가 이미 해제된 메모리 영역을 가리키게 됩니다. 이후 RCU가 콜백을 실행하려 하면 커널 크래시(page fault)가 발생합니다.
call_rcu()는 비동기적으로 콜백을 등록합니다. grace period는 수 밀리초에서 수십 밀리초 소요되므로, module_exit()에서 rcu_barrier()를 호출하지 않으면 모듈 코드 영역이 해제된 후 콜백이 실행되어 커널 패닉이 발생합니다.
/* ✗ 잘못된 패턴: rcu_barrier() 없이 모듈 언로드 */
static void my_rcu_callback(struct rcu_head *head)
{
struct my_obj *obj = container_of(head, struct my_obj, rcu);
kfree(obj);
}
static void my_remove(struct my_obj *obj)
{
list_del_rcu(&obj->list);
call_rcu(&obj->rcu, my_rcu_callback); /* 콜백 등록 (아직 미실행) */
}
static void __exit my_module_exit(void)
{
my_cleanup_all();
/* BUG: rcu_barrier() 누락 — 콜백 완료 전에 모듈 코드 해제됨 */
}
/* ✓ 올바른 패턴: rcu_barrier()로 콜백 완료 대기 */
static void __exit my_module_exit(void)
{
my_cleanup_all(); /* 모든 객체에 대해 call_rcu() 호출 */
rcu_barrier(); /* 모든 CPU의 RCU 콜백 완료 대기 */
/* 이제 안전하게 모듈 언로드 가능 */
}
module_exit(my_module_exit);
call_rcu(), call_srcu(), call_rcu_tasks() 등 비동기 RCU 콜백을 사용하는 모듈은 module_exit()에서 해당 barrier(rcu_barrier(), srcu_barrier() 등)를 호출해야 합니다. CONFIG_MODULE_UNLOAD=y 환경에서 이를 누락하면 모듈 언로드 경로에서 Use-After-Free 또는 커널 크래시로 이어질 수 있습니다.
4. SRCU vs RCU 선택 오류 사례
일반 RCU의 read-side critical section에서는 sleep이 불가능합니다(CONFIG_PREEMPT_RCU에서도 voluntary sleep은 금지). 블록 I/O 완료 경로, 파일시스템 코드 등 sleep이 필요한 구간에서 일반 RCU를 사용하면 데드락이나 RCU stall이 발생합니다. 이때 SRCU(Sleepable RCU)를 사용해야 합니다.
rcu_read_lock()과 rcu_read_unlock() 사이에서 mutex_lock(), kmalloc(GFP_KERNEL), copy_to_user() 등 sleep 가능 함수를 호출하면 CONFIG_PROVE_RCU 환경에서 "suspicious RCU usage" 경고가 출력되며, 최악의 경우 grace period가 무한정 지연됩니다.
/* ✗ 잘못된 패턴: sleep 가능 경로에서 일반 RCU 사용 */
rcu_read_lock();
p = rcu_dereference(shared_ptr);
mutex_lock(&p->mutex); /* BUG: sleep 가능! */
result = vfs_read(p->file, ...); /* BUG: 블록 I/O 발생 가능 */
mutex_unlock(&p->mutex);
rcu_read_unlock();
/* ✓ 올바른 패턴: SRCU 사용 */
DEFINE_STATIC_SRCU(my_srcu);
/* 읽기 측: srcu_read_lock()은 sleep 허용 */
int idx;
idx = srcu_read_lock(&my_srcu);
p = srcu_dereference(shared_ptr, &my_srcu);
mutex_lock(&p->mutex); /* OK: SRCU read-side에서 sleep 가능 */
result = vfs_read(p->file, ...); /* OK: 블록 I/O도 안전 */
mutex_unlock(&p->mutex);
srcu_read_unlock(&my_srcu, idx);
/* 업데이트 측 */
rcu_assign_pointer(shared_ptr, new_data);
synchronize_srcu(&my_srcu); /* SRCU grace period 대기 */
kfree(old_data);
srcu_struct 인스턴스 사용. (4) SRCU 사용 시 하나의 srcu_struct를 여러 무관한 용도로 공유하면 grace period가 불필요하게 길어지므로, 용도별로 분리하십시오.
성능 튜닝 가이드라인
RCU 성능은 워크로드 특성에 따라 크게 달라집니다. 이 섹션에서는 워크로드별 최적 설정과 주요 파라미터 튜닝 방법을 안내합니다.
워크로드별 튜닝 결정 트리
주요 튜닝 파라미터
| 파라미터 | 기본값 | 조정 시점 | 효과 |
|---|---|---|---|
blimit | 10 | 콜백 큐 폭증 시 | 한 번에 처리하는 최대 콜백 수. 값 증가 → 콜백 처리 속도↑, softirq 지연↑ |
qhimark | 10000 | 메모리 압박 시 | 콜백 수가 이 값 초과 → GP 강제 시작. 낮추면 메모리 소비↓ |
qlowmark | 100 | qhimark와 쌍으로 | 콜백 수가 이 값 이하로 떨어지면 강제 GP 해제 |
jiffies_till_first_fqs | 3 (jiffies) | GP 지연 민감 시 | 첫 FQS까지 대기 시간. 줄이면 GP 빨라지지만 CPU 부하↑ |
jiffies_till_next_fqs | 3 (jiffies) | GP 지연 민감 시 | FQS 간 대기 시간 |
kthread_prio | 0 | RT 환경 | RCU kthread의 SCHED_FIFO 우선순위. RT 환경에서는 2~3 권장 |
# 런타임 튜닝 예시
echo 30 > /sys/module/rcutree/parameters/blimit # 콜백 배치 크기 증가
echo 5000 > /sys/module/rcutree/parameters/qhimark # 강제 GP 임계값 하향
echo 1 > /sys/module/rcutree/parameters/jiffies_till_first_fqs # FQS 빠르게
NOCB + nohz_full + isolcpus 최적 조합
CPU 격리 워크로드에서 최적의 조합:
# 커널 부트 파라미터 예시: CPU 4-15를 격리
isolcpus=nohz,domain,managed_irq,4-15 \
nohz_full=4-15 \
rcu_nocbs=4-15 \
irqaffinity=0-3
# isolcpus: 스케줄러 도메인에서 제외, 관리 IRQ 차단
# nohz_full: tick-less 모드 (1개 태스크만 실행 시 틱 중지)
# rcu_nocbs: RCU 콜백을 rcuo kthread로 오프로드 → 격리 CPU 간섭 제거
# irqaffinity: 하드웨어 IRQ를 시스템 관리 작업용 CPU(0-3)로 제한
irqaffinity=0-3는 인터럽트를 격리 CPU가 아니라 시스템 관리 작업을 맡는 CPU로 모아, RT/HPC 태스크가 도는 격리 CPU의 지터를 줄이기 위한 설정입니다.
CONFIG_RCU_FAST_NO_HZ vs 기본: 이 옵션은 idle CPU가 콜백 대기 중일 때도 잠들 수 있게 합니다. 전력 절감 효과가 있지만, GP 완료 지연이 발생할 수 있습니다. nohz_full 환경에서는 항상 활성화를 권장합니다.
perf_stats 해석법
# RCU 성능 통계 확인
cat /sys/kernel/debug/rcu/rcu_preempt/perf_stats
# 출력 예시:
# Completed grace periods: 12345
# Grace-period duration (ns): avg=1234567 max=9876543
# Force-quiescent-state scans: 678
# Expedited grace periods: 45
# RCU callback invocations: 56789
# 주의 지표:
# - GP duration max가 수 초 이상 → stall 가능성 조사
# - FQS scans이 GP 수 대비 매우 높음 → CPU가 QS 보고 지연
# - Expedited GP 비율 높음 → expedited 남용 여부 확인
CONFIG 옵션 종합
RCU 관련 주요 커널 설정 옵션을 기능별로 분류합니다.
RCU 구현 선택
| CONFIG 옵션 | 설명 | 기본값 |
|---|---|---|
CONFIG_TREE_RCU | SMP용 Tree RCU (대부분의 커널) | SMP 시 자동 선택 |
CONFIG_PREEMPT_RCU | 선점 가능 RCU (PREEMPT 커널) | CONFIG_PREEMPT=y 시 자동 |
CONFIG_TINY_RCU | UP(단일 프로세서)용 경량 RCU | !SMP 시 자동 선택 |
CONFIG_TASKS_RCU | RCU-Tasks 지원 | y (BPF 등 필요 시) |
CONFIG_TASKS_TRACE_RCU | RCU-Tasks-Trace 지원 | y |
CONFIG_SRCU | SRCU 지원 | y |
성능 튜닝
| CONFIG 옵션 | 설명 | 기본값 |
|---|---|---|
CONFIG_RCU_NOCB_CPU | RCU 콜백 오프로딩 지원 | n (활성화 권장: RT/HPC) |
CONFIG_RCU_FAST_NO_HZ | idle CPU에서 RCU 콜백 가속 처리 | n |
CONFIG_RCU_BOOST | GP 지연 시 reader 우선순위 부스팅 | RT 커널에서 y |
CONFIG_RCU_BOOST_DELAY | 부스팅 시작 지연 (ms) | 500 |
CONFIG_RCU_EXP_KTHREAD | expedited GP를 전용 kthread로 처리 | n |
CONFIG_RCU_FANOUT | rcu_node 트리 fanout (가지 수) | 64 (64비트), 32 (32비트) |
CONFIG_RCU_FANOUT_LEAF | 리프 노드 fanout | 16 |
CONFIG_RCU_NOCB_CPU_CB_BOOST | NOCB 콜백 처리 kthread 우선순위 부스트 | n |
CONFIG_RCU_LAZY | 콜백 배치 처리 (지연 실행, 전력 절약) | n |
디버깅 및 테스트
| CONFIG 옵션 | 설명 | 기본값 |
|---|---|---|
CONFIG_PROVE_RCU | lockdep 기반 RCU 규칙 검증 | PROVE_LOCKING 의존 |
CONFIG_RCU_CPU_STALL_TIMEOUT | stall 감지 타임아웃 (초) | 21 |
CONFIG_RCU_CPU_STALL_CPUTIME | stall 시 CPU 시간 통계 출력 | n |
CONFIG_RCU_TRACE | RCU tracepoint 활성화 | n |
CONFIG_RCU_TORTURE_TEST | RCU 스트레스 테스트 모듈 | n (m 권장: 테스트 시) |
CONFIG_RCU_REF_SCALE_TEST | RCU 참조 획득 성능 벤치마크 | n |
CONFIG_DEBUG_OBJECTS_RCU_HEAD | rcu_head 오브젝트 추적 (double-free 감지) | n |
CONFIG_RCU_STRICT_GRACE_PERIOD | 엄격한 GP 검증 (디버그 전용) | n |
CONFIG_PROVE_RCU_LIST | RCU 리스트 순회 규칙 검증 | n |
개발/테스트 권장 설정: CONFIG_PROVE_RCU=y, CONFIG_PROVE_RCU_LIST=y, CONFIG_DEBUG_OBJECTS_RCU_HEAD=y, CONFIG_RCU_CPU_STALL_CPUTIME=y를 활성화하면 RCU 관련 버그를 조기에 발견할 수 있습니다. 운영 환경에서는 성능 영향을 고려하여 비활성화하세요.
커널 부트 파라미터 종합
rcupdate.* 파라미터
| 파라미터 | 설명 | 기본값 |
|---|---|---|
rcupdate.rcu_cpu_stall_timeout=N | RCU stall 감지 타임아웃 (초) | 21 |
rcupdate.rcu_cpu_stall_suppress=1 | stall 경고 억제 | 0 |
rcupdate.rcu_cpu_stall_suppress_at_boot=1 | 부팅 중 stall 경고 억제 | 0 (일부 배포판 1) |
rcupdate.rcu_cpu_stall_ftrace_dump=1 | stall 시 ftrace 버퍼 덤프 | 0 |
rcupdate.rcu_expedited=1 | 모든 synchronize_rcu를 expedited로 | 0 |
rcupdate.rcu_normal=1 | expedited GP 사용 금지 (정상 GP만) | 0 |
rcupdate.rcu_normal_after_boot=1 | 부팅 후 expedited → normal 전환 | 0 |
rcupdate.rcu_task_stall_timeout=N | RCU-Tasks stall 타임아웃 (ms) | 600000 |
rcupdate.rcu_self_test=1 | 부팅 시 RCU 자체 테스트 실행 | 0 |
rcutree.* 파라미터
| 파라미터 | 설명 | 기본값 |
|---|---|---|
rcutree.blimit=N | 1회 softirq에서 처리할 최대 콜백 수 | 10 |
rcutree.qhimark=N | 콜백 큐 하이워터마크 (GP 가속 트리거) | 10000 |
rcutree.qlowmark=N | 콜백 큐 로우워터마크 (가속 해제) | 100 |
rcutree.jiffies_till_first_fqs=N | 첫 Force QS까지 jiffies | 1 |
rcutree.jiffies_till_next_fqs=N | 이후 Force QS 간격 jiffies | 1 |
rcutree.kthread_prio=N | RCU GP kthread RT 우선순위 | 1 (SCHED_FIFO) |
rcutree.rcu_kick_kthreads=1 | stall 시 kthread 강제 깨우기 | 0 |
NOCB 관련 파라미터
| 파라미터 | 설명 | 예시 |
|---|---|---|
rcu_nocbs=CPULIST | 콜백 오프로드 대상 CPU | rcu_nocbs=0-3,8 |
rcu_nocbs=all | 모든 CPU 콜백 오프로드 | |
rcupdate.rcu_nocb_gp_stride=N | rcuog 그룹당 CPU 수 | 4 |
srcutree.* 파라미터
| 파라미터 | 설명 | 기본값 |
|---|---|---|
srcutree.exp_holdoff=N | expedited SRCU GP 간 대기 (us) | 25 |
srcutree.counter_wrap_check=N | 카운터 래핑 감지 주기 (GP 수) | 4096 |
srcutree.convert_to_big=N | big SRCU 전환 임계값 | 자동 |
# 부트 파라미터 조합 예 (RT/HPC 시스템)
GRUB_CMDLINE_LINUX="rcu_nocbs=0-7 \
rcupdate.rcu_cpu_stall_timeout=60 \
rcutree.kthread_prio=2 \
rcupdate.rcu_normal_after_boot=1 \
isolcpus=0-7 nohz_full=0-7"
# 런타임 파라미터 확인
cat /sys/module/rcupdate/parameters/rcu_cpu_stall_timeout
cat /sys/module/rcutree/parameters/blimit
cat /sys/module/rcutree/parameters/kthread_prio
RCU-Tasks (Trampoline RCU)
RCU-Tasks는 태스크(task_struct)를 단위로 grace period를 추적하는 RCU 변형입니다. BPF 트램폴린, 함수 훅, ftrace 패치 등 함수 포인터 수준의 동적 코드 수정이 필요한 경우에 사용됩니다.
| 변형 | QS 단위 | 주요 사용처 | grace period 유발 방법 |
|---|---|---|---|
RCU-Tasks | task 스케줄링 포인트 | BPF trampoline, ftrace, livepatch | synchronize_rcu_tasks() |
RCU-Tasks-Rude | task context 전환 | task 전환 필요 코드 패치 | synchronize_rcu_tasks_rude() |
RCU-Tasks-Trace | BPF 프로그램 실행 | BPF 프로그램 교체 | synchronize_rcu_tasks_trace() |
/* RCU-Tasks 사용 패턴 (BPF trampoline 교체 예) */
#include <linux/rcupdate_trace.h>
/* 새 trampoline 설치 후 이전 것의 안전한 해제 */
rcu_assign_pointer(prog->trampoline, new_tramp);
/* 모든 태스크가 이전 trampoline을 떠날 때까지 대기 */
synchronize_rcu_tasks(); /* 모든 task가 스케줄링 포인트를 한 번 통과할 때까지 */
bpf_trampoline_free(old_tramp);
/* RCU-Tasks-Trace read-side (BPF 프로그램 내부) */
rcu_read_lock_trace();
/* BPF 프로그램 실행 구간 */
rcu_read_unlock_trace();
일반 RCU와의 차이: 일반 RCU는 CPU 단위로 QS를 추적하지만, RCU-Tasks는 태스크 단위로 추적합니다. idle CPU나 커널 스레드가 없는 환경에서도 동작하며, grace period는 일반 RCU보다 훨씬 길 수 있습니다(수백 ms ~ 수 초). 핫 경로가 아닌 코드 패치, 모듈 교체 경로에서만 사용하세요.
call_rcu_tasks / rcu_barrier_tasks
/* call_rcu_tasks(): 비동기 RCU-Tasks 콜백 등록 */
call_rcu_tasks(&obj->rcu, my_tasks_callback);
/* 콜백은 모든 task가 스케줄링 포인트를 통과한 후 실행 */
void my_tasks_callback(struct rcu_head *head)
{
struct my_obj *obj = container_of(head,
struct my_obj, rcu);
kfree(obj);
}
/* rcu_barrier_tasks(): 모든 call_rcu_tasks 콜백 완료 대기 */
rcu_barrier_tasks(); /* 모듈 언로드 시 필수 */
/* 모듈 exit 패턴 */
static void __exit my_exit(void)
{
cleanup_all();
rcu_barrier_tasks(); /* RCU-Tasks 콜백 완료 */
rcu_barrier_tasks_trace(); /* RCU-Tasks-Trace 콜백 완료 */
rcu_barrier(); /* 일반 RCU 콜백 완료 */
}
call_rcu_tasks_trace 상세
/* RCU-Tasks-Trace: BPF 프로그램 교체용
* read-side: rcu_read_lock_trace() / rcu_read_unlock_trace()
* write-side: synchronize_rcu_tasks_trace() / call_rcu_tasks_trace()
*/
/* BPF 프로그램 교체 패턴 */
rcu_assign_pointer(bpf_prog, new_prog);
synchronize_rcu_tasks_trace(); /* 모든 BPF 실행 완료 대기 */
bpf_prog_free(old_prog);
/* 비동기 변형 */
call_rcu_tasks_trace(&prog->rcu, free_prog_callback);
/* barrier 변형 */
rcu_barrier_tasks_trace();
/* rcu_read_lock_trace_held(): lockdep 검증 */
WARN_ON_ONCE(!rcu_read_lock_trace_held());
call_rcu_tasks_rude (deprecated)
Deprecated: synchronize_rcu_tasks_rude()와 call_rcu_tasks_rude()는 Linux 6.x에서 deprecated 상태입니다. 기존 코드 호환을 위해 유지되지만, 새 코드에서는 synchronize_rcu_tasks() 또는 synchronize_rcu_tasks_trace()를 사용하세요.
/* RCU-Tasks-Rude (deprecated)
* 모든 CPU에서 context switch를 강제하여 GP 완료.
* RCU-Tasks보다 공격적이지만 시스템 영향이 큼. */
synchronize_rcu_tasks_rude(); /* deprecated — 사용 자제 */
call_rcu_tasks_rude(&head, cb); /* deprecated */
실제 커널 서브시스템 사용 패턴
RCU는 Linux 커널 전반에서 사용됩니다. 주요 서브시스템별 RCU 활용 패턴과 핵심 코드를 살펴봅니다.
실전에서는 API 이름보다 어떤 수명 관리 패턴을 선택하느냐가 더 중요합니다. 먼저 반복적으로 등장하는 구현 패턴을 보고, 이어서 실제 서브시스템 사례에 연결해 보겠습니다.
구현 패턴 선택 빠른 지도
| 상황 | Reader 경로 | Writer 경로 | 회수 방식 | 핵심 이유 |
|---|---|---|---|---|
| 읽기 위주의 설정 스냅샷 | rcu_read_lock() + rcu_dereference() | 새 객체 생성 후 rcu_replace_pointer() | kfree_rcu() | 핫 경로가 불변 스냅샷을 즉시 읽을 수 있음 |
| 조회 후 락 밖에서 오래 사용 | RCU lookup 후 refcount_inc_not_zero() | 테이블에서 제거 후 참조 반납 | 마지막 put에서 kfree_rcu() | RCU 밖으로 객체를 안전하게 들고 나가려면 소유권 승격 필요 |
| 대량 삭제 또는 재구성 | 기존 리스트를 계속 순회 | list_del_rcu()로 분리 후 한 번에 GP 대기 | synchronize_rcu() 뒤 일괄 kfree() | 콜백 폭주를 줄이고 회수 비용을 배치 처리 |
| 함수 테이블 또는 핸들러 핫스왑 | RCU로 현재 테이블 참조 | 완성된 테이블 전체를 교체 | call_rcu() | 부분 수정 중간 상태를 reader가 보지 않게 함 |
| read-side에서 sleep 필요 | srcu_read_lock() + srcu_dereference() | 포인터 교체 후 synchronize_srcu() | GP 뒤 일반 kfree() 또는 call_srcu() | 표준 RCU는 sleep 불가, SRCU는 가능 |
패턴 선택 기준: reader가 포인터를 rcu_read_unlock() 밖으로 들고 나갈 필요가 없으면 단순 포인터 스왑이 가장 싸고, 들고 나가야 하면 refcount 승격이 필요합니다. update가 대량이면 call_rcu()를 개별 호출하기보다 한 번의 grace period로 묶는 편이 더 낫습니다.
패턴 1: 읽기 위주 설정 스냅샷 교체
가장 흔한 형태는 작고 불변인 설정 객체를 통째로 교체하는 방식입니다. 네트워크 fast path의 정책 스냅샷, 파일시스템의 마운트 옵션 캐시, 드라이버의 런타임 설정처럼 읽기는 매우 잦고 갱신은 드문 상황에서 적합합니다.
struct fast_cfg {
u32 batch;
u32 timeout_ms;
bool drop_invalid;
struct rcu_head rcu;
};
static DEFINE_MUTEX(cfg_lock);
static struct fast_cfg __rcu *g_cfg;
int fast_path_handle(struct packet *pkt)
{
const struct fast_cfg *cfg;
u32 batch, timeout_ms;
bool drop_invalid;
rcu_read_lock();
cfg = rcu_dereference(g_cfg);
if (!cfg) {
rcu_read_unlock();
return -ENOENT;
}
/* 발행 후에는 불변 객체이므로 값을 지역 변수로 복사해 락 밖으로 전달 */
batch = cfg->batch;
timeout_ms = cfg->timeout_ms;
drop_invalid = cfg->drop_invalid;
rcu_read_unlock();
return run_pipeline(pkt, batch, timeout_ms, drop_invalid);
}
int fast_cfg_update(u32 batch, u32 timeout_ms, bool drop_invalid)
{
struct fast_cfg *new_cfg, *old_cfg;
new_cfg = kmalloc(sizeof(*new_cfg), GFP_KERNEL);
if (!new_cfg)
return -ENOMEM;
new_cfg->batch = batch;
new_cfg->timeout_ms = timeout_ms;
new_cfg->drop_invalid = drop_invalid;
mutex_lock(&cfg_lock);
old_cfg = rcu_replace_pointer(g_cfg, new_cfg,
lockdep_is_held(&cfg_lock));
mutex_unlock(&cfg_lock);
if (old_cfg)
kfree_rcu(old_cfg, rcu);
return 0;
}
- Reader는 포인터를 오래 들고 있지 않습니다. 필요한 스칼라 값만 복사하고 바로
rcu_read_unlock()합니다. - Writer는 기존 객체를 수정하지 않습니다. 항상 새 객체를 만든 뒤 한 번에 포인터를 교체해야 reader가 반쯤 갱신된 상태를 보지 않습니다.
- 중첩 포인터가 있으면 깊은 복사 또는 별도 수명 관리가 필요합니다. 바깥 객체만 RCU로 보호하고 내부 버퍼를 공유하면 결국 내부 버퍼 쪽에서 UAF가 납니다.
패턴 2: RCU 조회 후 refcount로 소유권 승격
RCU reader는 원칙적으로 rcu_read_unlock() 이후 객체를 계속 쓰면 안 됩니다. lookup 결과를 잠금 밖으로 넘겨 오래 사용해야 한다면, RCU 안에서 refcount를 올려 소유권을 가져오는 handoff가 필요합니다. 소켓 lookup, PID lookup, 세션 테이블 검색에서 매우 자주 쓰입니다.
struct session {
u32 id;
refcount_t refcnt;
struct hlist_node node;
struct rcu_head rcu;
};
static DEFINE_SPINLOCK(session_lock);
static struct hlist_head session_ht[SESSION_HT_SIZE];
struct session *session_lookup_get(u32 id)
{
struct session *s;
u32 slot = hash_min(id, SESSION_HT_BITS);
rcu_read_lock();
hlist_for_each_entry_rcu(s, &session_ht[slot], node) {
if (s->id != id)
continue;
/* unlock 전에 ref를 잡아야 GP 경계 밖으로 안전하게 넘길 수 있음 */
if (!refcount_inc_not_zero(&s->refcnt))
continue;
rcu_read_unlock();
return s;
}
rcu_read_unlock();
return NULL;
}
void session_remove(struct session *s)
{
spin_lock(&session_lock);
hlist_del_rcu(&s->node);
spin_unlock(&session_lock);
/* 해시 테이블이 갖고 있던 참조 1개 반납 */
session_put(s);
}
void session_put(struct session *s)
{
if (refcount_dec_and_test(&s->refcnt))
kfree_rcu(s, rcu);
}
핵심 레이스: rcu_read_unlock() 후에 refcount를 올리면 늦습니다. 그 사이 writer가 객체를 테이블에서 제거하고 마지막 참조를 내려 GP 후 해제 경로로 보낼 수 있기 때문입니다. 반드시 RCU read-side 내부에서 refcount_inc_not_zero()를 호출해야 합니다.
이 패턴의 장점은 reader 핫 경로는 여전히 가볍게 유지하면서도, 찾은 객체를 I/O 완료 경로나 workqueue로 안전하게 넘길 수 있다는 점입니다. 반대로 lookup 성공률이 낮고 참조 승격이 매우 잦다면 refcount cacheline 경합이 병목이 될 수 있으므로, 그 경우에는 per-CPU 캐시나 배치 handoff를 검토해야 합니다.
패턴 3: 대량 삭제는 한 번의 grace period로 배치 회수
규칙 집합 재로딩, 라우팅 정책 전체 교체, 큰 리스트 재정렬처럼 짧은 시간에 많은 객체를 retire해야 할 때는 각 객체마다 call_rcu()를 거는 방식이 비효율적입니다. process context에서 블로킹이 가능하다면, RCU 링크만 먼저 끊고 한 번만 synchronize_rcu() 한 뒤 묶어서 해제하는 편이 더 단순하고 메모리 압력도 낮습니다.
struct rule {
struct list_head list;
struct list_head retire_node; /* 생성 시 INIT_LIST_HEAD 필요 */
u32 key;
};
static LIST_HEAD(active_rules);
static DEFINE_SPINLOCK(rules_lock);
void rules_reload(struct list_head *new_rules)
{
struct rule *rule, *tmp;
LIST_HEAD(retired_rules);
spin_lock(&rules_lock);
list_for_each_entry_safe(rule, tmp, &active_rules, list) {
list_del_rcu(&rule->list);
INIT_LIST_HEAD(&rule->retire_node);
list_add_tail(&rule->retire_node, &retired_rules);
}
list_splice_tail_init(new_rules, &active_rules);
spin_unlock(&rules_lock);
/* reader들이 old rule을 모두 빠져나올 때까지 한 번만 대기 */
synchronize_rcu();
list_for_each_entry_safe(rule, tmp, &retired_rules, retire_node) {
list_del_init(&rule->retire_node);
kfree(rule);
}
}
- 장점 - retire 대상이 많을수록
call_rcu()폭주를 줄여 메모리 사용량과 callback 지연을 낮춥니다. - 조건 -
synchronize_rcu()는 sleep할 수 있는 문맥에서만 사용해야 합니다. softirq, hardirq, spinlock 보유 상태에서는 부적합합니다. - 실전 팁 - 새 규칙 집합을 미리 준비해 둔 뒤 짧은 잠금 구간 안에서 포인터나 리스트만 바꾸고, 무거운 해제는 잠금 밖에서 처리하는 것이 핵심입니다.
패턴 4: 함수 테이블 전체를 교체하는 핫스왑
BPF hook, 프로토콜 handler, LSM 정책 함수 테이블처럼 함수 포인터 묶음을 바꾸는 경우에는 개별 함수 포인터를 제자리 수정하지 말고, 완성된 테이블을 통째로 교체해야 합니다. 그래야 reader가 old/new 함수 조합이 섞인 중간 상태를 보지 않습니다.
struct parser_ops {
int (*parse)(struct packet *pkt);
int (*classify)(struct packet *pkt);
struct rcu_head rcu;
};
static DEFINE_MUTEX(parser_lock);
static struct parser_ops __rcu *g_parser_ops;
int rx_classify(struct packet *pkt)
{
const struct parser_ops *ops;
int ret = -ENOENT;
rcu_read_lock();
ops = rcu_dereference(g_parser_ops);
if (ops)
ret = ops->classify(pkt);
rcu_read_unlock();
return ret;
}
static void parser_ops_free_rcu(struct rcu_head *rh)
{
struct parser_ops *ops = container_of(rh, struct parser_ops, rcu);
kfree(ops);
}
void parser_ops_install(struct parser_ops *new_ops)
{
struct parser_ops *old_ops;
mutex_lock(&parser_lock);
old_ops = rcu_replace_pointer(g_parser_ops, new_ops,
lockdep_is_held(&parser_lock));
mutex_unlock(&parser_lock);
if (old_ops)
call_rcu(&old_ops->rcu, parser_ops_free_rcu);
}
왜 call_rcu()인가? 이 예시에서는 단순 kfree_rcu()보다 call_rcu()가 더 적절합니다. 함수 테이블과 함께 모듈 참조, 통계 버퍼, 보조 메모리 정리 같은 사용자 정의 destructor가 필요할 수 있기 때문입니다. 또한 모듈이 이 콜백을 제공한다면 module_exit()에서 rcu_barrier()가 필요하다는 점도 자연스럽게 연결됩니다.
금지 패턴: g_parser_ops->classify = new_fn; 같은 제자리 변경은 위험합니다. reader 하나는 새 classify를 보고 다른 reader는 아직 옛 parse를 보는 식으로, 함수들 간의 일관된 조합이 깨질 수 있습니다.
패턴 5: sleep 가능한 정책 조회는 SRCU로 분리
reader가 mutex를 잡거나 펌웨어 로딩, 파일 I/O, 메모리 할당처럼 sleep 가능한 작업을 해야 하면 일반 RCU는 맞지 않습니다. 이 경우에는 해당 서브시스템 전용 srcu_struct를 두고, read-side를 SRCU 도메인으로 분리하는 편이 안전합니다.
struct policy_state {
struct mutex lock;
u32 version;
};
DEFINE_STATIC_SRCU(policy_srcu);
static DEFINE_MUTEX(policy_update_lock);
static struct policy_state __rcu *policy_ptr;
int policy_check(struct request *req)
{
struct policy_state *policy;
int idx, ret;
idx = srcu_read_lock(&policy_srcu);
policy = srcu_dereference(policy_ptr, &policy_srcu);
if (!policy) {
ret = -ENOENT;
goto out;
}
mutex_lock(&policy->lock); /* SRCU read-side에서는 sleep 가능 */
ret = slow_policy_match(policy, req);
mutex_unlock(&policy->lock);
out:
srcu_read_unlock(&policy_srcu, idx);
return ret;
}
void policy_reload(struct policy_state *new_policy)
{
struct policy_state *old_policy;
mutex_lock(&policy_update_lock);
old_policy = rcu_dereference_protected(policy_ptr,
lockdep_is_held(&policy_update_lock));
rcu_assign_pointer(policy_ptr, new_policy);
mutex_unlock(&policy_update_lock);
synchronize_srcu(&policy_srcu);
kfree(old_policy);
}
- SRCU 도메인을 분리 - 무관한 정책 엔진이 하나의
srcu_struct를 공유하면 grace period가 서로의 tail latency를 끌어올립니다. - writer는 여전히 직렬화가 필요 - SRCU가 있다고 해서 writer 경쟁이 사라지지 않습니다. 포인터 교체 자체는 mutex나 spinlock으로 보호해야 합니다.
- 업데이트 빈도가 높으면 비동기 회수도 검토 - reload가 매우 잦다면
synchronize_srcu()대신call_srcu()로 회수를 밀어내는 것이 더 나을 수 있습니다.
정리: 표준 RCU는 "아주 짧고 아주 자주 실행되는 reader"에, SRCU는 "sleep 가능하지만 상대적으로 무거운 reader"에 맞습니다. 두 메커니즘을 섞어 쓰기보다, 각 자료구조가 요구하는 reader 특성에 맞춰 하나를 명확히 선택하는 편이 디버깅과 lockdep 추론에 유리합니다.
RCU가 보호하는 범위와 보호하지 않는 범위
실전 버그의 상당수는 "RCU가 객체를 보호한다"는 문장을 너무 넓게 해석해서 발생합니다. RCU가 직접 보장하는 것은 RCU read-side에서 도달 가능한 포인터 경로와 grace period 전까지의 메모리 유지입니다. 반대로 타이머, workqueue, DMA, 사용자 참조처럼 RCU lookup 없이 객체를 직접 들고 있는 사용자는 별도 종료 절차가 필요합니다.
| 대상 | RCU만으로 충분한가 | 추가로 필요한 것 | 설명 |
|---|---|---|---|
rcu_dereference() 후 rcu_read_unlock() 이전 접근 | O | 없음 | 전형적인 RCU 보호 구간 |
| unlock 이후에도 객체 사용 | X | refcount 또는 다른 소유권 | RCU는 임계구역 밖 수명을 보장하지 않음 |
| timer/workqueue/completion 콜백 | X | del_timer_sync(), cancel_work_sync() 등 | 콜백은 이미 객체 포인터를 직접 보유할 수 있음 |
| 모듈 코드의 함수 포인터 | 조건부 | synchronize_rcu() 또는 rcu_barrier() | 언등록 후에도 기존 reader가 함수 본문을 실행 중일 수 있음 |
| 중첩 자원 | X | destructor에서 개별 정리 | RCU는 바깥 컨테이너 해제 시점만 늦출 뿐 내부 자원 수명 정책까지 대신하지 않음 |
핵심 원칙: "해시 테이블에서 제거했으니 끝"이라고 생각하면 위험합니다. RCU는 새 lookup을 막는 데는 좋지만, 이미 예약된 타이머나 이미 큐에 올라간 work item까지 자동으로 없애 주지는 않습니다.
패턴 6: timer/workqueue를 가진 객체의 제거 순서
연결 추적 객체, 캐시 엔트리, 장치 상태 객체처럼 해시 테이블에 RCU로 게시되면서 동시에 timer나 workqueue도 붙어 있는 경우가 많습니다. 이때는 RCU 제거와 비동기 실행원 종료를 분리해서 처리해야 합니다.
struct conn {
refcount_t refcnt;
bool dead;
struct hlist_node node;
struct timer_list keepalive_timer;
struct work_struct retry_work;
struct rcu_head rcu;
};
static DEFINE_SPINLOCK(conn_lock);
static struct hlist_head conn_ht[CONN_HT_SIZE];
static void conn_retry_workfn(struct work_struct *work)
{
struct conn *c = container_of(work, struct conn, retry_work);
if (READ_ONCE(c->dead))
return;
retry_packet(c);
if (!READ_ONCE(c->dead))
mod_timer(&c->keepalive_timer, jiffies + HZ);
}
static void conn_timer_fn(struct timer_list *t)
{
struct conn *c = from_timer(c, t, keepalive_timer);
if (READ_ONCE(c->dead))
return;
queue_work(system_unbound_wq, &c->retry_work);
}
void conn_remove(struct conn *c)
{
WRITE_ONCE(c->dead, true); /* 재arm 차단 */
spin_lock(&conn_lock);
hlist_del_rcu(&c->node); /* 새 lookup 차단 */
spin_unlock(&conn_lock);
del_timer_sync(&c->keepalive_timer);
cancel_work_sync(&c->retry_work);
/* 해시 테이블이 갖고 있던 publication ref 반납 */
conn_put(c);
}
void conn_put(struct conn *c)
{
if (refcount_dec_and_test(&c->refcnt))
kfree_rcu(c, rcu);
}
- 먼저 dead 플래그 - timer와 work가 서로를 다시 예약할 수 있으므로, 제거 초기에 재arm을 막아야 합니다.
- 그 다음 RCU 링크 제거 - 새 reader가 더 이상 객체를 찾지 못하게 합니다.
- 그 다음 비동기 실행원 종료 -
del_timer_sync()와cancel_work_sync()는 이미 실행 중인 콜백까지 끝날 때까지 기다립니다. - 마지막으로 참조 반납 - 기존 RCU reader가 남아 있더라도
kfree_rcu()가 GP 뒤에 메모리를 정리합니다.
왜 이 순서인가? 만약 conn_put()를 먼저 호출해 마지막 참조를 떨어뜨리면, 아직 종료되지 않은 timer/work가 해제 예정 객체를 건드릴 수 있습니다. 반대로 timer/work만 먼저 끄고 해시에서 늦게 빼면, 그 사이 새 lookup이 계속 들어와 객체 생존 시간이 늘어납니다.
패턴 7: 관련 필드를 하나의 루트 객체로 묶어 발행
버킷 배열, 크기, 기본 정책, 함수 테이블처럼 함께 일관되게 바뀌어야 하는 필드가 여러 개라면, 전역 변수 여러 개를 따로 publish하지 말고 하나의 루트 객체에 묶어 RCU로 교체해야 합니다. 이는 설정 스냅샷보다 한 단계 큰 패턴으로, 해시 테이블 루트나 정책 엔진 컨텍스트에 자주 쓰입니다.
struct acl_root {
u32 mask;
bool default_deny;
struct acl_bucket *buckets;
struct acl_ops *ops;
struct rcu_head rcu;
};
static DEFINE_MUTEX(acl_lock);
static struct acl_root __rcu *g_acl_root;
static void acl_root_free_rcu(struct rcu_head *rh)
{
struct acl_root *root = container_of(rh, struct acl_root, rcu);
kfree(root->buckets);
kfree(root);
}
int acl_lookup(const struct flow_key *key)
{
const struct acl_root *root;
const struct acl_bucket *bucket;
u32 slot;
int ret;
rcu_read_lock();
root = rcu_dereference(g_acl_root);
if (!root) {
rcu_read_unlock();
return -ENOENT;
}
slot = flow_hash(key) & root->mask;
bucket = &root->buckets[slot];
ret = root->ops->match(bucket, key, root->default_deny);
rcu_read_unlock();
return ret;
}
int acl_reload(struct acl_root *new_root)
{
struct acl_root *old_root;
mutex_lock(&acl_lock);
old_root = rcu_dereference_protected(g_acl_root,
lockdep_is_held(&acl_lock));
rcu_assign_pointer(g_acl_root, new_root);
mutex_unlock(&acl_lock);
if (old_root)
call_rcu(&old_root->rcu, acl_root_free_rcu);
return 0;
}
왜 전역 변수 여러 개가 아니라 루트 1개인가? g_mask, g_buckets, g_ops를 따로 갱신하면 reader가 서로 다른 세대의 값을 섞어 볼 수 있습니다. 반면 루트 객체 하나만 publish하면 reader는 항상 같은 generation의 필드 집합을 봅니다.
이 패턴은 "설정 스냅샷"보다 더 큰 단위의 publish에 해당합니다. 핵심은 연관 필드끼리 묶어서 deep copy 후 한 번에 교체하는 것이며, 해제 콜백에서 루트 아래의 하위 버퍼까지 같이 정리하는 점입니다.
패턴 8: XArray lockless lookup과 RCU handoff
현대 커널에서는 해시 리스트만이 아니라 XArray 같은 공용 자료구조 위에서도 RCU 기반 lockless lookup을 자주 사용합니다. 이때도 원리는 같습니다. xa_load()로 얻은 포인터는 일시적인 RCU 보호 포인터일 뿐이므로, 임계구역 밖으로 들고 나가려면 refcount 승격이 필요합니다.
struct file_obj {
refcount_t refcnt;
struct inode *inode;
struct rcu_head rcu;
};
DEFINE_XARRAY(file_xa);
struct file_obj *file_get_by_id(unsigned long id)
{
struct file_obj *obj;
rcu_read_lock();
obj = xa_load(&file_xa, id);
if (obj && !refcount_inc_not_zero(&obj->refcnt))
obj = NULL;
rcu_read_unlock();
return obj;
}
int file_publish(unsigned long id, struct file_obj *new_obj)
{
void *old;
refcount_set(&new_obj->refcnt, 1); /* XArray가 publication ref 1개 보유 */
old = xa_store(&file_xa, id, new_obj, GFP_KERNEL);
if (xa_is_err(old))
return xa_err(old);
if (old)
file_put(old);
return 0;
}
void file_remove(unsigned long id)
{
struct file_obj *obj;
obj = xa_erase(&file_xa, id);
if (obj)
file_put(obj);
}
void file_put(struct file_obj *obj)
{
if (refcount_dec_and_test(&obj->refcnt))
kfree_rcu(obj, rcu);
}
xa_load()는 lockless 조회 - reader는 XArray 내부 잠금을 잡지 않고도 빠르게 포인터를 얻습니다.xa_store()/xa_erase()는 publication만 바꿈 - 객체 수명 종료는 여전히 refcount와 RCU 회수 경로가 담당합니다.- 여러 자료구조를 함께 갱신하면 외부 잠금이 필요 - XArray 자체는 슬롯 하나의 갱신은 직렬화하지만, 다른 리스트나 통계와 원자적으로 묶으려면 별도 writer 락이 필요합니다.
패턴 9: 콜백 체인 언등록과 모듈 언로드 경계
trace hook, 프로토콜 핸들러 목록, 내부 notifier 유사 구조처럼 콜백 객체 목록을 RCU로 돌리는 경우도 많습니다. 이런 패턴은 reader가 함수를 순회해 바로 호출하므로 빠르지만, 반대로 언등록 후에도 기존 reader가 함수 본문을 계속 실행할 수 있다는 점을 항상 의식해야 합니다.
struct pkt_hook {
int (*fn)(struct packet *pkt);
struct list_head node;
struct rcu_head rcu;
};
static LIST_HEAD(pkt_hook_list);
static DEFINE_MUTEX(pkt_hook_lock);
int pkt_run_hooks(struct packet *pkt)
{
struct pkt_hook *hook;
int ret = 0;
rcu_read_lock();
list_for_each_entry_rcu(hook, &pkt_hook_list, node) {
ret = hook->fn(pkt);
if (ret)
break;
}
rcu_read_unlock();
return ret;
}
int pkt_hook_register(struct pkt_hook *hook)
{
mutex_lock(&pkt_hook_lock);
list_add_rcu(&hook->node, &pkt_hook_list);
mutex_unlock(&pkt_hook_lock);
return 0;
}
static void pkt_hook_free_rcu(struct rcu_head *rh)
{
struct pkt_hook *hook = container_of(rh, struct pkt_hook, rcu);
kfree(hook);
}
void pkt_hook_unregister(struct pkt_hook *hook)
{
mutex_lock(&pkt_hook_lock);
list_del_rcu(&hook->node);
mutex_unlock(&pkt_hook_lock);
call_rcu(&hook->rcu, pkt_hook_free_rcu);
}
- 언등록 직후에도 기존 reader는 hook을 돌 수 있습니다. 따라서
list_del_rcu()다음에 즉시kfree()하면 UAF가 납니다. - 콜백 본문이 sleep하면 안 됩니다. 이 패턴은 보통 패킷 경로나 tracepoint 같은 핫 경로용입니다. sleep 가능한 콜백 목록이면 일반 RCU 대신 SRCU나 blocking notifier가 맞습니다.
- 모듈 언로드는 한 단계 더 필요합니다. hook 구조체를
call_rcu()로 해제하는 모듈은module_exit()에서rcu_barrier()까지 호출해야 콜백과 해제 함수가 모두 끝난 뒤 모듈 텍스트를 내려갈 수 있습니다.
실전 판단법: "reader가 데이터를 읽는가"뿐 아니라 "reader가 함수 호출까지 하는가"를 구분하세요. 함수 호출형 RCU는 객체 메모리뿐 아니라 코드 수명까지 같이 생각해야 해서, 모듈 경계에서 특히 더 까다롭습니다.
여기까지가 가장 자주 보이는 기본 패턴입니다. 아래부터는 실제 커널 코드에서 한 단계 더 자주 부딪히는 고급 조합을 정리합니다. 특히 "RCU 하나만으로 해결되지 않는 부분"과 "다른 동기화와 결합해야 정확해지는 부분"을 의식해서 읽는 것이 중요합니다.
패턴 10: RCU + seqcount로 일관된 스칼라 묶음 읽기
RCU는 포인터 수명을 다루는 데 뛰어나지만, 서로 연관된 여러 스칼라 값을 한 세트로 일관되게 읽는 문제는 별도의 도구가 더 낫습니다. 대표적인 조합이 RCU + seqcount입니다. 객체의 정체성(identity)과 생존은 RCU가 보장하고, 객체 내부의 짧은 scalar 묶음의 일관성은 seqcount가 보장합니다.
struct classifier_ops {
int (*classify)(struct packet *pkt,
u32 rate_limit, u32 burst, bool drop_invalid);
struct rcu_head rcu;
};
struct qos_policy {
seqcount_t cfg_seq;
u32 rate_limit;
u32 burst;
bool drop_invalid;
struct classifier_ops __rcu *cls;
struct rcu_head rcu;
};
static DEFINE_MUTEX(qos_lock);
static struct qos_policy __rcu *g_qos;
int qos_classify(struct packet *pkt)
{
struct qos_policy *policy;
struct classifier_ops *cls;
unsigned seq;
u32 rate_limit, burst;
bool drop_invalid;
int ret;
rcu_read_lock();
policy = rcu_dereference(g_qos);
if (!policy) {
rcu_read_unlock();
return -ENOENT;
}
do {
seq = read_seqcount_begin(&policy->cfg_seq);
rate_limit = READ_ONCE(policy->rate_limit);
burst = READ_ONCE(policy->burst);
drop_invalid = READ_ONCE(policy->drop_invalid);
cls = rcu_dereference(policy->cls);
} while (read_seqcount_retry(&policy->cfg_seq, seq));
ret = cls->classify(pkt, rate_limit, burst, drop_invalid);
rcu_read_unlock();
return ret;
}
void qos_update_scalars(struct qos_policy *policy,
u32 rate_limit, u32 burst, bool drop_invalid)
{
mutex_lock(&qos_lock);
write_seqcount_begin(&policy->cfg_seq);
policy->rate_limit = rate_limit;
policy->burst = burst;
policy->drop_invalid = drop_invalid;
write_seqcount_end(&policy->cfg_seq);
mutex_unlock(&qos_lock);
}
void qos_replace_classifier(struct qos_policy *policy,
struct classifier_ops *new_cls)
{
struct classifier_ops *old_cls;
mutex_lock(&qos_lock);
write_seqcount_begin(&policy->cfg_seq);
old_cls = rcu_dereference_protected(policy->cls,
lockdep_is_held(&qos_lock));
rcu_assign_pointer(policy->cls, new_cls);
write_seqcount_end(&policy->cfg_seq);
mutex_unlock(&qos_lock);
if (old_cls)
call_rcu(&old_cls->rcu, classifier_free_rcu);
}
- seqcount는 lifetime을 보장하지 않습니다. 그래서
policy객체 자체는 RCU로 보호해야 합니다. - reader 재시도는 스칼라 일관성을 위해 필요합니다. 예를 들어
rate_limit만 새 값이고burst는 옛 값인 중간 조합을 피할 수 있습니다. - writer는 외부 직렬화가 있어야 합니다.
write_seqcount_begin()은 다중 writer 상호배제를 대신하지 않습니다. 위 예시에서는qos_lock이 그 역할을 합니다.
흔한 오해: seqcount만 있으면 포인터까지 안전하다고 생각하기 쉽습니다. 하지만 seqcount는 "찢어진 읽기"를 막을 뿐, writer가 옛 포인터가 가리키는 메모리를 해제하는 순간을 지연시키지 않습니다. 포인터 생존은 여전히 RCU나 refcount가 맡아야 합니다.
패턴 11: 슬롯 재사용과 ABA 문제는 generation으로 차단
RCU lookup이 빠르다고 해도, id 슬롯이 재사용되는 구조에서는 예전 핸들이 우연히 새 객체를 가리키는 문제가 남습니다. 이건 메모리 UAF가 아니라 논리적 ABA 버그입니다. 파일 핸들, 세션 ID, 내부 객체 핸들 테이블에서 흔합니다. 해결은 generation counter를 핸들에 포함시키는 것입니다.
struct handle_obj {
u32 id;
u32 gen; /* publish 후 불변 */
refcount_t refcnt;
struct rcu_head rcu;
};
static DEFINE_SPINLOCK(handle_lock);
static struct handle_obj __rcu *handle_slots[MAX_HANDLES];
static inline u64 encode_handle(struct handle_obj *obj)
{
return ((u64)obj->gen << 32) | obj->id;
}
struct handle_obj *handle_get(u64 handle)
{
u32 id = (u32)handle;
u32 gen = handle >> 32;
struct handle_obj *obj;
rcu_read_lock();
obj = rcu_dereference(handle_slots[id]);
if (!obj || READ_ONCE(obj->gen) != gen ||
!refcount_inc_not_zero(&obj->refcnt))
obj = NULL;
rcu_read_unlock();
return obj;
}
int handle_publish(u32 id, struct handle_obj *new_obj)
{
struct handle_obj *old_obj;
spin_lock(&handle_lock);
old_obj = rcu_dereference_protected(handle_slots[id],
lockdep_is_held(&handle_lock));
new_obj->id = id;
new_obj->gen = old_obj ? old_obj->gen + 1 : 1;
refcount_set(&new_obj->refcnt, 1);
rcu_assign_pointer(handle_slots[id], new_obj);
spin_unlock(&handle_lock);
if (old_obj)
handle_put(old_obj);
return 0;
}
void handle_put(struct handle_obj *obj)
{
if (refcount_dec_and_test(&obj->refcnt))
kfree_rcu(obj, rcu);
}
- generation은 publish 후 불변이어야 합니다. 기존 객체의
gen을 제자리 증가시키면 reader가 같은 포인터에서 서로 다른 세대를 보게 됩니다. - 핸들 검증은 refcount 승격 전에 끝내는 것이 일반적입니다. 잘못된 generation이면 애초에 소유권을 줄 필요가 없습니다.
- RCU는 메모리 안전을, generation은 의미 안전을 보장합니다. 둘은 역할이 다릅니다.
패턴 12: hlist_nulls로 재해시 중 lookup 재시도
흐름 테이블, 소켓 테이블처럼 엔트리가 버킷 사이를 옮겨 다니는 해시 구조에서는 단순 hlist보다 hlist_nulls가 유리합니다. reader가 순회 중에 엔트리가 다른 버킷으로 이동해도, 리스트 끝의 nulls 마커로 "내가 잘못된 버킷을 끝까지 읽었다"는 사실을 감지하고 재시도할 수 있기 때문입니다.
struct flow_entry {
u32 key;
u32 bucket_id;
refcount_t refcnt;
struct hlist_nulls_node node;
struct rcu_head rcu;
};
static DEFINE_SPINLOCK(flow_lock);
static struct hlist_nulls_head flow_ht[FLOW_HT_SIZE];
struct flow_entry *flow_lookup_get(u32 key)
{
u32 slot = hash_min(key, FLOW_HT_BITS);
struct hlist_nulls_node *node;
struct flow_entry *flow;
begin:
rcu_read_lock();
hlist_nulls_for_each_entry_rcu(flow, node, &flow_ht[slot], node) {
if (flow->key != key)
continue;
if (!refcount_inc_not_zero(&flow->refcnt))
continue;
rcu_read_unlock();
return flow;
}
rcu_read_unlock();
if (get_nulls_value(node) != slot)
goto begin; /* 순회 중 rehash/move 감지 */
return NULL;
}
void flow_move_bucket(struct flow_entry *flow, u32 new_slot)
{
spin_lock(&flow_lock);
flow->bucket_id = new_slot;
hlist_nulls_del_rcu(&flow->node);
hlist_nulls_add_head_rcu(&flow->node, &flow_ht[new_slot]);
spin_unlock(&flow_lock);
}
왜 일반 hlist가 아니라 hlist_nulls인가? 일반 hlist에서는 reader가 이전 버킷을 순회하다가 엔트리가 다른 버킷으로 이동하면, 그 reader는 "못 찾았다"는 결과를 정상 종료로 오인할 수 있습니다. nulls 마커는 이 종료가 정말 빈 버킷의 끝이었는지, 아니면 중간에 구조가 이동했는지를 구분하게 해 줍니다.
적합한 상황: 키 갱신, 리밸런싱, namespace 이동처럼 객체가 버킷을 옮길 수 있는 해시 구조라면 hlist_nulls가 좋은 선택입니다. 반대로 객체가 한 번 들어간 버킷에서 거의 움직이지 않는다면 일반 hlist가 더 단순합니다.
패턴 13: sleep 가능한 listener 집합은 SRCU + call_srcu
앞의 `패턴 9`는 hot path 콜백 목록에 적합했습니다. 하지만 listener가 펌웨어 요청, sysfs 접근, mutex, 메모리 할당처럼 sleep 가능한 작업을 수행해야 하면 일반 RCU callback list는 맞지 않습니다. 이때는 SRCU 기반 listener 집합이 자연스럽습니다.
struct pm_listener {
int (*fn)(struct pm_event *ev);
struct list_head node;
struct rcu_head rcu;
};
DEFINE_STATIC_SRCU(pm_srcu);
static LIST_HEAD(pm_listener_list);
static DEFINE_MUTEX(pm_listener_lock);
int pm_dispatch_event(struct pm_event *ev)
{
struct pm_listener *lst;
int idx, ret = 0;
idx = srcu_read_lock(&pm_srcu);
list_for_each_entry_srcu(lst, &pm_listener_list, node,
srcu_read_lock_held(&pm_srcu)) {
ret = lst->fn(ev); /* sleep 가능 */
if (ret)
break;
}
srcu_read_unlock(&pm_srcu, idx);
return ret;
}
int pm_listener_register(struct pm_listener *lst)
{
mutex_lock(&pm_listener_lock);
list_add_rcu(&lst->node, &pm_listener_list);
mutex_unlock(&pm_listener_lock);
return 0;
}
static void pm_listener_free_srcu(struct rcu_head *rh)
{
struct pm_listener *lst = container_of(rh, struct pm_listener, rcu);
kfree(lst);
}
void pm_listener_unregister(struct pm_listener *lst)
{
mutex_lock(&pm_listener_lock);
list_del_rcu(&lst->node);
mutex_unlock(&pm_listener_lock);
call_srcu(&pm_srcu, &lst->rcu, pm_listener_free_srcu);
}
- dispatch path가 sleep 가능하므로
srcu_read_lock()을 사용합니다. - 언등록 후 free는 call_srcu()로 미룹니다. SRCU reader가 모두 빠져나오기 전에는 listener 구조체를 해제할 수 없습니다.
- 모듈 경계라면 srcu_barrier()까지 필요합니다.
call_srcu()callback을 모듈이 제공한다면 module exit에서 반드시 기다려야 합니다.
이 패턴은 blocking notifier와 비슷한 문제를 다루지만, 자료구조 형식과 lifetime 정책을 직접 통제할 수 있다는 점이 다릅니다. 내부 서브시스템에서 listener 객체에 별도 상태나 통계를 붙이고 싶다면 custom SRCU list가 더 유연합니다.
패턴 14: 공개 후 제자리 수정 가능한 필드와 금지되는 필드
RCU에서 가장 흔한 설계 질문 중 하나는 이것입니다. "이 필드는 publish 이후에도 그냥 바꿔도 되나?" 답은 필드 유형에 따라 다릅니다. 중요한 기준은 reader가 그 필드를 단독 값으로 읽는지, 다른 필드와 조합해서 해석하는지, 포인터를 따라가며 lifetime을 기대하는지입니다.
| 필드 종류 | 제자리 수정 가능 여부 | 필요한 도구 | 이유 |
|---|---|---|---|
| 통계 카운터 | 대체로 가능 | atomic_t, u64_stats, per-CPU 변수 | reader가 약간의 stale 값을 허용하는 경우가 많음 |
| 단일 bool/flag | 조건부 가능 | READ_ONCE/WRITE_ONCE | 찢어진 읽기만 피하면 되는 경우 |
| 여러 scalar의 의미적 묶음 | 그냥은 위험 | copy-replace 또는 seqcount | 서로 다른 세대가 섞이면 논리 오류 |
| reader가 역참조하는 포인터 | 직접 overwrite 금지 | rcu_assign_pointer() + GP 후 free | 옛 포인터가 가리키는 메모리 생존이 필요 |
| 길이 + 버퍼 포인터 쌍 | 제자리 수정 비권장 | 루트 객체 교체 | 길이만 새 값이고 버퍼는 옛 값이면 메모리 오류 가능 |
| 함수 포인터 묶음 | 개별 변경 금지 | ops 테이블 통교체 | 중간 조합 노출 위험 |
struct nexthop {
struct rcu_head rcu;
};
static DEFINE_SPINLOCK(route_lock);
struct route_cfg {
atomic_long_t packets; /* 제자리 증가 가능 */
bool enabled; /* READ_ONCE/WRITE_ONCE 가능 */
u32 mtu; /* 단독 의미면 WRITE_ONCE 가능 */
struct nexthop __rcu *nh; /* 포인터는 RCU 규칙 필요 */
};
void route_fastpath_hit(struct route_cfg *cfg)
{
atomic_long_inc(&cfg->packets);
}
bool route_is_enabled(struct route_cfg *cfg)
{
return READ_ONCE(cfg->enabled);
}
void route_set_enabled(struct route_cfg *cfg, bool on)
{
WRITE_ONCE(cfg->enabled, on);
}
void route_replace_nh(struct route_cfg *cfg, struct nexthop *new_nh)
{
struct nexthop *old_nh;
spin_lock(&route_lock);
old_nh = rcu_replace_pointer(cfg->nh, new_nh,
lockdep_is_held(&route_lock));
spin_unlock(&route_lock);
if (old_nh)
call_rcu(&old_nh->rcu, nexthop_free_rcu);
}
핵심 감각: stale 값이 허용되는 단일 스칼라는 제자리 수정이 가능하지만, 다른 메모리 객체로 이어지는 관계가 들어가는 순간부터는 거의 항상 RCU publication 규칙이 다시 등장합니다. "포인터가 아니면 가볍고, 포인터면 무겁다"가 아니라, reader가 그 값을 어떻게 해석하는가가 기준입니다.
패턴 15: 부모는 고정하고 leaf 객체만 교체하는 세밀한 업데이트
패턴 7에서는 관련 필드를 모두 루트 객체에 묶어 통째로 교체했습니다. 하지만 모든 변경이 그렇게 무거울 필요는 없습니다. 부모 객체의 정체성은 유지하면서 하위 leaf 포인터 하나만 바꾸면 충분한 경우도 많습니다. 예를 들어 tenant, netdev, namespace 같은 상위 컨텍스트는 오래 살고, 그 밑의 현재 정책/쿼터/백엔드만 자주 바뀌는 경우입니다.
struct quota_state {
u64 hard_limit;
u64 soft_limit;
struct rcu_head rcu;
};
struct tenant_ctx {
spinlock_t lock;
refcount_t refcnt;
struct quota_state __rcu *quota;
};
int tenant_charge(struct tenant_ctx *tenant, u64 amount)
{
struct quota_state *quota;
int ret = 0;
rcu_read_lock();
quota = rcu_dereference(tenant->quota);
if (!quota || amount > quota->hard_limit)
ret = -EDQUOT;
rcu_read_unlock();
return ret;
}
void tenant_replace_quota(struct tenant_ctx *tenant,
struct quota_state *new_quota)
{
struct quota_state *old_quota;
spin_lock(&tenant->lock);
old_quota = rcu_replace_pointer(tenant->quota, new_quota,
lockdep_is_held(&tenant->lock));
spin_unlock(&tenant->lock);
if (old_quota)
kfree_rcu(old_quota, rcu);
}
- 언제 leaf 교체로 충분한가 - 부모 객체의 다른 필드와 새 leaf가 의미적으로 강하게 묶여 있지 않을 때입니다.
- 언제 루트 통교체가 필요한가 - 새 leaf와 함께 부모의 다른 설정값도 같은 generation이어야 할 때입니다. 이 경우 패턴 7처럼 루트를 통째로 바꾸는 편이 안전합니다.
- 상위 객체 수명은 별도 - 위 예시에서
tenant_ctx자체의 생존은 refcount가, 그 안의quotaleaf 생존은 RCU가 맡습니다. 이렇게 층별로 lifetime 도구가 다를 수 있습니다.
설계 감각: 자주 바뀌는 leaf와 오래 사는 parent를 분리하면 copy 비용을 줄일 수 있습니다. 반대로 parent와 leaf가 항상 한 세트로 움직여야 한다면 미세 최적화보다 generation 일관성이 더 중요합니다.
고급 패턴 선택 체크리스트
| 질문 | 예 | 권장 패턴 |
|---|---|---|
| reader가 unlock 뒤에도 객체를 계속 쓰는가 | 예 | 패턴 2 또는 8처럼 refcount handoff |
| timer/workqueue가 객체를 직접 들고 있는가 | 예 | 패턴 6 teardown 순서 적용 |
| 여러 scalar를 한 세트로 읽어야 하는가 | 예 | 패턴 10의 seqcount 재검증 |
| 핸들이 재사용될 수 있는가 | 예 | 패턴 11 generation 추가 |
| 객체가 버킷 사이를 이동하는가 | 예 | 패턴 12 hlist_nulls |
| listener/reader가 sleep 가능한가 | 예 | 패턴 5 또는 13의 SRCU |
| 관련 필드가 같은 generation이어야 하는가 | 예 | 패턴 7 루트 통교체 |
| parent는 안정적이고 leaf만 자주 바뀌는가 | 예 | 패턴 15 leaf 교체 |
네트워크: FIB 라우팅 테이블
net/ipv4/fib_trie.c의 라우팅 테이블은 RCU의 대표적 사용처입니다. 패킷 수신 경로(softirq)에서 매번 락 없이 라우팅 조회가 가능합니다.
/* net/ipv4/fib_trie.c — RCU 보호 라우팅 조회 (간략화) */
int fib_table_lookup(struct fib_table *tb,
const struct flowi4 *flp, struct fib_result *res)
{
struct trie *t = (struct trie *)tb->tb_data;
struct key_vector *pn;
rcu_read_lock();
pn = rcu_dereference(t->trie); /* trie 루트 접근 */
while (pn) {
/* RCU 보호 하에 trie 순회 — 락 없이 수백만 PPS 처리 */
pn = rcu_dereference(pn->child[idx]);
}
rcu_read_unlock();
return ret;
}
/* 라우팅 엔트리 업데이트 시: */
spin_lock_bh(&trie_lock); /* writer 동기화 */
rcu_assign_pointer(pn->child[idx], new_node);
spin_unlock_bh(&trie_lock);
call_rcu(&old_node->rcu, fib_free_node); /* GP 후 해제 */
VFS: dentry 캐시 + SLAB_TYPESAFE_BY_RCU
fs/dcache.c의 dentry 캐시는 SLAB_TYPESAFE_BY_RCU를 사용하여, slab 할당자가 GP 없이도 메모리를 재사용할 수 있게 합니다. 대신 reader가 접근한 dentry가 재할당되었을 수 있으므로, seqlock으로 유효성을 재검증합니다.
/* fs/dcache.c — SLAB_TYPESAFE_BY_RCU + seqlock 패턴 */
rcu_read_lock();
retry:
dentry = rcu_dereference(parent->d_child);
seq = read_seqcount_begin(&dentry->d_seq);
/* dentry 필드 접근 */
name = dentry->d_name;
if (read_seqcount_retry(&dentry->d_seq, seq))
goto retry; /* 재할당 감지 → 재시도 */
rcu_read_unlock();
/* slab 생성 시 TYPESAFE_BY_RCU 플래그 */
dentry_cache = kmem_cache_create("dentry",
sizeof(struct dentry), 0,
SLAB_RECLAIM_ACCOUNT | SLAB_PANIC | SLAB_TYPESAFE_BY_RCU,
NULL);
SLAB_TYPESAFE_BY_RCU 주의: 이 플래그는 객체 메모리가 GP 없이 재사용될 수 있음을 의미합니다. 따라서 RCU reader가 접근한 객체가 이미 다른 용도로 재할당되었을 수 있습니다. 반드시 seqlock이나 generation counter로 유효성을 재검증해야 합니다. 단순 RCU 보호와 혼동하면 심각한 UAF 버그가 됩니다.
프로세스: task_struct 순회
/* kernel/fork.c, include/linux/sched/signal.h */
/* RCU 보호 프로세스 리스트 순회 */
rcu_read_lock();
for_each_process(p) {
/* p는 RCU 보호 하에 안전하게 참조 가능
* p가 exit하더라도 GP 동안 task_struct가 유지됨 */
if (p->pid == target_pid) {
get_task_struct(p); /* 참조 카운트 증가 (RCU 밖에서 사용하려면) */
break;
}
}
rcu_read_unlock();
/* find_task_by_vpid()도 내부적으로 RCU 사용 */
rcu_read_lock();
task = find_task_by_vpid(pid);
if (task)
get_task_struct(task);
rcu_read_unlock();
메모리: anon_vma 역매핑
/* mm/rmap.c — anon_vma RCU 순회 (간략화) */
/* 물리 페이지 → 가상 주소 역매핑에서 RCU 사용 */
rcu_read_lock();
anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root, start, end) {
vma = avc->vma;
/* RCU 보호 하에 VMA 접근 — page reclaim 핫 경로 */
address = vma_address(page, vma);
try_to_unmap_one(folio, vma, address, ...);
}
rcu_read_unlock();
/* anon_vma 해제 시 */
call_rcu(&anon_vma->rcu, anon_vma_free); /* GP 후 안전 해제 */
네트워크 디바이스: rx_handler 교체
/* net/core/dev.c — rx_handler 교체 패턴 */
/* 수신 핸들러 등록 (bridge, bonding 등) */
int netdev_rx_handler_register(struct net_device *dev,
rx_handler_func_t *rx_handler, void *rx_handler_data)
{
rcu_assign_pointer(dev->rx_handler_data, rx_handler_data);
rcu_assign_pointer(dev->rx_handler, rx_handler);
return 0;
}
/* 패킷 수신 경로에서 (softirq) */
rx_handler = rcu_dereference(skb->dev->rx_handler);
if (rx_handler) {
/* 락 없이 핸들러 호출 — 수백만 PPS에서도 무잠금 */
ret = rx_handler(&skb);
}
/* 핸들러 해제 시 */
RCU_INIT_POINTER(dev->rx_handler, NULL);
synchronize_net(); /* 네트워크 RCU GP 대기 */
고급 RCU Trick 모음
앞선 섹션들이 "어떤 자료구조를 어떻게 RCU로 설계할 것인가"에 가까웠다면, 이 섹션은 실제 커널 코드에서 자주 보이는 구현 트릭과 관용 패턴을 모아 둔 것입니다. 여기서 말하는 trick은 편법이 아니라, 성능과 정확성을 동시에 만족시키기 위해 반복적으로 등장하는 미세 설계 기법을 뜻합니다.
| Trick | 핵심 API | 해결하는 문제 |
|---|---|---|
| 소유권 handoff | rcu_pointer_handoff() | RCU 보호 포인터를 refcount/락 소유 포인터로 전환 |
| 빈 경로 빠른 탈출 | rcu_access_pointer() | 대부분 NULL인 fast path에서 불필요한 RCU 진입 회피 |
| 재검증 후 사용 | lookup + lock + validate | RCU가 메모리 안전만 보장하고 의미 안전은 별도 보장해야 하는 상황 |
| 콜백에서 무거운 정리 이관 | call_rcu() + workqueue | RCU 콜백에서 sleep 불가 제약 회피 |
| 대형 가변 객체 회수 | kvfree_rcu() | kvmalloc/vmalloc 기반 큰 객체를 GP 뒤에 안전하게 해제 |
| 혼합 컨텍스트 증명 | rcu_dereference_check() | RCU reader와 writer-locked 경로가 같은 헬퍼를 공유할 때 lockdep에 의도 전달 |
| 초기화 포인터 publish 구분 | RCU_INIT_POINTER / rcu_assign_pointer() | 초기화/teardown과 live publication의 메모리 순서를 구분 |
| publication ref | refcount_inc_not_zero(), kfree_rcu() | 테이블이 보유한 참조와 외부 사용자 참조를 명확히 분리 |
읽는 방법: 각 trick의 표면 문법보다도, "무슨 invariant를 지키기 위해 이 기법이 필요한가"를 중심으로 보세요. RCU 코드는 API 이름보다도 수명 전이 규칙과 reader가 볼 수 있는 중간 상태의 범위가 핵심입니다.
Trick 1: rcu_pointer_handoff()로 소유권 전이를 문서화
RCU lookup 뒤에 refcount를 올렸거나 락을 획득해서, 포인터가 더 이상 "RCU에만 의존하는 임시 포인터"가 아니라 "이제는 다른 소유권 규칙 아래 안전한 포인터"가 되는 순간이 있습니다. 이때 rcu_pointer_handoff()를 쓰면 sparse에 의도를 명확히 전달할 수 있습니다.
struct cache_obj {
u32 key;
refcount_t refcnt;
struct hlist_node node;
struct rcu_head rcu;
};
struct cache_obj *cache_lookup_get(u32 key)
{
struct cache_obj *obj;
rcu_read_lock();
hlist_for_each_entry_rcu(obj, &cache_ht[hash_min(key, CACHE_BITS)], node) {
if (obj->key != key)
continue;
if (!refcount_inc_not_zero(&obj->refcnt))
continue;
rcu_read_unlock();
return rcu_pointer_handoff(obj);
}
rcu_read_unlock();
return NULL;
}
- 무엇이 바뀌는가 - 포인터의 메모리 안전 근거가
rcu_read_lock()에서refcnt > 0으로 바뀝니다. - 왜 쓰는가 - 컴파일 결과가 극적으로 달라지는 것은 아니지만, sparse와 코드 리뷰에서 "여기서부터는 일반 포인터다"라는 뜻이 분명해집니다.
- 언제 쓰면 안 되는가 - refcount도 안 올렸고 락도 안 잡은 상태에서 단지
rcu_read_unlock()직전에 호출하는 것은 의미가 없습니다. handoff는 새 소유권이 실제로 생긴 뒤에만 정당합니다.
Trick 2: rcu_access_pointer()로 대부분 비어 있는 fast path를 싸게 거르기
훅, 필터, optional callback처럼 대부분의 시간에는 NULL이고 가끔만 설정되는 포인터가 있습니다. 이런 경우 매번 rcu_read_lock()으로 들어가는 비용도 아깝다면, 먼저 rcu_access_pointer()로 값만 보고 빠르게 탈출한 뒤, 실제 역참조가 필요할 때만 정식 RCU read-side에 진입할 수 있습니다.
int egress_maybe_run(struct packet *pkt)
{
struct egress_prog *prog;
int ret = 0;
/* 값만 확인 — 역참조 금지 */
if (!rcu_access_pointer(pkt->dev->egress_prog))
return 0;
rcu_read_lock();
prog = rcu_dereference(pkt->dev->egress_prog);
if (prog)
ret = prog_run(prog, pkt);
rcu_read_unlock();
return ret;
}
- 첫 번째 체크는 힌트일 뿐입니다.
rcu_access_pointer()결과를 보고 "있다"고 판단해도, 실제로 RCU lock을 잡고 들어갔을 때는 이미 NULL로 바뀌었을 수 있습니다. - 그래서 두 번째 체크가 필요합니다. 위 코드에서
if (prog)가 그 역할입니다. - 반대로 NULL fast reject는 안전합니다. 처음에 NULL을 봤다면 단지 "지금 이 순간 없었다"는 뜻일 뿐이므로 그대로 빠져나와도 의미가 맞습니다.
자주 하는 실수: rcu_access_pointer()의 반환값을 바로 역참조하는 것입니다. 이 API는 포인터 값 확인용이지, 데이터를 읽기 위한 API가 아닙니다. 역참조가 시작되는 순간에는 반드시 rcu_dereference()로 넘어가야 합니다.
Trick 3: RCU lookup 후 락을 잡고 의미를 재검증
RCU는 "이 포인터가 가리키는 메모리가 아직 살아 있다"는 사실은 잘 보장하지만, "이 객체가 지금도 논리적으로 유효하다"는 사실까지 대신 증명해 주지는 않습니다. 그래서 lookup을 RCU로 빠르게 하고, 그 뒤 객체 락을 잡거나 참조를 획득한 다음 키, 상태, 리스트 연결 여부를 다시 확인하는 패턴이 매우 흔합니다.
struct peer {
spinlock_t lock;
u32 key;
bool dead;
refcount_t refcnt;
struct hlist_node node;
struct rcu_head rcu;
};
struct peer *peer_lookup_lock(u32 key)
{
struct peer *peer;
retry:
peer = peer_lookup_get(key); /* 패턴 2의 RCU + refcount lookup */
if (!peer)
return NULL;
spin_lock(&peer->lock);
if (peer->dead || peer->key != key) {
spin_unlock(&peer->lock);
peer_put(peer);
goto retry;
}
return peer; /* lock + refcount 둘 다 확보 */
}
언제 필요한가? 객체가 재활용되거나, 제거 직전에 dead 상태로 바뀌거나, lookup key와 내부 상태가 동기적으로 움직이지 않을 수 있는 구조에서는 거의 항상 필요합니다. 특히 SLAB_TYPESAFE_BY_RCU 계열과 잘 어울립니다.
Trick 4: call_rcu() 콜백은 가볍게, 무거운 정리는 workqueue로 넘기기
call_rcu() 콜백은 sleep할 수 없습니다. 하지만 실제 해제 작업이 fput(), kvfree(), mutex, firmware 정리처럼 잠들 수 있는 연산을 포함할 수 있습니다. 이럴 때 흔히 쓰는 trick이, RCU 콜백에서는 단지 workqueue에 넘기는 역할만 하고 실제 무거운 정리는 worker가 담당하게 하는 것입니다.
struct big_state {
struct work_struct free_work;
void *blob; /* kvmalloc() 메모리 */
struct file *trace_file; /* sleep 가능한 정리 필요 */
struct rcu_head rcu;
};
static void big_state_free_workfn(struct work_struct *work)
{
struct big_state *st = container_of(work, struct big_state, free_work);
if (st->trace_file)
fput(st->trace_file); /* sleep 가능 */
kvfree(st->blob);
kfree(st);
}
static void big_state_free_rcu(struct rcu_head *rh)
{
struct big_state *st = container_of(rh, struct big_state, rcu);
INIT_WORK(&st->free_work, big_state_free_workfn);
queue_work(system_unbound_wq, &st->free_work);
}
void big_state_retire(struct big_state *old)
{
call_rcu(&old->rcu, big_state_free_rcu);
}
- RCU 콜백의 역할 - grace period가 지난 뒤 "이제 sleep 가능한 정리를 해도 된다"는 신호를 workqueue로 전달합니다.
- 왜 유용한가 - fast path에는 여전히 비동기 retire만 보이고, 무거운 정리는 프로세스 컨텍스트로 밀어낼 수 있습니다.
- 주의 - 모듈 언로드 시에는
rcu_barrier()뿐 아니라 큐에 들어간 work까지flush_work()/cancel_work_sync()로 정리해야 합니다.
Trick 5: 큰 가변 길이 객체는 kvfree_rcu()로 정리
비트맵, 필터 테이블, 문자열 사전처럼 크기가 커서 kmalloc()로는 불안정하고 kvmalloc()을 써야 하는 객체가 있습니다. 이런 객체를 RCU로 교체할 때는 kfree_rcu() 대신 kvfree_rcu()가 훨씬 편합니다.
struct big_bitmap {
u32 nr_bits;
struct rcu_head rcu;
unsigned long bits[];
};
static DEFINE_MUTEX(bitmap_lock);
static struct big_bitmap __rcu *g_bitmap;
int bitmap_reload(u32 nr_bits)
{
struct big_bitmap *new_bm, *old_bm;
size_t nlongs = BITS_TO_LONGS(nr_bits);
new_bm = kvmalloc(struct_size(new_bm, bits, nlongs), GFP_KERNEL);
if (!new_bm)
return -ENOMEM;
new_bm->nr_bits = nr_bits;
bitmap_zero(new_bm->bits, nr_bits);
mutex_lock(&bitmap_lock);
old_bm = rcu_replace_pointer(g_bitmap, new_bm,
lockdep_is_held(&bitmap_lock));
mutex_unlock(&bitmap_lock);
if (old_bm)
kvfree_rcu(old_bm, rcu);
return 0;
}
왜 좋은가? 새 객체가 작은 경우 내부적으로 kfree 경로를 타고, 큰 경우 vfree 경로를 타는 차이를 호출자가 직접 신경 쓰지 않아도 됩니다. 대형 테이블을 자주 스왑하는 구성에서는 코드 단순화 효과가 큽니다.
Trick 6: rcu_dereference_check()로 혼합 컨텍스트를 lockdep에 증명
실전 헬퍼 함수 중에는 reader 경로에서는 RCU lock 아래서 호출되고, writer 경로에서는 이미 mutex나 spinlock을 쥔 채로 호출되는 경우가 많습니다. 이런 함수를 위해 rcu_dereference_check()를 쓰면 "이 포인터는 RCU reader이거나 writer lock 아래서만 읽는다"는 사실을 lockdep에 문서화할 수 있습니다.
struct policy_db {
struct policy_root __rcu *root;
struct mutex update_lock;
};
static struct policy_root *policy_root_get(struct policy_db *db)
{
return rcu_dereference_check(db->root,
lockdep_is_held(&db->update_lock));
}
int policy_lookup(struct policy_db *db, const struct request *req)
{
struct policy_root *root;
rcu_read_lock();
root = policy_root_get(db);
rcu_read_unlock();
return root ? match_request(root, req) : -ENOENT;
}
void policy_replace(struct policy_db *db, struct policy_root *new_root)
{
struct policy_root *old_root;
mutex_lock(&db->update_lock);
old_root = policy_root_get(db); /* writer도 같은 헬퍼 재사용 */
rcu_assign_pointer(db->root, new_root);
mutex_unlock(&db->update_lock);
if (old_root)
call_rcu(&old_root->rcu, policy_root_free_rcu);
}
이 trick의 장점은 "reader용 getter"와 "writer용 getter"를 따로 만들지 않아도 된다는 점입니다. 대신 전제 조건을 lockdep 조건식으로 명시해, 디버그 빌드에서 잘못된 호출 문맥을 잡아낼 수 있습니다.
Trick 7: RCU_INIT_POINTER와 rcu_assign_pointer()의 역할 분리
둘 다 포인터를 저장하지만 의미는 다릅니다. rcu_assign_pointer()는 live reader가 존재하는 시스템에서의 publish이고, RCU_INIT_POINTER()는 아직 reader가 없거나, 다른 동기화로 이미 완전히 배제된 상황에서의 단순 대입에 가깝습니다.
| 상황 | 권장 API | 이유 |
|---|---|---|
| 부팅 초기, 아직 외부 reader 없음 | RCU_INIT_POINTER() | 불필요한 publish 배리어 생략 가능 |
| 운영 중 새 객체를 reader에게 게시 | rcu_assign_pointer() | 초기화 완료 후 publish 순서 보장 필요 |
| 운영 중 포인터를 NULL로 제거 | 상황 의존 | reader가 동시에 볼 수 있으면 일반적으로 RCU_INIT_POINTER보다 rcu_assign_pointer가 보수적 |
| writer lock이 reader를 완전히 배제하는 teardown | RCU_INIT_POINTER() | 이미 외부 관찰자가 없음 |
/* 1. 초기 부팅/생성 경로 — 아직 외부 reader 없음 */
RCU_INIT_POINTER(dev->rx_handler, NULL);
/* 2. 운영 중 새 핸들러 게시 */
new_handler->ctx = ctx;
new_handler->flags = flags;
rcu_assign_pointer(dev->rx_handler, new_handler);
/* 3. 운영 중 해제 — 기존 reader가 있을 수 있으므로 GP 필요 */
rcu_assign_pointer(dev->rx_handler, NULL);
synchronize_rcu();
/* 4. 완전히 고립된 객체 파괴 경로 */
mutex_lock(&dev->destroy_lock); /* 외부 접근 완전 차단 */
RCU_INIT_POINTER(dev->rx_handler, NULL);
mutex_unlock(&dev->destroy_lock);
보수적으로 가도 손해가 적습니다. 문맥이 확실하지 않다면 rcu_assign_pointer()를 쓰는 편이 낫습니다. 반대로 live publication인데 RCU_INIT_POINTER()를 써 버리면 메모리 순서 버그가 숨어들 수 있습니다.
Trick 8: publication ref를 별도 개념으로 두면 teardown이 단순해진다
앞의 여러 예시에서 이미 암묵적으로 썼던 개념이 publication ref입니다. 이는 "테이블이나 리스트가 이 객체를 외부에 게시하고 있으므로, 자료구조 자체가 객체 참조 1개를 보유한다"는 규칙입니다. 이 규칙을 명시하면 teardown과 lookup handoff가 매우 정리됩니다.
struct job {
refcount_t refcnt;
bool dead;
struct hlist_node node;
struct rcu_head rcu;
};
struct job *job_alloc_publish(u32 key)
{
struct job *job = kzalloc(sizeof(*job), GFP_KERNEL);
if (!job)
return NULL;
refcount_set(&job->refcnt, 1); /* publication ref */
publish_job_to_hash(job, key);
return job;
}
struct job *job_get_from_lookup(u32 key)
{
struct job *job;
rcu_read_lock();
job = lookup_job_rcu(key);
if (job && !refcount_inc_not_zero(&job->refcnt))
job = NULL;
rcu_read_unlock();
return job;
}
void job_unpublish(struct job *job)
{
WRITE_ONCE(job->dead, true);
remove_job_from_hash_rcu(job);
job_put(job); /* publication ref 반납 */
}
void job_put(struct job *job)
{
if (refcount_dec_and_test(&job->refcnt))
kfree_rcu(job, rcu);
}
- 장점 1 - 자료구조 제거와 마지막 사용자 종료를 동일한
job_put()경로로 합칠 수 있습니다. - 장점 2 - lookup reader는 "publication ref 1개가 살아 있는 동안에는
refcount_inc_not_zero()가 성공할 수 있다"는 단순한 규칙 위에서 동작합니다. - 장점 3 - teardown 코드에서 "이 put은 누구 몫인가?"를 추적하기 쉬워집니다.
Trick 9: 객체 수에 따라 synchronize_rcu()와 call_rcu()를 전략적으로 섞기
자주 놓치는 부분은 call_rcu()와 synchronize_rcu()가 서로 배타적인 선택지가 아니라는 점입니다. 실전에서는 retire 수량과 문맥에 따라 둘을 섞어서 쓰는 것이 더 낫습니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 객체 1~2개 제거, 호출 경로가 비블로킹이어야 함 | call_rcu() / kfree_rcu() | writer latency 최소화 |
| 수천 개 객체 일괄 retire, process context | synchronize_rcu() 후 일괄 free | callback flood 방지 |
| 소수는 즉시, 다수는 배치 | 핫 경로는 call_rcu(), maintenance path는 batch free | latency와 메모리 사용량 균형 |
void route_gc_run(bool incremental)
{
if (incremental) {
/* 핫 경로 근처: 작은 수량만 비동기 retire */
kfree_rcu(victim, rcu);
return;
}
/* maintenance 스레드: 큰 배치는 한 번에 GP 대기 */
detach_many_routes_rcu(&retired);
synchronize_rcu();
free_retired_routes(&retired);
}
이 trick의 핵심은 API를 고르는 것이 아니라 어떤 호출 문맥에서 어떤 비용을 지불할지 분리하는 것입니다. RCU 자체보다 운영 비용이 병목이 되는 구간에서는 이런 전략적 혼합이 체감 차이를 만듭니다.
Trick 10: 1회성 publish는 cmpxchg_release 계열로 경합을 줄일 수 있다
가끔은 "최초 한 번만 설치되면 되고, 이후에는 거의 바뀌지 않는" singleton 포인터가 있습니다. 이런 경우 매번 별도 writer lock을 잡기보다, 완전히 초기화한 객체를 한 번만 원자적으로 publish하는 방식이 더 단순할 수 있습니다. 이때 쓰는 저수준 trick이 cmpxchg_release 계열입니다.
struct global_prog {
u32 version;
struct rcu_head rcu;
};
static struct global_prog __rcu *g_prog;
int prog_install_once(struct global_prog *new_prog)
{
struct global_prog *old;
/* new_prog는 여기 오기 전에 완전히 초기화되어 있어야 함 */
old = cmpxchg_release((struct global_prog **)&g_prog,
NULL, new_prog);
if (!old)
return 0; /* 내가 설치 성공 */
kfree(new_prog); /* 다른 writer가 먼저 설치함 */
return -EEXIST;
}
struct global_prog *prog_get(void)
{
struct global_prog *prog;
rcu_read_lock();
prog = rcu_dereference(g_prog);
rcu_read_unlock();
return prog;
}
- 언제 적합한가 - 최초 설치 경쟁만 해결하면 되고, 설치 후에는 거의 immutable인 전역 루트에 적합합니다.
- 왜 release인가 - writer가 채운 필드들이 포인터 publish보다 먼저 보이도록 해야 reader가 불완전한 객체를 보지 않습니다.
- 언제 피해야 하는가 - 일반적인 교체(update) 경로에는 부적합합니다. old/new 전환, 해제, 재시도 로직이 복잡해지므로 보통은 락 +
rcu_replace_pointer()가 더 읽기 쉽고 안전합니다.
저수준 trick: 이 방식은 명확한 불변 조건이 있을 때만 쓰는 편이 좋습니다. "writer가 여럿이지만 최초 설치 한 번만 경쟁한다" 같은 매우 좁은 문제에만 적용하고, 일반적인 운영 중 갱신 경로에는 남용하지 마십시오.
Trick 11: rcu_head_init()와 rcu_head_after_call_rcu()로 이중 retire를 잡기
복잡한 오류 경로나 중복 cleanup 경로가 있는 서브시스템에서는 같은 객체에 대해 call_rcu()가 두 번 들어가는 실수가 생각보다 흔합니다. 이건 보통 늦게 폭발하는 use-after-free나 double-free로 이어집니다. 디버그 빌드에서 이 실수를 일찍 잡아내는 trick이 rcu_head_init()와 rcu_head_after_call_rcu() 조합입니다.
struct retire_obj {
bool dead;
struct rcu_head rcu;
};
static void retire_obj_free_rcu(struct rcu_head *rh)
{
struct retire_obj *obj = container_of(rh, struct retire_obj, rcu);
kfree(obj);
}
void retire_obj_init(struct retire_obj *obj)
{
rcu_head_init(&obj->rcu);
obj->dead = false;
}
void retire_obj_queue_free(struct retire_obj *obj)
{
if (READ_ONCE(obj->dead))
return;
WRITE_ONCE(obj->dead, true);
if (rcu_head_after_call_rcu(&obj->rcu, retire_obj_free_rcu))
return; /* 이미 retire 등록됨 */
call_rcu(&obj->rcu, retire_obj_free_rcu);
}
언제 특히 유용한가? 에러 처리, timeout 처리, normal cleanup이 서로 다른 경로에서 같은 객체를 정리할 수 있는 드라이버나 네트워크 상태 머신에서 유용합니다. 운영 빌드에서 완전한 동기화 수단을 대신하지는 않지만, 디버그 단계에서 문제를 빨리 드러내 줍니다.
관련 문서
RCU와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.