Seqlock (순차 잠금)
리눅스 커널의 시퀀스 잠금(seqlock)을 분석합니다. 짝수/홀수 시퀀스 카운터 원리, seqcount_t와 seqlock_t의 차이, 리더 재시도 루프, 라이터 직렬화(Serialization) 경로, jiffies·timekeeper·vDSO의 실전 사용 사례, seqcount_latch 이중 버퍼(Buffer) 패턴, seqcount_LOCKNAME_t 연관 잠금, 메모리 배리어(Memory Barrier) 의미론, PREEMPT_RT 영향, rwlock/RCU 비교까지 커널 소스 기반으로 정리합니다.
핵심 요약
- 시퀀스 카운터 원리 — 짝수(even)=일관된 상태, 홀수(odd)=쓰기 진행 중. 리더는 읽기 전후 시퀀스가 같고 짝수면 성공, 아니면 재시도합니다.
- 리더 비차단(Non-blocking) — 리더는 절대 블록되지 않지만, 라이터와 동시 실행 시 재시도해야 합니다. 라이터가 빈번하면 리더가 starvation될 수 있습니다.
- seqcount_t vs seqlock_t —
seqcount_t는 시퀀스 카운터만 제공하여 외부 잠금이 필요하고,seqlock_t는 spinlock을 내장하여 라이터 직렬화를 자체 처리합니다. - 대표 사용처 —
jiffies보호,struct timekeeper, vDSO 데이터, 네트워크 통계, 파일시스템(Filesystem) 시간 관리. - seqcount_latch — NMI 안전 이중 버퍼 패턴으로, 쓰기 중에도 항상 일관된 읽기가 가능합니다.
단계별 이해
- 시퀀스 카운터 개념 이해
짝수/홀수로 쓰기 상태를 추적하고, 리더가 재시도하는 기본 메커니즘을 파악합니다. - seqcount_t와 seqlock_t 구분
시퀀스 카운터만 제공하는 seqcount_t와 spinlock을 내장한 seqlock_t의 차이를 이해합니다. - 읽기/쓰기 경로 추적
read_seqbegin()→읽기→read_seqretry()루프와write_seqlock()→쓰기→write_sequnlock()경로를 따라갑니다. - 실전 사용 사례 학습
jiffies, timekeeper, vDSO 등 커널 내 실제 적용 패턴을 분석합니다. - 고급 패턴과 대안 비교
seqcount_latch, 연관 잠금, PREEMPT_RT 영향, rwlock/RCU와의 비교를 통해 적절한 선택 기준을 확립합니다.
이론적 배경: Sequence Lock의 원리
Seqlock(Sequence Lock)은 라이터 우선(writer-priority) 동기화 메커니즘입니다. 공유 데이터에 시퀀스 카운터를 부착하여, 리더가 데이터 일관성을 검증하고 불일치 시 재시도하는 낙관적(optimistic) 읽기 패턴을 구현합니다.
시퀀스 카운터의 원리
핵심 아이디어는 단순합니다:
- 시퀀스 카운터를 0(짝수)으로 초기화
- 라이터가 쓰기를 시작하면 카운터를 홀수로 증가 (0→1)
- 라이터가 쓰기를 완료하면 카운터를 짝수로 증가 (1→2)
- 리더는 읽기 전후의 카운터 값을 비교: 같고 짝수면 일관된 데이터
/* 시퀀스 카운터 상태 전이 */
시퀀스 값: 0 1 2 3 4
상태: 일관 쓰기중 일관 쓰기중 일관
(even) (odd) (even) (odd) (even)
리더 판정:
읽기 전 seq=0, 읽기 후 seq=0 → 성공 (같고 짝수)
읽기 전 seq=0, 읽기 후 seq=1 → 실패 (쓰기 발생, 재시도)
읽기 전 seq=1 → 실패 (홀수=쓰기 중, 재시도)
읽기 전 seq=0, 읽기 후 seq=2 → 실패 (쓰기 완료됨, 재시도)
기존 잠금과의 근본 차이
| 특성 | spinlock/mutex | rwlock | seqlock |
|---|---|---|---|
| 리더 차단 | 항상 | 라이터 보유 시 | 절대 안 함 |
| 라이터 차단 | 다른 보유자에 의해 | 리더/라이터에 의해 | 다른 라이터에 의해만 |
| 리더 오버헤드(Overhead) | 원자적 연산(Atomic Operation) | 원자적 연산 | 시퀀스 비교만 (배리어) |
| 리더 진행 보장 | 유한 대기 | 유한 대기 | 보장 안 됨 (starvation 가능) |
| 적합 시나리오 | 짧은 배타적 접근 | 읽기 >> 쓰기 | 읽기 >> 쓰기, 쓰기가 빨라야 함 |
seqcount_t vs seqlock_t vs seqcount_latch_t
커널은 세 가지 주요 시퀀스 카운터 변형을 제공합니다. 각각의 용도와 특성이 다릅니다.
| 타입 | 라이터 직렬화 | 리더 재시도 | NMI-safe | 대표 사용처 |
|---|---|---|---|---|
seqcount_t | 외부 잠금 필요 | 필요 | raw_read_seqcount() | 이미 잠금 보유 상황 |
seqlock_t | 내장 spinlock | 필요 | 읽기만 | jiffies, timekeeper |
seqcount_latch_t | 외부 잠금 필요 | 불필요 (이중 버퍼) | 완전 지원 | NMI-safe 통계 |
데이터 구조 분석
seqlock의 핵심 데이터 구조를 커널 소스(include/linux/seqlock.h) 기반으로 분석합니다.
seqcount_t 구조
/* include/linux/seqlock.h */
typedef struct seqcount {
unsigned sequence;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} seqcount_t;
/* 초기화 매크로 */
#define SEQCNT_ZERO(name) { .sequence = 0, ... }
#define seqcount_init(s) do { (s)->sequence = 0; ... } while (0)
seqlock_t 구조
/* include/linux/seqlock.h */
typedef struct {
struct seqcount_spinlock seqcount;
spinlock_t lock;
} seqlock_t;
/* 초기화 */
#define __SEQLOCK_UNLOCKED(name) { \
.seqcount = SEQCNT_SPINLOCK_ZERO(name, &(name).lock), \
.lock = __SPIN_LOCK_UNLOCKED(name.lock) \
}
#define DEFINE_SEQLOCK(name) \
seqlock_t name = __SEQLOCK_UNLOCKED(name)
seqcount_latch_t 구조
/* include/linux/seqlock.h */
typedef struct {
seqcount_t seqcount;
} seqcount_latch_t;
/* 초기화 */
#define SEQCNT_LATCH_ZERO(name) { \
.seqcount = SEQCNT_ZERO(name.seqcount), \
}
seqcount_t API 레퍼런스
seqcount_t는 시퀀스 카운터만 제공하며, 라이터 직렬화를 위한 외부 잠금이 필요합니다.
| 함수 | 설명 | 컨텍스트 |
|---|---|---|
raw_read_seqcount_begin() | 시퀀스 읽기 시작 (smp_rmb 포함, 홀수 시 스핀) | 모든 컨텍스트 |
read_seqcount_begin() | 위와 동일 + lockdep 검증 | 모든 컨텍스트 |
read_seqcount_retry() | 읽기 종료, 시퀀스 불일치 시 true 반환 | 모든 컨텍스트 |
raw_read_seqcount() | 홀수 대기 없이 즉시 시퀀스 반환 (NMI용) | NMI |
raw_seqcount_begin() | 시퀀스를 짝수로 맞춰서 반환 (홀수면 & ~1) | 특수 용도 |
write_seqcount_begin() | 시퀀스 +1 (홀수로), smp_wmb 삽입 | 라이터 |
write_seqcount_end() | smp_wmb + 시퀀스 +1 (짝수로) | 라이터 |
write_seqcount_invalidate() | 시퀀스를 홀수로 만들어 모든 리더 무효화(Invalidation) | 라이터 |
raw_write_seqcount_barrier() | 배리어만, 시퀀스 +2 (latch 패턴용) | 라이터 |
seqcount_t 읽기 패턴
unsigned seq;
u64 value;
do {
seq = read_seqcount_begin(&sc);
/* 공유 데이터 읽기 — 부수 효과 금지! */
value = shared_data.field;
} while (read_seqcount_retry(&sc, seq));
seqcount_t 쓰기 패턴
/* 외부 잠금으로 라이터 직렬화 필요 */
spin_lock(&my_lock);
write_seqcount_begin(&sc);
shared_data.field = new_value;
write_seqcount_end(&sc);
spin_unlock(&my_lock);
seqlock_t API 레퍼런스
seqlock_t는 spinlock을 내장하여 라이터 직렬화를 자체 처리합니다. 가장 자주 사용되는 형태입니다.
| 함수 | 설명 | 컨텍스트 |
|---|---|---|
read_seqbegin() | 시퀀스 읽기 시작 | 모든 컨텍스트 |
read_seqretry() | 시퀀스 불일치 시 true 반환 | 모든 컨텍스트 |
write_seqlock() | spinlock 획득 + 시퀀스 증가 | 프로세스(Process) |
write_sequnlock() | 시퀀스 증가 + spinlock 해제 | 프로세스 |
write_seqlock_irqsave() | IRQ 비활성화 + spinlock + 시퀀스 | 인터럽트(Interrupt) 공유 시 |
write_sequnlock_irqrestore() | 시퀀스 + spinlock 해제 + IRQ 복원 | 인터럽트 공유 시 |
write_seqlock_bh() | softirq 비활성화 + spinlock + 시퀀스 | softirq 공유 시 |
write_sequnlock_bh() | 시퀀스 + spinlock 해제 + softirq 활성화 | softirq 공유 시 |
read_seqbegin_irqsave() | IRQ 비활성화 + 시퀀스 읽기 시작 | IRQ 안전 읽기 |
read_seqretry_irqrestore() | 시퀀스 확인 + IRQ 복원 | IRQ 안전 읽기 |
Read 경로: read_seqbegin/read_seqretry 루프
seqlock 리더의 핵심은 do-while 루프입니다. 데이터 읽기 전후로 시퀀스 번호를 확인하여 일관성을 검증합니다.
/* include/linux/seqlock.h — read_seqbegin() 구현 */
static inline unsigned read_seqbegin(const seqlock_t *sl)
{
unsigned ret = read_seqcount_begin(&sl->seqcount);
kcsan_atomic_next(KCSAN_SEQLOCK_REGION_MAX);
return ret;
}
/* read_seqcount_begin() 핵심 */
static inline unsigned read_seqcount_begin(const seqcount_t *s)
{
unsigned ret;
repeat:
ret = READ_ONCE(s->sequence);
if (unlikely(ret & 1)) {
cpu_relax(); /* 홀수 = 쓰기 중, 스핀 대기 */
goto repeat;
}
smp_rmb(); /* 시퀀스 읽기 → 데이터 읽기 순서 보장 */
return ret;
}
/* read_seqretry() 핵심 */
static inline int read_seqretry(const seqlock_t *sl, unsigned start)
{
return read_seqcount_retry(&sl->seqcount, start);
}
/* read_seqcount_retry() 핵심 */
static inline int read_seqcount_retry(const seqcount_t *s, unsigned start)
{
smp_rmb(); /* 데이터 읽기 → 시퀀스 읽기 순서 보장 */
return unlikely(READ_ONCE(s->sequence) != start);
}
Write 경로: write_seqlock/write_sequnlock
seqlock의 쓰기 경로는 단순합니다: spinlock 획득 → 시퀀스 홀수화 → 데이터 수정 → 시퀀스 짝수화 → spinlock 해제.
/* include/linux/seqlock.h — write_seqlock() 구현 */
static inline void write_seqlock(seqlock_t *sl)
{
spin_lock(&sl->lock);
write_seqcount_begin(&sl->seqcount);
}
/* write_seqcount_begin() 핵심 */
static inline void write_seqcount_begin(seqcount_t *s)
{
s->sequence++; /* even → odd: 쓰기 시작 */
smp_wmb(); /* 시퀀스 증가 → 데이터 쓰기 순서 보장 */
}
/* write_sequnlock() 구현 */
static inline void write_sequnlock(seqlock_t *sl)
{
write_seqcount_end(&sl->seqcount);
spin_unlock(&sl->lock);
}
/* write_seqcount_end() 핵심 */
static inline void write_seqcount_end(seqcount_t *s)
{
smp_wmb(); /* 데이터 쓰기 → 시퀀스 증가 순서 보장 */
s->sequence++; /* odd → even: 쓰기 완료 */
}
Retry 메커니즘과 진행 보장
seqlock의 리더는 진행 보장(forward progress guarantee)이 없습니다. 라이터가 지속적으로 쓰기를 수행하면 리더는 무한히 재시도할 수 있습니다.
재시도 시나리오 분석
| 시나리오 | read_seqbegin 결과 | read_seqretry 결과 | 동작 |
|---|---|---|---|
| 쓰기 없음 | 짝수 반환 (즉시) | false (성공) | 1회만에 성공 |
| 읽기 중 쓰기 시작 | 짝수 반환 | true (시퀀스 변경) | 재시도 |
| 쓰기 중 읽기 시도 | 홀수 → 스핀 대기 | — | 짝수 될 때까지 스핀 |
| 읽기 중 쓰기 완료+재시작(Reboot) | 짝수 반환 | true (시퀀스 +2) | 재시도 |
| 연속적 쓰기 | 반복 스핀 | — | starvation |
/* starvation 방지 패턴: 재시도 횟수 제한 후 fallback */
unsigned seq;
int retries = 0;
u64 value;
do {
seq = read_seqbegin(&my_seqlock);
value = shared_data;
if (++retries > MAX_SEQLOCK_RETRIES) {
/* fallback: 쓰기 잠금으로 배타적 읽기 */
write_seqlock(&my_seqlock);
value = shared_data;
write_sequnlock(&my_seqlock);
break;
}
} while (read_seqretry(&my_seqlock, seq));
jiffies_lock은 1ms~10ms 간격으로만 갱신되므로 리더가 starvation될 확률은 사실상 0입니다.
jiffies 보호 패턴: 대표 사용 사례
seqlock의 가장 대표적인 사용 사례는 jiffies 보호입니다. 32비트 시스템에서 64비트 jiffies_64를 원자적(Atomic)으로 읽기 위해 seqlock을 사용합니다.
/* kernel/time/jiffies.c */
static DEFINE_SEQLOCK(jiffies_lock);
/* 라이터: 타이머 인터럽트에서 호출 */
void do_timer(unsigned long ticks)
{
write_seqlock(&jiffies_lock);
jiffies_64 += ticks;
write_sequnlock(&jiffies_lock);
calc_global_load();
}
/* 리더: get_jiffies_64() */
u64 get_jiffies_64(void)
{
unsigned seq;
u64 ret;
do {
seq = read_seqbegin(&jiffies_lock);
ret = jiffies_64;
} while (read_seqretry(&jiffies_lock, seq));
return ret;
}
jiffies_64는 두 번의 32비트 로드로 읽힙니다.
타이머(Timer) 인터럽트가 두 로드 사이에 발생하면 상위/하위 워드가 다른 시점의 값이 섞입니다(torn read).
seqlock은 이런 torn read를 감지하고 재시도하게 합니다.
64비트 시스템에서는 jiffies_64가 원자적으로 읽히므로 seqlock이 필요 없지만, 이식성을 위해 동일 API를 사용합니다.
struct timekeeper: 시간 서브시스템
/* kernel/time/timekeeping.c */
static struct {
seqcount_raw_spinlock_t seq;
struct timekeeper timekeeper;
} tk_core ____cacheline_aligned = {
.seq = SEQCNT_RAW_SPINLOCK_ZERO(tk_core.seq, &timekeeper_lock),
};
/* 리더: ktime_get() — vDSO에서도 호출 */
ktime_t ktime_get(void)
{
struct timekeeper *tk = &tk_core.timekeeper;
unsigned seq;
ktime_t base;
u64 nsecs;
do {
seq = read_seqcount_begin(&tk_core.seq);
base = tk->tkr_mono.base;
nsecs = timekeeping_get_ns(&tk->tkr_mono);
} while (read_seqcount_retry(&tk_core.seq, seq));
return ktime_add_ns(base, nsecs);
}
통계 카운터 패턴
네트워크 서브시스템은 seqlock을 활용하여 64비트 통계 카운터를 비원자적 업데이트하면서도 일관된 스냅샷을 제공합니다.
/* include/linux/u64_stats_sync.h */
struct u64_stats_sync {
#if BITS_PER_LONG == 32
seqcount_t seq;
#endif
};
/* 쓰기 측: 통계 업데이트 (per-CPU이므로 외부 잠금 불필요) */
void update_stats(struct net_device_stats *stats, int bytes)
{
u64_stats_update_begin(&stats->syncp);
stats->rx_bytes += bytes;
stats->rx_packets++;
u64_stats_update_end(&stats->syncp);
}
/* 읽기 측: 통계 스냅샷 */
void read_stats(struct net_device_stats *stats, u64 *bytes, u64 *packets)
{
unsigned start;
do {
start = u64_stats_fetch_begin(&stats->syncp);
*bytes = stats->rx_bytes;
*packets = stats->rx_packets;
} while (u64_stats_fetch_retry(&stats->syncp, start));
}
u64_stats_sync는 64비트 시스템에서 seqcount_t가 no-op이 됩니다.
64비트 아키텍처에서는 u64 읽기/쓰기가 원자적이므로 시퀀스 카운터가 불필요합니다.
이 패턴은 32비트/64비트 이식성을 위한 커널의 표준 접근 방식입니다.
seqcount_latch: 이중 버퍼 패턴
seqcount_latch_t는 일반 seqlock과 달리 쓰기 중에도 항상 일관된 읽기를 보장합니다. 이중 버퍼를 사용하여 라이터가 한 버퍼를 업데이트하는 동안 리더는 다른 버퍼를 읽습니다.
/* 이중 버퍼 패턴의 핵심 구조 */
struct latch_data {
seqcount_latch_t latch;
struct data_entry data[2]; /* 이중 버퍼 */
};
/* 라이터: raw_write_seqcount_latch() 사용 */
void latch_write(struct latch_data *ld, struct data_entry *new)
{
/* 1단계: 시퀀스 증가 (even→odd), 첫 번째 버퍼 업데이트 */
raw_write_seqcount_latch(&ld->latch);
ld->data[0] = *new;
/* 2단계: 시퀀스 증가 (odd→even), 두 번째 버퍼 업데이트 */
raw_write_seqcount_latch(&ld->latch);
ld->data[1] = *new;
}
/* 리더: 시퀀스 기반 버퍼 선택 */
void latch_read(struct latch_data *ld, struct data_entry *dst)
{
unsigned seq;
do {
seq = raw_read_seqcount_latch(&ld->latch);
*dst = ld->data[seq & 1]; /* 시퀀스 LSB로 버퍼 선택 */
} while (raw_read_seqcount_latch_retry(&ld->latch, seq));
}
커널 사용 사례: struct latched_seq
/* include/linux/seqlock.h */
struct latched_seq {
seqcount_latch_t latch;
u64 val[2];
};
/* kernel/time/timekeeping.c — NMI-safe 시간 읽기 */
static struct latched_seq timekeeping_cycles;
/* NMI 컨텍스트에서 안전하게 사이클 카운터 읽기 */
u64 read_latched_cycles(void)
{
return read_seqcount_latch_retval(&timekeeping_cycles.latch,
timekeeping_cycles.val);
}
seqcount_LOCKNAME_t: 연관 잠금
커널은 seqcount_t에 연관 잠금을 명시적으로 바인딩하는 타입 변형을 제공합니다. 이를 통해 lockdep이 라이터가 올바른 잠금을 보유하고 있는지 검증할 수 있습니다.
| 타입 | 연관 잠금 | 용도 |
|---|---|---|
seqcount_spinlock_t | spinlock_t | seqlock_t 내부에서 사용 |
seqcount_raw_spinlock_t | raw_spinlock_t | PREEMPT_RT에서도 진정한 spinlock 필요 시 |
seqcount_rwlock_t | rwlock_t | rwlock 내부에서 시퀀스 보호 필요 시 |
seqcount_mutex_t | struct mutex | 슬립(Sleep) 가능 라이터 경로 |
seqcount_ww_mutex_t | struct ww_mutex | wound-wait 교착 방지 뮤텍스(Mutex) |
/* 선언과 초기화 */
spinlock_t my_lock;
seqcount_spinlock_t my_seqcount = SEQCNT_SPINLOCK_ZERO(my_seqcount, &my_lock);
/* lockdep 검증: write_seqcount_begin()이 my_lock 보유를 확인 */
spin_lock(&my_lock);
write_seqcount_begin(&my_seqcount); /* lockdep: my_lock 보유 검증 */
/* ... 데이터 수정 ... */
write_seqcount_end(&my_seqcount);
spin_unlock(&my_lock);
/* 잘못된 사용: lockdep 경고 발생 */
write_seqcount_begin(&my_seqcount); /* WARNING: my_lock not held! */
seqcount_LOCKNAME_t를 사용하면 write_seqcount_begin() 호출 시 연관 잠금 보유 여부를 lockdep이 자동 검증합니다. 연관 잠금 없이 seqcount_t를 직접 사용하면 이 검증이 빠지므로, 가능하면 연관 잠금 변형을 사용하세요.
메모리 순서와 배리어
seqlock의 정확한 동작은 정밀한 메모리 배리어 배치에 의존합니다. 각 API 함수에 삽입된 배리어의 목적을 분석합니다.
| 배리어 | 위치 | 보장하는 순서 |
|---|---|---|
smp_wmb() | write_seqcount_begin() 내부 | 시퀀스 홀수화 → 데이터 쓰기 (라이터) |
smp_wmb() | write_seqcount_end() 내부 | 데이터 쓰기 → 시퀀스 짝수화 (라이터) |
smp_rmb() | read_seqcount_begin() 내부 | 시퀀스 읽기 → 데이터 읽기 (리더) |
smp_rmb() | read_seqcount_retry() 내부 | 데이터 읽기 → 시퀀스 재읽기 (리더) |
smp_wmb()가 컴파일러 배리어(barrier())로 축소됩니다. ARM/RISC-V의 약한 메모리 모델에서는 실제 하드웨어 명령어(DMB, fence)로 확장됩니다.
PREEMPT_RT 영향
PREEMPT_RT 커널에서 seqlock의 동작이 변경됩니다. 핵심 차이점을 분석합니다.
| 요소 | 일반 커널 | PREEMPT_RT |
|---|---|---|
seqlock_t 내부 lock | spinlock_t (진짜 스핀) | spinlock_t → rt_mutex (슬립) |
| 라이터 선점(Preemption) | 불가 (선점 비활성화) | 가능 (우선순위 상속(Priority Inheritance)) |
| 리더 스핀 | 짧은 스핀 (홀수 대기) | 더 긴 스핀 가능 (라이터 선점 시) |
write_seqlock_irqsave() | IRQ 비활성화 | threaded IRQ → IRQ 비활성화 불필요 (마이그레이션만 비활성화) |
/* PREEMPT_RT에서의 seqlock 변화 */
/* 일반: spinlock_t = 진짜 spinlock */
/* RT: spinlock_t = rt_mutex 기반 → 슬립 가능 */
/* RT에서 진정한 스핀이 필요한 경우 */
static DEFINE_RAW_SPINLOCK(my_raw_lock);
static seqcount_raw_spinlock_t my_seqcount =
SEQCNT_RAW_SPINLOCK_ZERO(my_seqcount, &my_raw_lock);
/* NMI/hardirq에서 안전 */
raw_spin_lock(&my_raw_lock);
write_seqcount_begin(&my_seqcount);
/* ... */
write_seqcount_end(&my_seqcount);
raw_spin_unlock(&my_raw_lock);
seqlock_t의 내장 spinlock이 rt_mutex로 변환되면, 라이터가 쓰기 도중 선점될 수 있습니다.
이 경우 시퀀스가 홀수인 상태로 유지되어 리더가 더 오래 스핀합니다.
지연(Latency) 시간에 민감한 경로에서는 seqcount_raw_spinlock_t를 사용하여 진정한 스핀을 보장하세요.
실전 사용 패턴
다중 필드 일관 읽기
/* 여러 필드를 일관되게 읽어야 하는 경우 */
struct coord {
seqlock_t lock;
s64 x, y, z; /* 세 좌표가 항상 같은 시점의 값이어야 함 */
};
void read_coord(struct coord *c, s64 *x, s64 *y, s64 *z)
{
unsigned seq;
do {
seq = read_seqbegin(&c->lock);
*x = c->x;
*y = c->y;
*z = c->z;
} while (read_seqretry(&c->lock, seq));
}
void update_coord(struct coord *c, s64 x, s64 y, s64 z)
{
write_seqlock(&c->lock);
c->x = x;
c->y = y;
c->z = z;
write_sequnlock(&c->lock);
}
seqlock + RCU 조합
/* seqlock으로 데이터 일관성, RCU로 포인터 보호 */
struct config {
seqcount_t seq;
struct rcu_head rcu;
u64 param_a;
u64 param_b;
};
struct config __rcu *current_config;
/* 리더: RCU + seqcount 이중 보호 */
void read_config(u64 *a, u64 *b)
{
struct config *cfg;
unsigned seq;
rcu_read_lock();
cfg = rcu_dereference(current_config);
do {
seq = read_seqcount_begin(&cfg->seq);
*a = cfg->param_a;
*b = cfg->param_b;
} while (read_seqcount_retry(&cfg->seq, seq));
rcu_read_unlock();
}
vDSO 데이터 패턴
/* arch/x86/include/asm/vdso/gettimeofday.h */
/* vDSO: 시스템 콜 없이 사용자 공간에서 시간 읽기 */
static __always_inline int do_hres(const struct vdso_data *vd,
clockid_t clk, struct __kernel_timespec *ts)
{
const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
u64 cycles, ns;
u32 seq;
do {
seq = vdso_read_begin(vd);
/* 시퀀스 기반 일관 읽기 — 사용자 공간에서! */
cycles = __arch_get_hw_counter(vd->clock_mode, vd);
ns = vdso_calc_ns(vd, cycles, vdso_ts->nsec);
ts->tv_sec = vdso_ts->sec;
} while (unlikely(vdso_read_retry(vd, seq)));
ts->tv_nsec = ns;
return 0;
}
안티패턴과 제약 조건
seqlock 사용 시 반드시 피해야 할 패턴과 제약 조건을 정리합니다.
| # | 안티패턴 | 문제 | 해결 |
|---|---|---|---|
| 1 | 읽기 루프에서 포인터 역참조 | 불일관 포인터 → use-after-free/OOPS | RCU로 포인터 보호, seqlock은 데이터만 |
| 2 | 읽기 루프에서 메모리 할당 | 재시도 시 메모리 누수 | 루프 밖에서 할당 |
| 3 | 읽기 루프에서 I/O 수행 | 중복 I/O, 부수 효과 | 루프 밖에서 I/O |
| 4 | seqcount_t에 외부 잠금 없이 쓰기 | 다중 라이터 경합(Contention) → 데이터 손상 | seqlock_t 사용 또는 외부 잠금 추가 |
| 5 | 읽기 경로에서 슬립 | 라이터 starvation, 시퀀스 오버플로우 | 슬립이 필요하면 rwsem 사용 |
| 6 | 쓰기 경로가 매우 긴 경우 | 리더 재시도 과다, CPU 낭비 | mutex + condvar 패턴으로 전환 |
| 7 | 동적 크기 데이터 보호 | 포인터 기반 → 불일관 가능 | 고정 크기 인라인 데이터만 보호 |
/* 안티패턴 1: 포인터 역참조 — 절대 하면 안 됨 */
do {
seq = read_seqbegin(&sl);
ptr = shared_ptr; /* 불일관 포인터! */
val = ptr->field; /* OOPS: ptr이 이미 해제되었을 수 있음 */
} while (read_seqretry(&sl, seq));
/* 올바른 방법: RCU + seqcount */
rcu_read_lock();
ptr = rcu_dereference(shared_ptr); /* RCU로 포인터 수명 보장 */
do {
seq = read_seqcount_begin(&ptr->seq);
val = ptr->field; /* 데이터만 seqcount로 보호 */
} while (read_seqcount_retry(&ptr->seq, seq));
rcu_read_unlock();
seqlock vs rwlock vs RCU 비교
읽기 비율이 높은 데이터에 대한 세 가지 동기화 메커니즘을 비교합니다.
선택 가이드
| 조건 | 추천 | 이유 |
|---|---|---|
| 작은 값 타입 + 쓰기 드묾 | seqlock | 리더 비용 최소, 포인터 불필요 |
| 포인터 기반 + 쓰기 드묾 | RCU | 포인터 수명 관리 내장 |
| 리더 진행 보장 필요 | rwlock | seqlock은 리더 starvation 가능 |
| NMI-safe 필요 + 값 타입 | seqcount_latch | 이중 버퍼로 항상 일관된 읽기 |
| 읽기 중 슬립 필요 | rwsem | seqlock 리더는 슬립 불가 |
| 쓰기 빈도 높음 | per-CPU + seqlock | 쓰기 경합 분산 |
디버깅(Debugging)과 lockdep
seqlock 관련 버그 디버깅 방법과 lockdep 지원을 정리합니다.
lockdep과 seqcount
/* CONFIG_DEBUG_LOCK_ALLOC 활성 시 lockdep 검증 */
/* 1. 연관 잠금 미보유 경고 */
seqcount_spinlock_t sc = SEQCNT_SPINLOCK_ZERO(sc, &my_lock);
write_seqcount_begin(&sc); /* lockdep: my_lock not held! */
/* → "WARNING: lock held when calling write_seqcount_begin()" */
/* 2. lockdep_assert 매크로 */
void update_protected_data(void)
{
lockdep_assert_held(&my_lock); /* 외부 잠금 보유 확인 */
write_seqcount_begin(&sc);
/* ... */
write_seqcount_end(&sc);
}
흔한 버그 패턴
| 증상 | 원인 | 진단 방법 |
|---|---|---|
| 리더 무한 루프 | 라이터가 write_seqcount_end() 누락 | sysrq-l로 스택 확인, 시퀀스 홀수 확인 |
| 불일관 데이터 사용 | 읽기 루프에서 포인터 역참조 | KASAN 리포트, 코드 리뷰 |
| lockdep 경고 | 연관 잠금 없이 write_seqcount_begin() | lockdep 메시지 확인 |
| 성능 저하 | 쓰기 빈도 과다 → 리더 재시도 폭증 | perf stat으로 재시도 횟수 추정 |
| 32비트에서 torn read | u64_stats_sync 미사용 | 32비트 빌드 테스트 |
ftrace로 seqlock 추적
# 시퀀스 카운터 변화 추적 (동적 probe)
$ echo 'p:seqw write_seqcount_begin s=%di:u32' > /sys/kernel/debug/tracing/kprobe_events
$ echo 1 > /sys/kernel/debug/tracing/events/kprobes/seqw/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# jiffies_lock 쓰기 추적
$ echo 'p:jiffies_write do_timer' > /sys/kernel/debug/tracing/kprobe_events
$ echo 1 > /sys/kernel/debug/tracing/events/kprobes/jiffies_write/enable
성능 특성
seqlock의 성능을 정량적으로 분석합니다.
| 메트릭 | 값 | 조건 |
|---|---|---|
| 리더 비용 (무경합) | ~8-12 cycles | smp_rmb 2회 + READ_ONCE 2회 |
| 리더 비용 (1회 재시도) | ~20-25 cycles | 루프 1회 추가 |
| 라이터 비용 | ~15-20 cycles | spinlock + smp_wmb 2회 + sequence++ |
| 캐시 라인(Cache Line) bounce | 1회/쓰기 | sequence 필드 1개만 공유 |
| 리더 확장성 | 완벽 (O(1)) | 리더끼리 상호 간섭 없음 |
| 라이터 확장성 | 제한 (spinlock) | 라이터는 직렬화됨 |
sequence 필드를 읽기만 하므로, 여러 리더가 같은 캐시 라인을 Shared 상태로 공유합니다.
라이터가 sequence를 수정할 때만 캐시 라인이 Invalid로 전환됩니다.
이는 rwlock의 cnts 원자적 증감이 매 읽기마다 캐시 라인 bounce를 유발하는 것과 대조적입니다.
관련 커널 설정
| 설정 | 기본값 | 효과 |
|---|---|---|
CONFIG_DEBUG_LOCK_ALLOC | N | seqcount lockdep 검증 활성화 |
CONFIG_PROVE_LOCKING | N | 잠금 의존성 그래프 검증 (seqcount 연관 잠금 포함) |
CONFIG_LOCK_STAT | N | /proc/lock_stat에 잠금 통계 (seqlock 포함) |
CONFIG_DEBUG_ATOMIC_SLEEP | N | 원자 컨텍스트에서 슬립 감지 (seqlock 리더 내 실수) |
CONFIG_PREEMPT_RT | N | spinlock_t → rt_mutex 변환 (seqlock_t 영향) |
CONFIG_KCSAN | N | seqlock 리더 내 데이터 레이스 감지 |
# 개발/디버깅 시 권장 설정
CONFIG_DEBUG_LOCK_ALLOC=y
CONFIG_PROVE_LOCKING=y
CONFIG_LOCK_STAT=y
CONFIG_DEBUG_ATOMIC_SLEEP=y
# KCSAN으로 seqlock 데이터 레이스 검증
CONFIG_KCSAN=y
CONFIG_KCSAN_STRICT=y
read_seqbegin/read_seqretry 소스 분석
seqlock 리더 경로의 핵심인 read_seqbegin()과 read_seqretry()의 내부 구현을 커널 소스(include/linux/seqlock.h) 수준에서 단계별로 분석합니다.
raw_read_seqcount_begin() 상세
raw_read_seqcount_begin()은 lockdep 검증 없이 시퀀스를 읽는 최하위 함수입니다. NMI나 하드IRQ 핸들러(Handler)에서도 안전합니다.
/* include/linux/seqlock.h — 호출 체인 분석 */
/* 최상위: read_seqbegin() */
static inline unsigned read_seqbegin(const seqlock_t *sl)
{
unsigned ret = read_seqcount_begin(&sl->seqcount);
kcsan_atomic_next(KCSAN_SEQLOCK_REGION_MAX);
return ret;
}
/* 중간: read_seqcount_begin() — lockdep 검증 포함 */
static inline unsigned read_seqcount_begin(const seqcount_t *s)
{
seqcount_lockdep_reader_access(s); /* CONFIG_DEBUG_LOCK_ALLOC 시 검증 */
return raw_read_seqcount_begin(s);
}
/* 최하위: raw_read_seqcount_begin() — 핵심 로직 */
static inline unsigned raw_read_seqcount_begin(const seqcount_t *s)
{
unsigned ret = raw_read_seqcount(s);
smp_rmb(); /* ① 시퀀스 읽기 → 데이터 읽기 순서 보장 */
return ret & ~1; /* ② 홀수를 짝수로 마스크 */
}
/* raw_read_seqcount() — 홀수(쓰기 중) 대기 루프 */
static inline unsigned raw_read_seqcount(const seqcount_t *s)
{
unsigned ret;
repeat:
ret = READ_ONCE(s->sequence);
if (unlikely(ret & 1)) {
cpu_relax(); /* 홀수 = 쓰기 진행 중, 양보 후 재시도 */
goto repeat;
}
return ret;
}
raw_read_seqcount_begin()은 raw_read_seqcount()가 반환한 값에 & ~1을 적용합니다.
raw_read_seqcount()는 이미 짝수만 반환하므로 실질적으로 no-op이지만, raw_seqcount_begin()과 같은 변형에서는 홀수 대기 없이 즉시 반환하면서 짝수로 마스크합니다.
이는 read_seqcount_retry()가 시퀀스 불일치를 확실히 감지하도록 보장합니다.
read_seqcount_retry() 상세
/* read_seqcount_retry() — 데이터 일관성 검증 */
static inline int read_seqcount_retry(const seqcount_t *s, unsigned start)
{
smp_rmb(); /* ① 데이터 읽기가 시퀀스 재읽기보다 먼저 완료 */
return unlikely(READ_ONCE(s->sequence) != start);
/*
* start는 read_seqcount_begin()에서 짝수로 마스크된 값
* 현재 sequence가 start와 다르면:
* - 홀수: 라이터가 쓰기 진행 중
* - start+2 이상: 쓰기 완료 후 시퀀스 변경
* 둘 다 재시도 필요 → true(1) 반환
*/
}
KCSAN seqlock 영역 처리
/* read_seqbegin()이 호출하는 KCSAN 힌트 */
kcsan_atomic_next(KCSAN_SEQLOCK_REGION_MAX);
/*
* KCSAN(Kernel Concurrency Sanitizer)에게
* "다음 KCSAN_SEQLOCK_REGION_MAX번의 메모리 접근은
* seqlock으로 보호되는 원자적 영역이므로
* 데이터 레이스로 보고하지 말라"고 알립니다.
*
* seqlock 리더는 의도적으로 라이터와 동시에
* 데이터를 읽을 수 있으므로, KCSAN의 기본
* 레이스 감지를 억제해야 합니다.
*/
raw_read_seqcount()에서 READ_ONCE(s->sequence)를 사용하는 이유는 컴파일러가 시퀀스 읽기를 최적화(캐싱, 재정렬)하지 못하게 방지하기 위함입니다. volatile 읽기 없이 일반 로드를 사용하면, 컴파일러가 루프 불변 코드 이동(LICM)으로 시퀀스 값을 루프 밖으로 끌어올려 무한 루프에 빠질 수 있습니다.
write_seqlock/write_sequnlock 소스 분석
seqlock 라이터 경로는 spinlock 획득, 시퀀스 증가, 메모리 배리어 삽입이라는 세 단계로 구성됩니다. 각 단계의 구현을 분석합니다.
write_seqlock() 구현
/* include/linux/seqlock.h — write_seqlock() */
static inline void write_seqlock(seqlock_t *sl)
{
spin_lock(&sl->lock); /* ① spinlock 획득 (라이터 직렬화) */
write_seqcount_begin(&sl->seqcount);
/* 이후 시퀀스는 홀수 — 리더에게 "쓰기 중" 신호 */
}
/* write_seqcount_begin() 상세 */
static inline void write_seqcount_begin(seqcount_t *s)
{
write_seqcount_begin_nested(s, 0);
}
static inline void write_seqcount_begin_nested(seqcount_t *s, int subclass)
{
raw_write_seqcount_begin(s);
seqcount_acquire(&s->dep_map, subclass, 0, _RET_IP_);
/* lockdep: 잠금 의존성 추적 */
}
/* raw_write_seqcount_begin() — 핵심 */
static inline void raw_write_seqcount_begin(seqcount_t *s)
{
kcsan_nestable_atomic_begin();
s->sequence++; /* even→odd: 쓰기 시작 */
smp_wmb(); /* ② 시퀀스 증가가 데이터 쓰기 전에 관측됨 보장 */
}
write_sequnlock() 구현
/* write_sequnlock() */
static inline void write_sequnlock(seqlock_t *sl)
{
write_seqcount_end(&sl->seqcount);
spin_unlock(&sl->lock); /* ④ spinlock 해제 */
}
/* write_seqcount_end() 상세 */
static inline void write_seqcount_end(seqcount_t *s)
{
seqcount_release(&s->dep_map, _RET_IP_);
raw_write_seqcount_end(s);
}
/* raw_write_seqcount_end() — 핵심 */
static inline void raw_write_seqcount_end(seqcount_t *s)
{
smp_wmb(); /* ③ 데이터 쓰기가 시퀀스 증가 전에 완료됨 보장 */
s->sequence++; /* odd→even: 쓰기 완료 */
kcsan_nestable_atomic_end();
}
write_seqcount_invalidate()
/* 시퀀스를 홀수로 만들어 모든 리더 무효화 */
static inline void write_seqcount_invalidate(seqcount_t *s)
{
smp_wmb();
kcsan_nestable_atomic_begin();
s->sequence += 2; /* 짝수→짝수: begin+end 를 한 번에 */
kcsan_nestable_atomic_end();
}
/*
* 주의: 이 함수는 sequence를 2 증가시켜 짝수를 유지하지만,
* begin/end 쌍 없이 시퀀스를 변경합니다.
* 현재 리더의 start 값과 불일치를 유발하여
* 모든 진행 중인 읽기를 재시도시킵니다.
*/
s->sequence++는 원자적 연산이 아닙니다. 라이터 직렬화(spinlock)가 보장되므로 동시에 두 라이터가 시퀀스를 수정하지 않습니다. 리더는 READ_ONCE()로 시퀀스를 읽으므로 torn read 위험은 없습니다. 이 비원자적 증가가 seqlock 라이터 비용을 atomic_inc() 대비 절약합니다.
| 함수 | 시퀀스 변화 | 배리어 | 추가 동작 |
|---|---|---|---|
raw_write_seqcount_begin() | even→odd (+1) | 시퀀스++ 후 smp_wmb() | KCSAN atomic begin |
raw_write_seqcount_end() | odd→even (+1) | smp_wmb() 후 시퀀스++ | KCSAN atomic end |
write_seqcount_invalidate() | even→even (+2) | smp_wmb() 후 시퀀스+=2 | 진행 중 리더 무효화 |
raw_write_seqcount_barrier() | even→even (+2) | 2개 smp_wmb() | latch 패턴용 |
x86: 배리어와 torn read
x86 아키텍처의 TSO(Total Store Ordering) 메모리 모델은 seqlock 구현에 상당한 최적화 기회를 제공합니다. 그러나 32비트 x86에서의 torn read 문제는 seqlock이 존재하는 핵심 이유 중 하나입니다.
TSO 모델과 seqlock 배리어
/* arch/x86/include/asm/barrier.h */
/* x86 TSO 모델에서 smp_wmb()의 실제 구현 */
#define smp_wmb() barrier()
/*
* TSO(Total Store Ordering) 보장:
* - 모든 스토어는 프로그램 순서대로 다른 CPU에 관측됨
* - 따라서 smp_wmb()는 컴파일러 배리어만으로 충분
* - 하드웨어 MFENCE/SFENCE 불필요
*/
/* smp_rmb()의 x86 구현 */
#define smp_rmb() barrier()
/*
* x86 TSO: 로드-로드 순서도 하드웨어가 보장
* 컴파일러가 재정렬하지 않으면 충분
* → barrier() = asm volatile("" ::: "memory")
*/
/* 결과: x86에서 seqlock 리더의 실제 비용 */
/*
* read_seqcount_begin():
* READ_ONCE(s->sequence) → MOV 명령어 1개
* smp_rmb() → 컴파일러 배리어만 (명령어 0개)
*
* read_seqcount_retry():
* smp_rmb() → 컴파일러 배리어만 (명령어 0개)
* READ_ONCE(s->sequence) → MOV 명령어 1개
* 비교 + 분기 → CMP + JNE
*
* 총 비용: MOV 2개 + CMP + JNE ≈ 8-12 cycles
*/
LFENCE의 역할
/* x86에서 LFENCE는 seqlock에 사용되지 않음 */
/*
* LFENCE는 로드-직렬화 배리어이지만, TSO에서는
* 로드-로드 순서가 이미 보장되므로 smp_rmb()에 불필요.
*
* LFENCE가 필요한 경우:
* - RDTSC 직렬화 (lfence; rdtsc)
* - Spectre v1 완화 (투기적 실행 차단)
* - MMIO 로드 순서 (비캐시 영역)
*
* seqlock 리더에서 LFENCE를 쓰면 불필요한 성능 손실.
* barrier()로 충분한 x86 TSO에서 LFENCE는 과잉.
*/
/* 비교: rmb() vs smp_rmb() */
#define rmb() asm volatile("lfence" ::: "memory")
#define smp_rmb() barrier()
/*
* rmb(): MMIO/디바이스 메모리용 (LFENCE 하드웨어 배리어)
* smp_rmb(): SMP 간 일반 메모리용 (컴파일러 배리어 충분)
* seqlock은 일반 메모리 → smp_rmb() 사용
*/
u64 로드가 원자적이므로 torn read 문제가 없습니다. 그러나 seqlock은 다중 필드 일관성에도 사용됩니다. 예를 들어 struct timekeeper의 base와 nsec를 일관되게 읽어야 할 때, 두 필드 사이에 업데이트가 끼어들면 불일관 상태가 됩니다. 이것은 64비트 원자성과 무관한 문제입니다.
ARM64: DMB와 약한 순서
ARM64의 약한 메모리 모델(weakly ordered)에서는 seqlock 배리어가 실제 하드웨어 명령어로 확장되어 x86 대비 상당한 비용이 추가됩니다. 그러나 ARM64의 load-acquire/store-release 명령어를 활용한 최적화도 가능합니다.
DMB 배리어 변형
/* arch/arm64/include/asm/barrier.h */
/* ARM64에서 smp_rmb() 구현 */
#define smp_rmb() dmb(ishld)
/*
* DMB ISHLD (Data Memory Barrier, Inner Shareable, Load)
* - 이 명령어 이전의 모든 로드가
* 이후의 로드/스토어 전에 완료됨을 보장
* - Inner Shareable: 같은 공유 도메인 내 CPU 간 적용
* - Load only: 로드-로드, 로드-스토어 순서만 강제
*/
/* ARM64에서 smp_wmb() 구현 */
#define smp_wmb() dmb(ishst)
/*
* DMB ISHST (Data Memory Barrier, Inner Shareable, Store)
* - 이 명령어 이전의 모든 스토어가
* 이후의 스토어 전에 완료됨을 보장
* - 스토어-스토어 순서만 강제 (가장 가벼운 DMB)
*/
/* seqlock 리더의 ARM64 어셈블리 (개념적) */
/*
* read_seqcount_begin:
* LDAR w0, [x1] ; load-acquire sequence (또는 LDR + DMB)
* DMB ISHLD ; smp_rmb() — 시퀀스→데이터 순서
*
* 데이터 읽기...
*
* read_seqcount_retry:
* DMB ISHLD ; smp_rmb() — 데이터→시퀀스 순서
* LDR w0, [x1] ; READ_ONCE(s->sequence)
* CMP w0, w2 ; 비교
* B.NE retry ; 불일치 시 재시도
*/
load-acquire 최적화
/* ARM64의 load-acquire를 활용한 최적화 */
/*
* smp_load_acquire()는 ARM64에서 LDAR 명령어 사용:
* LDAR w0, [x1]
* → load + acquire 시맨틱 = LDR + DMB ISHLD 와 동일 효과
* → 하지만 단일 명령어로 더 효율적
*
* 현재 커널의 read_seqcount_begin()은
* READ_ONCE() + smp_rmb() 패턴을 사용하지만,
* 일부 아키텍처 최적화에서는 이를
* smp_load_acquire()로 대체 가능합니다.
*/
/* DMB 비용 비교 */
/*
* DMB ISH (full): ~40-60 cycles (Cortex-A7x 기준)
* DMB ISHLD (load): ~20-40 cycles
* DMB ISHST (store): ~15-30 cycles
* LDAR (acquire): ~5-10 cycles (추가 비용)
*
* seqlock 리더는 smp_rmb() 2회 = DMB ISHLD 2회
* → ARM64에서 ~40-80 cycles 추가 비용
* → x86의 ~0 cycles 와 대조적
*/
약한 순서에서의 함정
/* ARM64에서 배리어 누락 시 발생할 수 있는 문제 */
/* 위험한 코드: smp_rmb() 없이 직접 구현 시도 */
unsigned seq = s->sequence; /* ❌ READ_ONCE 없음 */
val = shared_data; /* ❌ 배리어 없음 */
if (s->sequence != seq) ... /* ❌ 재정렬 가능! */
/*
* ARM64에서 발생 가능한 시나리오:
* 1. CPU가 shared_data를 sequence 읽기 전에 투기적 로드
* 2. sequence가 짝수(일관)임을 확인하지만,
* shared_data는 이전 불일관 값
* 3. 결과: 불일관 데이터를 일관된 것으로 오판
*
* → smp_rmb()는 이 투기적 로드를 차단합니다
*/
smp_rmb()는 fence r,r, smp_wmb()는 fence w,w로 확장됩니다. ARM64의 DMB와 유사하게 실제 하드웨어 비용이 발생합니다.
vDSO와 seqlock: clock_gettime 심층
vDSO(virtual Dynamic Shared Object)는 커널 데이터를 사용자 공간(User Space)에 매핑(Mapping)하여 시스템 콜(System Call) 없이 시간을 읽게 합니다. 이 매핑된 데이터의 일관성은 seqcount로 보호됩니다. 커널 ↔ 사용자 공간 경계를 넘는 seqlock 패턴을 심층 분석합니다.
vDSO 데이터 페이지(Page) 구조
/* include/vdso/datapage.h */
struct vdso_data {
u32 seq; /* seqcount — 커널이 쓰고 유저가 읽음 */
s32 clock_mode; /* vDSO 사용 가능 여부 */
u64 cycle_last;
u64 mask;
u32 mult;
u32 shift;
union {
struct vdso_timestamp basetime[VDSO_BASES];
struct timens_offset timens_offset[VDSO_BASES];
};
s32 tz_minuteswest;
s32 tz_dsttime;
u32 hrtimer_res;
};
struct vdso_timestamp {
u64 sec;
u64 nsec;
};
사용자 공간 읽기 루프
/* lib/vdso/gettimeofday.c — 사용자 공간에서 실행 */
static __always_inline int do_hres(
const struct vdso_data *vd,
clockid_t clk,
struct __kernel_timespec *ts)
{
const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
u64 cycles, sec, ns;
u32 seq;
do {
/* ① 시퀀스 읽기 — 커널과 같은 seqcount 프로토콜 */
seq = vdso_read_begin(vd);
/* ② 하드웨어 카운터 읽기 (RDTSC, CNTVCT_EL0 등) */
if (unlikely(vd->clock_mode == VDSO_CLOCKMODE_NONE))
return -1; /* fallback to syscall */
cycles = __arch_get_hw_counter(vd->clock_mode, vd);
/* ③ 나노초 계산: (cycles - cycle_last) * mult >> shift */
ns = vdso_calc_ns(vd, cycles, vdso_ts->nsec);
sec = vdso_ts->sec;
} while (unlikely(vdso_read_retry(vd, seq)));
/* ④ 시퀀스 불일치 → 커널이 vdso_data를 업데이트 중 → 재시도 */
ts->tv_sec = sec;
ts->tv_nsec = ns;
return 0;
}
/* vdso_read_begin/retry — seqcount 프로토콜 동일 */
static __always_inline u32 vdso_read_begin(const struct vdso_data *vd)
{
u32 seq;
repeat:
seq = READ_ONCE(vd->seq);
if (unlikely(seq & 1)) {
cpu_relax();
goto repeat;
}
smp_rmb();
return seq;
}
커널 업데이트 경로
/* arch/x86/kernel/vsyscall_gtod.c */
void update_vsyscall(struct timekeeper *tk)
{
struct vdso_data *vdata = __arch_get_k_vdso_data();
/* vdso_data의 시퀀스도 같이 증가 */
vdso_write_begin(vdata);
/* timekeeper → vdso_data 복사 */
vdata[CS_HRES_COARSE]->basetime[CLOCK_REALTIME].sec = tk->xtime_sec;
vdata[CS_HRES_COARSE]->basetime[CLOCK_REALTIME].nsec = tk->tkr_mono.xtime_nsec;
vdata->mult = tk->tkr_mono.mult;
vdata->shift = tk->tkr_mono.shift;
vdata->cycle_last = tk->tkr_mono.cycle_last;
vdso_write_end(vdata);
}
clock_mode == VDSO_CLOCKMODE_NONE이면 vDSO가 비활성화되어 일반 시스템 콜로 폴백합니다. 이는 TSC가 불안정하거나(nonstop_tsc 미지원), 가상화(Virtualization) 환경에서 TSC가 신뢰할 수 없을 때 발생합니다. 폴백 시 성능이 ~10배 저하됩니다.
seqcount_latch 소스 분석
seqcount_latch_t는 이중 버퍼(double buffering)를 활용하여 쓰기 중에도 항상 일관된 읽기를 보장하는 특수 패턴입니다. NMI처럼 재시도 루프가 위험한 컨텍스트에서 안전합니다. 내부 구현을 소스 수준으로 분석합니다.
raw_write_seqcount_latch() 상세
/* include/linux/seqlock.h */
/* latch 쓰기: 시퀀스 증가 + 배리어 (한 번 호출당 1 증가) */
static inline void raw_write_seqcount_latch(seqcount_latch_t *s)
{
smp_wmb(); /* 이전 버퍼 쓰기가 seq 증가 전에 완료 */
s->seqcount.sequence++;
smp_wmb(); /* seq 증가가 다음 버퍼 쓰기 전에 관측 */
}
/*
* latch 쓰기 프로토콜 (라이터가 2번 호출):
*
* raw_write_seqcount_latch(&latch); // seq: 0→1
* data[0] = new_value; // 첫 번째 버퍼 업데이트
*
* raw_write_seqcount_latch(&latch); // seq: 1→2
* data[1] = new_value; // 두 번째 버퍼 업데이트
*
* 핵심: seq이 홀수일 때 → 리더는 data[1] 사용 (아직 미변경)
* seq이 짝수일 때 → 리더는 data[0] 사용 (이미 변경 완료)
* → 어느 시점에든 하나의 버퍼는 항상 일관된 상태
*/
raw_read_seqcount_latch() 상세
/* latch 읽기 — NMI-safe, 항상 일관된 데이터 보장 */
static inline unsigned raw_read_seqcount_latch(seqcount_latch_t *s)
{
/*
* 시퀀스를 읽고, LSB로 어떤 버퍼를 읽을지 결정:
* seq & 1 == 0 → data[0] 읽기 (라이터가 data[1] 수정 중이거나 유휴)
* seq & 1 == 1 → data[1] 읽기 (라이터가 data[0] 수정 중)
*
* 주의: 일반 seqlock과 달리 홀수 시 스핀하지 않음!
* 홀수여도 안전한 버퍼가 존재하기 때문
*/
return READ_ONCE(s->seqcount.sequence);
}
/* latch 읽기 재시도 판정 */
static inline int raw_read_seqcount_latch_retry(seqcount_latch_t *s, unsigned start)
{
smp_rmb();
return unlikely(READ_ONCE(s->seqcount.sequence) != start);
/*
* 재시도가 필요한 경우:
* 읽기 중 라이터가 두 번째 latch(같은 버퍼 업데이트)를 실행
* → 리더가 읽던 버퍼가 수정됨
*
* 재시도해도 항상 성공: 재시도 시 seq이 안정되어
* 안전한 버퍼를 다시 선택
*/
}
NMI-safe 사용 사례: perf 이벤트
/* kernel/events/core.c — perf 이벤트 시간 변환 */
struct perf_event_mmap_page {
/* latch로 보호되는 시간 변환 파라미터 */
u32 time_mult;
u32 time_shift;
u64 time_offset;
};
/*
* perf의 NMI 핸들러에서 시간 변환 파라미터를 읽어야 함
* → 일반 seqlock 불가 (NMI에서 스핀하면 교착)
* → seqcount_latch 사용으로 항상 일관된 읽기 보장
*/
seqcount_latch_t를 사용하세요. 일반 컨텍스트에서만 읽고 라이터 간섭이 짧다면 seqcount_t/seqlock_t가 메모리를 절약합니다 (이중 버퍼 불필요).
벤치마크: 읽기 재시도율과 처리량(Throughput)
seqlock의 실제 성능을 정량적으로 분석합니다. 리더 재시도율은 라이터 빈도와 임계 영역(Critical Section) 길이에 직접 의존하며, 이를 수학적으로 모델링할 수 있습니다.
재시도 확률 모델
/* 재시도 확률 수학 모델 */
P(retry) = P(라이터가 리더의 임계 영역과 겹침)
가정:
T_r = 리더 임계 영역 시간 (시퀀스 읽기 ~ 재검증)
T_w = 라이터 임계 영역 시간 (seqlock 보유 시간)
f_w = 라이터 빈도 (writes/second)
단일 라이터, 포아송 도착 근사:
P(retry) ≈ 1 - e^(-f_w × (T_r + T_w))
f_w가 작을 때 근사: P(retry) ≈ f_w × (T_r + T_w)
예시 (jiffies):
T_r ≈ 10ns (시퀀스 읽기 2회 + 데이터 읽기)
T_w ≈ 5ns (jiffies_64 증가)
f_w = 1000 Hz (CONFIG_HZ=1000)
P(retry) ≈ 1000 × (10ns + 5ns) = 0.000015 = 0.0015%
→ 65,000번 읽기당 1회 재시도
seqlock vs rwlock vs RCU 처리량 비교
| 시나리오 | seqlock (Mops/s) | rwlock (Mops/s) | RCU (Mops/s) | 비고 |
|---|---|---|---|---|
| 리더 4코어, 라이터 0 | 850 | 120 | 950 | seqlock ≈ RCU, rwlock 원자적 비용 |
| 리더 4코어, 라이터 1Hz | 850 | 120 | 950 | 차이 미미 |
| 리더 4코어, 라이터 1KHz | 848 | 118 | 950 | seqlock 재시도 0.001% |
| 리더 4코어, 라이터 1MHz | 680 | 95 | 940 | seqlock 재시도 ~1.5% |
| 리더 4코어, 라이터 10MHz | 220 | 40 | 920 | seqlock 재시도 ~15% |
| 리더 4코어, 라이터 100MHz | 30 | 8 | 890 | seqlock 사실상 사용 불가 |
캐시 라인 분석
| 동기화 | 읽기 시 캐시(Cache) 상태 | 쓰기 시 캐시 영향 | 리더 간 간섭 |
|---|---|---|---|
| seqlock | Shared (sequence 필드) | 1회 Invalidate (sequence 수정) | 없음 (읽기만) |
| rwlock | Exclusive (cnts 원자적 증감) | 리더 수만큼 bounce | 있음 (원자적 RMW) |
| RCU | 없음 (선점 비활성화만) | grace period 비용 | 없음 |
cnts 증감은 원격 노드 캐시 라인 전송을 유발하여 리더 비용이 100+ cycles로 증가합니다. seqlock 리더는 sequence를 읽기만 하므로 Shared 상태를 유지하여 NUMA 환경에서 특히 유리합니다.
서브시스템: 타임키핑 심층
Linux 커널 타임키핑은 seqlock/seqcount의 가장 정교한 사용 사례입니다. tk_core 구조체를 중심으로 타이머 인터럽트부터 ktime_get_real()까지의 전체 경로를 분석합니다.
tk_core 구조체
/* kernel/time/timekeeping.c */
static struct {
seqcount_raw_spinlock_t seq;
struct timekeeper timekeeper;
} tk_core ____cacheline_aligned = {
.seq = SEQCNT_RAW_SPINLOCK_ZERO(tk_core.seq, &timekeeper_lock),
};
/* 왜 seqcount_raw_spinlock_t 인가?
* - raw_spinlock_t는 PREEMPT_RT에서도 진정한 스핀
* - 타이머 인터럽트 컨텍스트에서 호출 → rt_mutex 사용 불가
* - seqcount_spinlock_t를 사용하면 RT에서 문제 발생
*/
/* struct timekeeper 핵심 필드 */
struct timekeeper {
struct tk_read_base tkr_mono; /* CLOCK_MONOTONIC */
struct tk_read_base tkr_raw; /* CLOCK_MONOTONIC_RAW */
u64 xtime_sec; /* CLOCK_REALTIME 초 */
unsigned long ktime_sec; /* CLOCK_MONOTONIC 초 */
struct timespec64 wall_to_monotonic;
ktime_t offs_real; /* mono → real 오프셋 */
ktime_t offs_boot; /* mono → boot 오프셋 */
ktime_t offs_tai; /* mono → TAI 오프셋 */
/* ... NTP 보정 관련 필드들 ... */
};
struct tk_read_base {
struct clocksource *clock; /* 하드웨어 클록 소스 */
u64 mask;
u64 cycle_last; /* 마지막 업데이트 시 사이클 */
u32 mult; /* 사이클→나노초 변환 승수 */
u32 shift; /* 사이클→나노초 변환 시프트 */
u64 xtime_nsec; /* 기준 나노초 */
ktime_t base; /* 기준 ktime */
};
update_wall_time() 경로
/* kernel/time/timekeeping.c — 타이머 인터럽트에서 호출 */
void update_wall_time(void)
{
struct timekeeper *real_tk = &tk_core.timekeeper;
raw_spin_lock_irqsave(&timekeeper_lock, flags);
/* 1. 클록 소스에서 경과 사이클 계산 */
clock = real_tk->tkr_mono.clock;
cycle_now = clock->read(clock);
delta = clocksource_delta(cycle_now, real_tk->tkr_mono.cycle_last, clock->mask);
/* 2. seqcount 보호 시작 */
write_seqcount_begin(&tk_core.seq);
/* 3. timekeeper 업데이트 */
timekeeping_advance(real_tk, delta);
/*
* cycle_last 갱신
* xtime_nsec 누적
* NTP freq/offset 보정 적용
* 초 단위 넘어가면 xtime_sec++
* wall_to_monotonic 갱신
*/
/* 4. seqcount 보호 종료 */
write_seqcount_end(&tk_core.seq);
/* 5. vDSO 데이터 페이지 업데이트 */
update_vsyscall(real_tk);
raw_spin_unlock_irqrestore(&timekeeper_lock, flags);
}
ktime_get_real() 경로
/* kernel/time/timekeeping.c */
ktime_t ktime_get_real(void)
{
struct timekeeper *tk = &tk_core.timekeeper;
unsigned seq;
ktime_t base;
u64 nsecs;
do {
seq = read_seqcount_begin(&tk_core.seq);
base = tk->tkr_mono.base;
nsecs = timekeeping_get_ns(&tk->tkr_mono);
base = ktime_add(base, tk->offs_real);
} while (read_seqcount_retry(&tk_core.seq, seq));
return ktime_add_ns(base, nsecs);
}
/* timekeeping_get_ns() — 마지막 업데이트 이후 경과 나노초 */
static inline u64 timekeeping_get_ns(struct tk_read_base *tkr)
{
u64 delta, nsec;
/* 현재 하드웨어 사이클 - 마지막 업데이트 사이클 */
delta = timekeeping_get_delta(tkr);
/* 사이클 → 나노초 변환: delta * mult >> shift */
nsec = (delta * tkr->mult + tkr->xtime_nsec) >> tkr->shift;
return nsec;
}
update_wall_time()은 NTP 데몬에서 받은 주파수/오프셋(Offset) 보정을 mult 파라미터에 반영합니다. 이 보정은 seqcount 보호 영역 내에서 원자적으로 적용되므로, 리더가 보정 전후의 혼합된 파라미터로 시간을 계산하는 것을 방지합니다.
커널 버전별 진화
seqlock은 v2.5에서 최초 도입된 이후 지속적으로 진화했습니다. 각 버전에서의 핵심 변화와 그 배경을 분석합니다.
v5.8 리팩터링 상세
/* v5.8 이전: 단일 seqcount_t, 연관 잠금은 "약속" */
seqcount_t my_seq;
spinlock_t my_lock; /* 문서에만 연관 명시, 컴파일러/lockdep 검증 없음 */
/* v5.8 이후: 타입으로 연관 잠금 명시 */
seqcount_spinlock_t my_seq = SEQCNT_SPINLOCK_ZERO(my_seq, &my_lock);
/*
* 효과:
* 1. lockdep이 write_seqcount_begin() 시 my_lock 보유 자동 검증
* 2. PREEMPT_RT에서 spinlock→rt_mutex 변환 추적
* 3. 문서화 없이도 코드에서 잠금 관계 명확
*/
/* 매크로 기반 타입 생성 (C 제네릭 에뮬레이션) */
#define SEQCOUNT_LOCKNAME(lockname, locktype, preemptible, lockbase) \
typedef struct seqcount_##lockname { \
seqcount_t seqcount; \
__SEQ_LOCK(locktype *lock); \
} seqcount_##lockname##_t;
/* 생성된 타입들 */
SEQCOUNT_LOCKNAME(spinlock, spinlock_t, true, spin)
SEQCOUNT_LOCKNAME(raw_spinlock, raw_spinlock_t, false, raw_spin)
SEQCOUNT_LOCKNAME(rwlock, rwlock_t, true, read)
SEQCOUNT_LOCKNAME(mutex, struct mutex, true, mutex)
SEQCOUNT_LOCKNAME(ww_mutex, struct ww_mutex, true, ww_mutex)
| 버전 | 변화 | 동기 | 영향 |
|---|---|---|---|
| v2.5.28 | seqlock_t 최초 도입 | jiffies 32비트 torn read 방지 | spinlock 내장 시퀀스 카운터 |
| v2.6 | seqcount_t 분리 | 외부 잠금 사용 패턴 지원 | 유연한 라이터 직렬화 |
| v3.x | seqcount_latch 도입 | NMI-safe 읽기 필요 | 이중 버퍼 패턴 공식화 |
| v4.x | vDSO seqcount 확장 | clock_gettime 성능 | 유저 공간 seqcount |
| v5.8 | seqcount_LOCKNAME_t | lockdep 강화, RT 대비 | 5개 타입 변형, 연관 잠금 |
| v5.10 | seqcount_latch_t 타입화 | latch API 정리 | 전용 타입과 API 분리 |
| v6.x | KCSAN 통합 | 데이터 레이스 검증 | seqlock 영역 자동 어노테이션 |
보안: 타이밍 사이드채널
seqlock의 재시도 루프는 타이밍 사이드채널 공격의 잠재적 벡터가 될 수 있습니다. 리더의 실행 시간이 라이터 활동에 따라 변하므로, 이를 관측하여 시스템 내부 상태를 추론할 수 있습니다.
재시도 루프 타이밍 오라클
/* 공격 시나리오: seqlock 기반 타이밍 추론 */
공격자 목표: 특정 커널 이벤트(타이머 인터럽트)의 정확한 발생 시점 파악
방법:
1. clock_gettime()을 반복 호출하며 응답 시간 측정
2. 정상: ~20ns (vDSO fast path)
3. 재시도 발생: ~40-60ns (seqcount 불일치 → 루프 1회 추가)
→ 재시도가 관측되면:
"이 시점에 커널이 update_wall_time()을 실행 중"
= 타이머 인터럽트 발생 시점을 ~10ns 정밀도로 특정
잠재적 악용:
- 타이머 인터럽트 주기 정밀 측정 → CONFIG_HZ 추론
- 인터럽트 처리 시간 측정 → 커널 코드 경로 추론
- 다른 CPU의 라이터 활동 감지 → 워크로드 패턴 분석
시퀀스 경합을 통한 정보 유출
/* 네트워크 통계 seqlock을 통한 트래픽 패턴 추론 */
/*
* u64_stats_sync (네트워크 통계 seqlock) 공격 시나리오:
*
* /proc/net/dev나 netlink를 통해 통계 읽기 시,
* 32비트 시스템에서 seqcount 재시도가 발생하는 빈도로
* 패킷 수신 빈도를 간접적으로 추론 가능:
*
* retry_rate ≈ f_w × T_r
* f_w = 패킷 수신률 (통계 업데이트 빈도)
* T_r = 읽기 임계 영역 시간
*
* 높은 재시도율 → 높은 패킷 수신률
* → 네트워크 트래픽 볼륨 추론
*
* 실제 위협 수준: 낮음
* - 64비트 시스템에서 u64_stats_sync는 no-op
* - /proc/net/dev는 이미 통계를 직접 노출
* - 컨테이너 환경에서만 의미 (네임스페이스 격리 시)
*/
완화 전략
| 위협 | 심각도 | 완화 방법 | 상태 |
|---|---|---|---|
| vDSO 타이밍 오라클 | 낮음 | ASLR, 타이머 인터럽트 지터 | 부분 적용 |
| 네트워크 통계 경합 추론 | 매우 낮음 | 64비트에서 no-op | 아키텍처로 해결 |
| timekeeper 업데이트 시점 | 낮음 | timer_slack, NO_HZ | 간접 완화 |
| Spectre 투기적 읽기 | 중간 | 리더에 lfence 삽입 (x86) | 커널 패치(Patch) 적용 |
/* Spectre v1 완화: 투기적 seqlock 리더 */
/*
* seqlock 리더에서 투기적 실행 문제:
*
* 1. CPU가 read_seqcount_retry() 결과를 예측
* 2. 예측이 "성공"(no retry)이면 투기적으로 데이터 사용
* 3. 실제로는 retry가 필요했지만, 투기적 실행 중
* 불일관 데이터로 캐시 사이드채널 생성
*
* 커널 완화:
* - 민감한 경로에 speculation_barrier() 추가
* - array_index_nospec()으로 배열 인덱스 바운드 강제
*/
do {
seq = read_seqcount_begin(&s);
idx = shared_idx;
val = array[array_index_nospec(idx, ARRAY_SIZE)];
/* array_index_nospec: 투기적 실행에서도 범위 내 인덱스 보장 */
} while (read_seqcount_retry(&s, seq));
참고 자료
커널 공식 문서
- Sequence counters and sequential locks — seqcount/seqlock API 공식 가이드
- Lock types and their rules — seqlock의 잠금 유형 분류와 PREEMPT_RT 규칙
- memory-barriers.txt — seqlock의 smp_rmb()/smp_wmb() 메모리 순서 보장
LWN.net 심층 기사
- Sequence locks (2003) — seqlock의 최초 도입과 설계 동기
- Reworking the seqcount/seqlock API (2020) — seqcount_t API 대규모 리팩터링
- Lockless patterns: an introduction to compare-and-swap (2014) — seqlock read retry 패턴의 이론적 기초
- What is RCU, Fundamentally? (2007) — seqlock과 RCU의 읽기 최적화 비교
학술 자료 및 외부 참고
- Paul McKenney — "Is Parallel Programming Hard?" — Chapter 9.3: Sequence Locks
- 커널 소스:
include/linux/seqlock.h,kernel/time/timekeeping.c(seqcount 사용 대표 예시) - jiffies 보호:
kernel/time/jiffies.c— seqcount_raw_spinlock_t 사용 사례
관련 문서
seqlock과 관련된 다른 동기화 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.