Context Switching
Linux 커널의 Context Switching은 CPU 실행 주체를 전환하는 핵심 경로입니다. 이 문서는 schedule() 호출 시점부터 __switch_to 복귀까지 레지스터/커널 스택/메모리 컨텍스트 전환 단계를 해부하고, TLB/PCID 비용, FPU 상태 관리, KPTI 영향, ARM64 차이, perf·ftrace·eBPF 기반 병목 진단 절차까지 종합적으로 다룹니다.
핵심 요약
- 레지스터 전환 — CPU의 범용 레지스터(RAX~R15), 명령 포인터(RIP), 플래그(RFLAGS)를 task_struct의 thread 구조체에 저장·복원합니다.
- 커널 스택 전환 — 모든 프로세스는 독립적인 커널 스택(8KB 또는 16KB)을 가지며, RSP/RBP 레지스터를 교체하여 전환합니다.
- 주소 공간 전환 — CR3 레지스터(페이지 테이블 베이스)를 변경해 가상 주소 공간을 교체합니다. 커널 스레드는 이 단계를 건너뜁니다.
- TLB 관리 — PCID(Process Context Identifier)를 사용하면 주소 공간을 바꿔도 TLB를 비우지 않아 성능이 크게 향상됩니다.
- Lazy FPU — FPU/SIMD 레지스터(최대 2KB)는 실제 사용 시점까지 저장을 지연해 불필요한 비용을 줄입니다.
- KPTI 영향 — Meltdown 취약점 완화(KPTI)로 커널/유저 공간 전환 시 CR3를 두 번 교체하는 추가 비용이 발생합니다.
- 직접 비용 vs 간접 비용 — 레지스터 저장·복원의 직접 비용(1~2µs)보다 캐시 오염·TLB 미스로 인한 간접 비용이 훨씬 큽니다.
- eBPF 트레이싱 —
bpftrace와sched:sched_switch트레이스포인트로 Context Switch를 실시간 분석할 수 있습니다. - 선점 모델 — 커널은 PREEMPT_NONE(서버 기본), PREEMPT_VOLUNTARY, PREEMPT_FULL, PREEMPT_RT 4가지 모델을 지원합니다. 선점 모델이 Context Switch 빈도와 레이턴시를 결정합니다.
- CPU 마이그레이션 — 부하 분산기가 태스크를 다른 CPU로 이동하면 캐시·TLB가 모두 콜드 상태가 되어 성능 비용이 큽니다. CPU 어피니티로 마이그레이션을 제한할 수 있습니다.
- 새 태스크 첫 실행 — fork()된 새 프로세스는 switch_to()로 복원할 레지스터가 없어, copy_thread()가 ret_from_fork 주소를 미리 스택에 배치하여 첫 실행 경로를 구성합니다.
단계별 이해
- 트리거 이해
Context Switch가 언제 발생하는지 파악합니다: 타임 슬라이스 만료(HZ 틱), 블로킹 시스템 콜, 우선순위 선점, sched_yield().vmstat 1의 cs 컬럼으로 초당 횟수를 확인하세요. - schedule() → context_switch() 경로
schedule()→pick_next_task()→context_switch()순으로 호출됩니다. context_switch()가 실제 전환의 핵심으로, 주소 공간 전환과 레지스터 전환을 수행합니다. - 주소 공간 전환 (switch_mm)
유저 프로세스 간 전환 시switch_mm_irqs_off()가 CR3를 교체합니다. PCID 지원 CPU에서는CR3_PCID_NOFLUSH비트로 TLB를 보존합니다. - 레지스터 전환 (switch_to → __switch_to_asm)
switch_to매크로가 callee-saved 레지스터(RBX, RBP, R12~R15)를 커널 스택에 push/pop합니다.__switch_to()는 TLS, I/O 비트맵, FPU 상태를 처리합니다. - 커널 스택 전환 원리
각 프로세스의thread.sp(스택 포인터)를 교체합니다. 전환 후 새 프로세스의 스택에서 실행이 재개되고, ret 명령으로 이전에 중단된 지점으로 돌아갑니다. - FPU 상태 처리
FPU를 사용하지 않는 프로세스는 불필요한 XSAVE/XRSTOR를 수행하지 않습니다. CR0.TS 비트를 세트하여 다음 FPU 명령에서 #NM 예외를 발생시키고, 그때 이전 FPU 상태를 저장합니다. - KPTI 추가 비용 확인
Meltdown 취약 CPU에서는 커널/유저 전환마다 CR3를 두 번 씁니다(커널 페이지 테이블 → 유저 페이지 테이블).cat /sys/devices/system/cpu/vulnerabilities/meltdown으로 상태를 확인하세요. - 성능 분석 및 최적화
/proc/[pid]/status의 voluntary/nonvoluntary ctxt_switches로 원인을 파악하고, CPU 어피니티·실시간 우선순위·비동기 I/O로 최적화합니다. - 선점 모델 확인
zcat /proc/config.gz | grep PREEMPT로 현재 커널의 선점 모델을 확인합니다. 서버는 PREEMPT_NONE, 데스크톱은 PREEMPT_VOLUNTARY/FULL, 실시간 시스템은 PREEMPT_RT를 사용합니다. 선점 모델에 따라 비자발적 Context Switch 빈도가 크게 달라집니다. - CPU 마이그레이션 제어
taskset -c 0-3 ./myapp으로 프로그램을 특정 CPU 세트에 고정합니다. 마이그레이션이 자주 발생하면perf stat -e cpu-migrations ./myapp으로 확인하고, NUMA 인식 할당(numactl --membind=0 --cpunodebind=0)으로 성능을 높입니다.
개요 (Overview)
Context Switch는 CPU가 현재 실행 중인 프로세스(또는 스레드)의 상태를 저장하고, 다른 프로세스의 이전 상태를 복원하여 실행을 재개하는 과정입니다. Linux 커널은 선점형 멀티태스킹을 구현하기 위해 이 메커니즘을 사용합니다.
Context Switch는 다음 시점에 발생합니다:
- 타임 슬라이스 만료 — 스케줄러 틱 인터럽트(CONFIG_HZ, 기본 250Hz)
- 블로킹 시스템 콜 — I/O 대기, sleep(), wait(), mutex_lock()
- 우선순위 선점 — 높은 우선순위 프로세스가 깨어남(wake_up())
- 명시적 양보 — sched_yield(), cond_resched()
- 인터럽트 후 선점 — 인터럽트 핸들러 종료 후 TIF_NEED_RESCHED 플래그 확인
빈도: 일반적인 리눅스 서버는 초당 100~10,000번의 Context Switch가 발생합니다. CPU-bound 워크로드는 낮고, I/O-bound 워크로드는 높습니다. vmstat 1의 cs 컬럼으로 확인 가능합니다. 데이터베이스 서버는 수만 회까지 올라갈 수 있습니다.
Context Switch 상세 과정
Context Switch는 크게 ①주소 공간 전환, ②스택/레지스터 전환, ③후처리 3단계로 나뉩니다:
schedule() ← 스케줄러 진입
└─ __schedule(preempt)
├─ pick_next_task() ← 다음 실행할 task 선택 (CFS, RT, DL)
└─ context_switch() ← 실제 전환 시작
├─ [주소 공간 전환]
│ ├─ next->mm 있음? → switch_mm_irqs_off()
│ │ ├─ PCID 지원? → CR3 쓰기 (NOFLUSH 비트 포함)
│ │ └─ PCID 미지원 → CR3 쓰기 (TLB 전체 플러시)
│ └─ next->mm 없음? → 커널 스레드: active_mm 차용
├─ [레지스터/스택 전환]
│ └─ switch_to(prev, next, prev)
│ ├─ __switch_to_asm() ← 어셈블리: callee-saved 레지스터 push/pop
│ └─ __switch_to() ← C: FPU, TLS, I/O 비트맵 등
└─ finish_task_switch(prev) ← prev 정리, TIF 플래그 처리
__switch_to() 함수 심층 분석
어셈블리 래퍼: __switch_to_asm
switch_to 매크로가 최종적으로 호출하는 어셈블리 함수입니다. callee-saved 레지스터만 저장하면 되는 이유는 컴파일러 ABI상 나머지 레지스터는 호출자가 이미 저장했기 때문입니다:
/* arch/x86/entry/entry_64.S */
SYM_FUNC_START(__switch_to_asm)
/*
* prev를 저장하고 next를 복원한다.
* callee-saved 레지스터만 저장: RBX, RBP, R12-R15
*/
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* prev->thread.sp = RSP (스택 포인터 저장) */
movq %rsp, TASK_threadsp(%rdi)
/* RSP = next->thread.sp (스택 포인터 복원) */
movq TASK_threadsp(%rsi), %rsp
/* next의 스택에서 레지스터 복원 */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
/* __switch_to(prev, next) 호출 후 ret */
jmp __switch_to
SYM_FUNC_END(__switch_to_asm)
RIP은 저장하지 않는다: jmp __switch_to로 끝나기 때문에 반환 주소(RIP)는 이미 스택에 있습니다. __switch_to()가 return prev로 끝나면 스택에서 pop한 주소로 돌아가는데, 이 주소가 이전에 switch_to를 호출한 지점이므로 Process B의 실행이 자연스럽게 재개됩니다.
__switch_to() C 구현
/* arch/x86/kernel/process_64.c */
__visible struct task_struct *
__switch_to(struct task_struct *prev, struct task_struct *next)
{
struct thread_struct *prev_thread = &prev->thread;
struct thread_struct *next_thread = &next->thread;
int cpu = smp_processor_id();
/* 1. FPU/SIMD 저장 준비 (Lazy: CR0.TS 세트만 함) */
switch_fpu_prepare(prev, cpu);
/* 2. 커널 스택 최상단 주소를 TSS에 기록 (시스템 콜/인터럽트용) */
this_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next));
/* 3. TLS (Thread Local Storage) 전환: FS/GS base MSR 업데이트 */
load_TLS(next_thread, cpu);
arch_end_context_switch(next);
/* 4. 디버그 레지스터 복원 (DR0-DR7, 하드웨어 브레이크포인트) */
if (unlikely(next_thread->debugreg_active))
switch_to_thread_hw_breakpoint(next);
/* 5. I/O 권한 비트맵 전환 (ioperm() 사용 시) */
if (unlikely(prev_thread->io_bitmap || next_thread->io_bitmap))
tss_update_io_bitmap();
/* 6. FPU/SIMD 상태 복원 준비 */
switch_fpu_finish(next);
/* 7. 특수 레지스터: DS, ES 등은 일반적으로 고정값 */
return prev;
}
/* context_switch() - kernel/sched/core.c */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
/* 주소 공간 전환 */
if (!next->mm) {
/* 커널 스레드: 이전 mm 차용 (페이지 테이블 전환 없음) */
next->active_mm = prev->active_mm;
if (prev->mm)
mmgrab(prev->active_mm);
enter_lazy_tlb(prev->active_mm, next);
} else {
/* 유저 프로세스: mm 전환 */
switch_mm_irqs_off(prev->active_mm, next->mm, next);
lru_gen_use_mm(next->mm);
}
rq->curr = next;
/* 실제 레지스터 전환 (위 어셈블리) */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
커널 스택 구조와 전환
각 프로세스(스레드)는 독립적인 커널 스택을 가집니다. x86-64에서 기본 크기는 16KB(4페이지)이며, CONFIG_THREAD_SIZE_ORDER로 설정됩니다.
/* 커널 스택 구조 (높은 주소 → 낮은 주소) */
struct thread_info { /* 스택 최하단 (가장 낮은 주소) */
unsigned long flags; /* TIF_NEED_RESCHED 등 */
unsigned long syscall_work;
u32 status;
/* ... */
};
/*
* task_top_of_stack(): 커널 스택의 최상단 주소
* = (unsigned long)(task->stack) + THREAD_SIZE
* 이 값이 TSS.sp0에 기록되어 시스템 콜 진입 시 사용됨
*/
static __always_inline unsigned long task_top_of_stack(struct task_struct *task)
{
return (unsigned long)(task->stack) + THREAD_SIZE;
}
/* thread_struct: task_struct 내 아키텍처별 레지스터 상태 */
struct thread_struct {
unsigned long sp; /* 커널 스택 포인터 (switch_to로 교환) */
unsigned long ip; /* 복귀 명령 포인터 */
unsigned long fs; /* FS 세그먼트 (64비트 TLS) */
unsigned long gs; /* GS 세그먼트 */
struct fpu fpu; /* FPU/SIMD 상태 */
/* ... */
};
새 태스크의 첫 번째 실행
fork()나 clone()으로 생성된 새 프로세스는 아직 단 한 번도 CPU를 얻은 적이 없습니다. switch_to()는 이전에 저장해 둔 레지스터를 복원하는 방식으로 동작하는데, 새 프로세스에는 "저장해 둔" 상태가 없습니다. 이 문제를 해결하기 위해 copy_thread()가 커널 스택을 미리 셋업하고, ret_from_fork 주소를 복귀 주소로 배치합니다.
/* arch/x86/kernel/process.c — fork() 시 커널 스택 초기화 */
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
struct pt_regs *childregs;
/* 새 태스크 커널 스택 최상단에 pt_regs 공간 예약 */
childregs = task_pt_regs(p);
/* ── 커널 스레드 경로 ── */
if (unlikely(args->fn)) {
/* 커널 스레드: 레지스터 상태 필요 없음 */
memset(childregs, 0, sizeof(*childregs));
p->thread.sp = (unsigned long)childregs;
/* R12에 실행 함수, R13에 인자 저장 (ret_from_fork에서 호출) */
p->thread.r12 = (unsigned long)args->fn;
p->thread.r13 = (unsigned long)args->fn_arg;
p->thread.ip = (unsigned long)ret_from_kernel_thread;
return 0;
}
/* ── 유저 프로세스 경로 ── */
/* 부모의 유저 레지스터 상태 복사 */
*childregs = *current_pt_regs();
childregs->ax = 0; /* fork() 자식 반환값 = 0 */
/*
* __switch_to_asm의 "초기 callee-saved push" 시뮬레이션:
* 스택에 R15, R14, R13, R12, RBX, RBP = 0 배치
* 그 위에 ret_from_fork 주소를 반환 주소로 배치
*/
p->thread.sp = (unsigned long)childregs - sizeof(unsigned long) * 6;
/* 위 주소 위치에 실제로 0 × 6 + ret_from_fork 주소가 배치됨 */
p->thread.ip = (unsigned long)ret_from_fork;
return 0;
}
/*
* switch_to() 내부 동작 (새 태스크 첫 실행):
*
* 1. __switch_to_asm: RSP ← p->thread.sp (위에서 셋업한 위치)
* 2. pop R15, R14, R13, R12, RBX, RBP (모두 0 값)
* 3. jmp __switch_to → return prev
* 4. ret → 스택에서 ret_from_fork 주소 pop → 진입
*/
/* arch/x86/entry/entry_64.S — 새 태스크 첫 진입점 */
SYM_CODE_START(ret_from_fork)
/*
* 이 지점에 도달할 때:
* - RSP: 새 태스크 커널 스택 (switch_to 완료)
* - R12: 커널 스레드 함수 포인터 (유저 프로세스는 0)
* - R13: 커널 스레드 인자 (유저 프로세스는 0)
*/
/* finish_task_switch(prev): 이전 태스크 참조 카운터 정리 */
movq %rax, %rdi /* prev = switch_to()의 반환값 */
call schedule_tail
/* 커널 스레드: R12 != 0 이면 직접 호출 */
testq %r12, %r12
jnz .Lkernel_thread_entry
/* 유저 프로세스: 유저 공간으로 복귀 준비 */
movq %rsp, %rdi
call syscall_exit_to_user_mode /* seccomp, signals, TIF 플래그 처리 */
jmp sysret_safe_exit /* SYSRET로 유저 공간 복귀 */
.Lkernel_thread_entry:
movq %r13, %rdi /* fn 인자 */
CALL_NOSPEC r12 /* fn(arg) 호출 */
xorl %edi, %edi
call do_exit /* 커널 스레드 종료 */
SYM_CODE_END(ret_from_fork)
exec() 후 첫 실행: exec()는 기존 프로세스의 주소 공간을 교체합니다. execve 시스템 콜이 완료되면 start_thread()로 pt_regs의 RIP과 RSP를 새 바이너리의 진입점과 유저 스택으로 설정합니다. 이후 sysret으로 복귀할 때 새 바이너리의 첫 명령이 실행됩니다.
저장·복원되는 레지스터
| 레지스터 종류 | 예시 (x86-64) | 저장 방식 | 비고 |
|---|---|---|---|
| callee-saved | RBX, RBP, R12~R15 | __switch_to_asm (push/pop) | ABI 상 함수가 직접 보존 |
| 명령 포인터 | RIP | 스택의 반환 주소 | call/ret으로 자동 관리 |
| 스택 포인터 | RSP | thread.sp 필드 | switch_to_asm 핵심 교환 |
| 플래그 | RFLAGS | 스택 (인터럽트 프레임) | 인터럽트 재진입 시 복원 |
| TLS 세그먼트 | FS, GS base MSR | __switch_to() / load_TLS() | 스레드 로컬 변수용 |
| 페이지 테이블 | CR3 | switch_mm (write_cr3) | PCID 포함 |
| FPU/SIMD | x87, SSE, AVX, AVX-512 | Lazy XSAVE/XRSTOR | 최대 2.5KB, 지연 저장 |
| 디버그 | DR0~DR7 | switch_to_thread_hw_breakpoint() | 하드웨어 브레이크포인트 사용 시만 |
Lazy FPU/SIMD 상태 관리
FPU(x87)/SSE/AVX/AVX-512 레지스터 집합은 크기가 크기 때문에(최대 2.5KB) 매 Context Switch마다 저장하면 심각한 성능 저하가 발생합니다. Linux 커널은 Lazy 저장 전략을 사용합니다.
Lazy FPU 동작 원리
/* 전환 시: CR0.TS (Task Switched) 비트 설정 */
static inline void switch_fpu_prepare(struct task_struct *old, int cpu)
{
if (test_cpu_flag(X86_FEATURE_FPU)) {
/* FPU를 마지막으로 사용한 태스크 기록 */
per_cpu(fpu_fpregs_owner_ctx, cpu) = NULL;
/* CR0.TS 세트: 다음 FPU 명령에서 #NM 예외 발생 */
stts(); /* set task-switched flag in CR0 */
}
}
/* FPU 명령 실행 시 #NM 예외 핸들러 */
dotraplinkage void do_device_not_available(struct pt_regs *regs, long error_code)
{
/* 이전 프로세스의 FPU 상태를 이제 저장 */
fpu__save(¤t->thread.fpu);
/* 현재 프로세스의 FPU 상태 복원 */
fpu__restore(¤t->thread.fpu);
/* CR0.TS 클리어: 이후 FPU 명령은 정상 실행 */
clts();
}
/* XSAVE/XRSTOR 명령으로 FPU 상태 저장/복원 */
static inline void copy_kernel_to_fpregs(struct fregs_state *fpstate)
{
/* XRSTOR: 컴포넌트별 선택적 복원 (AVX-512 사용 안 하면 생략) */
XSTATE_XRESTORE(&fpstate->xsave, xstate_bv, 0);
}
/* XSAVE 컴포넌트 마스크 (xstate_bv) */
#define XFEATURE_MASK_FP (1 << 0) /* x87 FPU (576B) */
#define XFEATURE_MASK_SSE (1 << 1) /* SSE/XMM (256B) */
#define XFEATURE_MASK_YMM (1 << 2) /* AVX/YMM (256B) */
#define XFEATURE_MASK_AVX512 (7 << 5) /* ZMM/Opmask (~1.5KB) */
| FPU 상태 | 크기 | 명령 | 저장 조건 |
|---|---|---|---|
| x87 FPU + XMM (SSE) | 512 B | FXSAVE/FXRSTOR | 구형 (XSAVE 미지원 시) |
| XSAVE (컴포넌트 선택) | 576B ~ 2.5KB | XSAVEOPT/XRSTORS | 실제 사용 컴포넌트만 |
| AVX-512 추가 | +1,664 B | ZMM 레지스터 포함 | AVX-512 사용 프로세스만 |
성능 팁: AVX-512를 사용하는 프로세스와 사용하지 않는 프로세스가 같은 CPU에서 Context Switch되면, FPU 상태 크기 차이(512B vs 2.5KB)로 인해 불균등한 비용이 발생합니다. CPU 어피니티로 AVX-512 워크로드를 격리하면 일관된 성능을 얻을 수 있습니다.
TLB 관리와 PCID
PCID (Process-Context Identifier)
x86에서 CR3(페이지 테이블 포인터)를 변경하면 기본적으로 TLB 전체가 무효화됩니다. PCID는 각 주소 공간에 12비트 태그를 부여해 TLB 항목을 구분하므로, 주소 공간 전환 후에도 이전 TLB 항목을 재사용할 수 있습니다:
PCID는 각 주소 공간에 12비트 태그를 부여해 TLB 항목을 구분하므로, 주소 공간 전환 후에도 이전 TLB 항목을 재사용할 수 있습니다:
/* arch/x86/mm/tlb.c */
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
unsigned long new_pgd;
u16 new_asid;
if (cpu_feature_enabled(X86_FEATURE_PCID)) {
/* PCID 사용: TLB 유지 (NOFLUSH 비트 세트) */
new_asid = mm_cpumask(next)->bits[0] & 0xfff; /* 12비트 PCID */
/* CR3 상위 비트(63)에 NOFLUSH 설정 → TLB 유지 */
new_pgd = __pa(next->pgd) | (u64)new_asid | CR3_PCID_NOFLUSH;
} else {
/* PCID 미지원: TLB 전체 플러시 (느림) */
new_pgd = __pa(next->pgd);
}
write_cr3(new_pgd);
/* KPTI 활성화 시: 유저 페이지 테이블도 업데이트 */
if (static_cpu_has(X86_FEATURE_PTI)) {
/* 유저 공간 CR3 = 커널 CR3 | PTI_USER_PGTABLE_AND_PCID_MASK */
this_cpu_write(cpu_tlbstate.user_pcid, new_asid | PTI_USER_PCID_BITS);
}
}
/* 능동적 TLB 플러시 (특정 주소 범위만) */
static void flush_tlb_range_vm(struct vm_area_struct *vma,
unsigned long start, unsigned long end)
{
if (cpu_feature_enabled(X86_FEATURE_INVPCID)) {
/* INVPCID: 특정 PCID의 특정 VA만 무효화 */
invpcid_flush_one(asid, start);
} else {
/* INVLPG: 페이지 단위 무효화 */
asm volatile("invlpg (%0)" :: "r"(start) : "memory");
}
}
PCID 성능 향상
| 시나리오 | PCID 미사용 | PCID 사용 | 개선 |
|---|---|---|---|
| Context Switch 지연 (직접) | 2.5µs | 1.2µs | 52% 감소 |
| TLB Miss (전환 직후) | ~100% | ~5% | 95% 감소 |
| 처리량 (I/O 집약적) | baseline | +15~20% | 캐시 재사용 효과 |
| 메모리 (TLB 엔트리 수) | 하나의 컨텍스트 | 최대 4096개 PCID | 다중 프로세스 TLB 공존 |
Context Switch 비용 분석
직접 비용 (µs 단위)
- callee-saved 레지스터 push/pop — ~100ns (6개 레지스터 × ~16ns)
- thread.sp 교환 — ~10ns
- CR3 쓰기 (주소 공간 전환) — PCID: ~50ns / 플러시: ~500ns
- TSS sp0 업데이트 — ~20ns
- TLS MSR 업데이트 — ~50ns (WRMSR × 2)
- FPU 상태 저장 (비Lazy) — XSAVE: 200~800ns (크기 의존)
- 총 직접 비용 — ~300ns (PCID, Lazy FPU) ~ 2.5µs (플러시, XSAVE)
간접 비용 (훨씬 큼)
- L1/L2 캐시 오염 — 새 프로세스의 데이터가 캐시를 채워 이전 프로세스 데이터 축출. 64KB L1 캐시 완전 교체 시 수십 µs 추가 레이턴시
- LLC (L3) 캐시 미스 — 공유되지만 접근 패턴이 바뀌면 수십 µs
- TLB Miss 폭풍 — PCID 없는 경우, 전환 후 모든 메모리 접근이 페이지 테이블 워크 필요
- 분기 예측기 오염 — BTB(Branch Target Buffer) 초기화: 수 µs
- 인터럽트 비활성화 구간 — context_switch() 중 인터럽트 비활성화로 인한 레이턴시
- 총 간접 비용 — 수십 ~ 수백 µs (워크로드, 캐시 크기, CPU 구조 의존)
간접 비용 함정: perf stat -e context-switches는 직접 비용만 측정합니다. 실제 성능 영향은 캐시 미스(cache-misses), TLB 미스(dTLB-load-misses)를 함께 측정해야 전체 그림을 볼 수 있습니다.
KPTI (Kernel Page-Table Isolation)와 Context Switch
Meltdown 취약점(CVE-2017-5754) 완화를 위해 도입된 KPTI는 커널 주소 공간과 유저 주소 공간을 분리된 페이지 테이블로 관리합니다. 이로 인해 커널↔유저 전환마다 CR3를 두 번 교체하는 추가 비용이 발생합니다.
KPTI CR3 이중 전환
/*
* 유저 공간 → 커널 공간 (시스템 콜/인터럽트):
* CR3 = kernel_pgd | ASID (커널 페이지 테이블로 전환)
*
* 커널 공간 → 유저 공간 (sysret/iret):
* CR3 = user_pgd | ASID (유저 페이지 테이블로 전환)
*
* Context Switch 시 (process A → process B):
* CR3 = B.kernel_pgd | ASID_B ← switch_mm에서 1회
* CR3 = B.user_pgd | ASID_B ← sysret 직전 1회
*
* KPTI 비활성화 시: CR3 교체 1회
* KPTI 활성화 시: CR3 교체 2회 (추가 비용 30~50ns)
*/
/* arch/x86/entry/entry_64.S — sysret 직전 */
.if PTI_USER_PGTABLE_MASK != 0
ALTERNATIVE "", "jmp .Lswitch_to_user_cr3_\@", X86_FEATURE_PTI
.Lswitch_to_user_cr3_\@:
SWITCH_TO_USER_CR3_STACK scratch_reg=%rax
.endif
KPTI 성능 영향 측정
# KPTI 상태 확인
cat /sys/devices/system/cpu/vulnerabilities/meltdown
# "Mitigation: PTI" — KPTI 활성화
# "Not affected" — KPTI 비활성화 (Meltdown 미취약 CPU)
# KPTI 비활성화 (보안 주의! 테스트 전용)
# 커널 파라미터: nopti
# Context Switch + 시스템 콜 비용 측정
perf stat -e context-switches,syscalls:sys_enter_read \
-p $(pgrep nginx) sleep 5
# KPTI 영향: 시스템 콜 집약적 워크로드에서 5~30% 성능 저하
# 특히 컨테이너/VM 환경에서 두드러짐
| CPU 세대 | Meltdown 취약 | KPTI 활성화 | 시스템 콜 오버헤드 |
|---|---|---|---|
| Intel Core i7 (Skylake 이전) | 예 | 예 (기본) | 5~30% |
| Intel Core (Coffee Lake 이후) | 일부 | CPU 의존 | 2~10% |
| AMD Zen 2 이상 | 아니오 | 비활성화 | 0% |
| ARM Cortex-A (ARMv8.5+) | 일부 모델 | CSV3 완화 | 1~5% |
INVPCID + PCID 조합: KPTI 활성화 시에도 PCID를 사용하면 유저/커널 페이지 테이블 각각 별도 PCID를 부여해 TLB 플러시를 최소화합니다. Intel Haswell 이상에서 INVPCID 명령으로 선택적 무효화가 가능합니다.
ARM64 Context Switch 구현
ARM64(AArch64)의 Context Switch는 x86-64와 유사하지만, TTBR0_EL1(유저 페이지 테이블)과 TTBR1_EL1(커널 페이지 테이블)이 분리된 레지스터를 사용합니다.
cpu_switch_to 어셈블리
/* arch/arm64/kernel/entry.S */
SYM_FUNC_START(cpu_switch_to)
mov x10, #THREAD_CPU_CONTEXT
add x8, x0, x10 /* prev->thread.cpu_context */
mov x9, sp
/* callee-saved 레지스터 저장: x19-x28, fp(x29), lr(x30), sp */
stp x19, x20, [x8], #16
stp x21, x22, [x8], #16
stp x23, x24, [x8], #16
stp x25, x26, [x8], #16
stp x27, x28, [x8], #16
stp x29, x9, [x8], #16
str lr, [x8] /* pc = lr (반환 주소) */
/* next의 레지스터 복원 */
add x8, x1, x10 /* next->thread.cpu_context */
ldp x19, x20, [x8], #16
ldp x21, x22, [x8], #16
ldp x23, x24, [x8], #16
ldp x25, x26, [x8], #16
ldp x27, x28, [x8], #16
ldp x29, x9, [x8], #16
ldr lr, [x8]
mov sp, x9 /* 스택 포인터 전환 */
ret /* lr(pc)로 점프 → next 프로세스 재개 */
SYM_FUNC_END(cpu_switch_to)
ARM64 주소 공간 전환
/* arch/arm64/include/asm/mmu_context.h */
static inline void
switch_mm(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
if (prev != next) {
/*
* TTBR0_EL1: 유저 공간 페이지 테이블 베이스 레지스터
* ASID (ARM의 PCID 대응): 16비트, TLB 공유 가능
*/
cpu_switch_mm(next->pgd, next);
}
}
static inline void cpu_switch_mm(pgd_t *pgd, struct mm_struct *mm)
{
BUG_ON(pgd == swapper_pg_dir);
/* ASID 포함 TTBR0 값 계산 */
uintptr_t ttbr1 = read_sysreg(ttbr1_el1);
unsigned long asid = ASID(mm);
/* TTBR0_EL1 업데이트 (ASID 포함) */
write_sysreg(__phys_to_ttbr0(virt_to_phys(pgd)) |
((u64)asid << 48), ttbr0_el1);
isb(); /* 명령 장벽: 이후 TLB 워크가 새 TTBR0 사용 */
}
/*
* ARM64 ASID (Address Space ID):
* - 8비트 (256개) 또는 16비트 (65536개, CPU 의존)
* - x86의 PCID와 동일한 역할: TLB 태그로 사용
* - ASID 소진 시 global TLB 플러시 후 재할당
*/
ARM64 vs x86-64 비교
| 항목 | x86-64 | ARM64 |
|---|---|---|
| 레지스터 전환 함수 | __switch_to_asm + __switch_to | cpu_switch_to (하나로 통합) |
| callee-saved 수 | 6개 (RBX,RBP,R12~R15) | 12개 (x19~x28,fp,sp) |
| 페이지 테이블 레지스터 | CR3 (하나, PCID 포함) | TTBR0_EL1 + TTBR1_EL1 (분리) |
| TLB 태그 | PCID (12비트) | ASID (8비트 또는 16비트) |
| FPU 저장 | XSAVE/XRSTOR | FPSIMD (ld1/st1 명령) |
| Meltdown 완화 | KPTI (CR3 이중 교체) | 대부분 미취약 (일부 CSV3) |
SMP CPU 마이그레이션과 Context Switch
멀티코어 시스템에서 부하 분산기(Load Balancer)는 태스크를 한 CPU에서 다른 CPU로 이동합니다. 이 CPU 마이그레이션은 Context Switch와 밀접하게 연관되어 있으며, 캐시와 TLB를 완전히 콜드 상태로 만들기 때문에 비용이 매우 큽니다.
/* kernel/sched/core.c — CPU 마이그레이션 핵심 경로 */
/* 부하 분산 트리거 (scheduler_tick() → 주기적 호출) */
static void run_rebalance_domains(struct softirq_action *h)
{
struct rq *this_rq = this_rq();
enum cpu_idle_type idle = this_rq->idle_balance ?
CPU_IDLE : CPU_NOT_IDLE;
/* 모든 스케줄링 도메인을 순회하며 부하 불균형 탐지 */
rebalance_domains(this_rq, idle);
}
/* 태스크 마이그레이션 실행 */
static void __migrate_task(struct rq *src_rq, struct rq_flags *src_rf,
struct task_struct *p, int dest_cpu)
{
/* 소스 런큐에서 태스크 제거 */
dequeue_task(src_rq, p, DEQUEUE_NOCLOCK);
/* 마이그레이션 메타데이터 업데이트 */
set_task_cpu(p, dest_cpu); /* p->cpu = dest_cpu */
p->on_rq = TASK_ON_RQ_MIGRATING;
/* 목적지 CPU 런큐에 삽입 */
struct rq *dest_rq = cpu_rq(dest_cpu);
rq_lock(dest_rq, &dest_rf);
enqueue_task(dest_rq, p, ENQUEUE_NOCLOCK);
wakeup_preempt(dest_rq, p, 0); /* 목적지 CPU 선점 요청 */
rq_unlock(dest_rq, &dest_rf);
}
/*
* 마이그레이션 비용 분석:
* 1. 같은 LLC 공유 코어 간: 비교적 저렴 (L3 캐시 공유)
* 2. 다른 LLC 코어 간: L3 Miss 발생, 수십 µs
* 3. NUMA 노드 간: 원격 DRAM 접근 40~100ns/회
*
* 마이그레이션 억제 요소:
* - cache_hot_is_busy(): 최근 사용 태스크는 이동 보류
* - sched_migration_cost_ns(=500000): 500µs 이내 실행 태스크는 HOT
*/
마이그레이션 제어
# CPU 어피니티: 특정 CPU 세트에 고정 (마이그레이션 차단)
taskset -c 0-3 ./myapp # CPU 0,1,2,3만 허용
taskset -p -c 4-7 $(pgrep app) # 실행 중인 프로세스에 적용
# NUMA 인식 실행: 메모리와 CPU를 같은 노드에 할당
numactl --cpunodebind=0 --membind=0 ./myapp
# CPU 마이그레이션 이벤트 측정
perf stat -e cpu-migrations -p $(pgrep myapp) sleep 10
# 스케줄링 도메인별 마이그레이션 통계
cat /proc/schedstat
# cpu0: ... migrations=12345 (부하 분산으로 이동된 횟수)
# 특정 CPU 격리 (커널 파라미터, /etc/default/grub)
# isolcpus=2,3 → CPU 2,3을 부하 분산에서 제외
# nohz_full=2,3 → 틱리스 모드 (Context Switch 없음)
# rcu_nocbs=2,3 → RCU 콜백을 다른 CPU로 오프로드
| 마이그레이션 범위 | 대기 시간 | 캐시 영향 | NUMA 영향 |
|---|---|---|---|
| 같은 코어 (HT 형제) | 거의 없음 | L1/L2 공유 → 최소 | 없음 |
| 같은 LLC (소켓 내) | ~10~50µs | L3 공유 → 낮음 | 없음 |
| 다른 LLC (소켓 내) | ~50~200µs | L3 미스 → 중간 | 없음 |
| 다른 NUMA 노드 | ~200µs~2ms | 전체 미스 → 높음 | 원격 메모리 접근 2~5배 |
자발적 vs 비자발적 Context Switch
| 종류 | 발생 원인 | 커널 경로 | 성능 영향 | 최적화 |
|---|---|---|---|---|
| 자발적 | sleep(), I/O 대기, mutex_lock(), wait_event() | schedule() 직접 호출 | 불가피하지만 낮음 | 비동기 I/O(io_uring), 잠금 경합 감소 |
| 비자발적 | 타임 슬라이스 만료, 고우선순위 깨어남 | TIF_NEED_RESCHED → 인터럽트 후 선점 | 높음 (CPU 경쟁 지표) | 프로세스 수 감소, CPU 어피니티, SCHED_FIFO |
# 프로세스별 자발적/비자발적 Context Switch 조회
cat /proc/$(pgrep nginx | head -1)/status | grep ctxt
voluntary_ctxt_switches: 12345 # 자발적 (I/O 대기 등)
nonvoluntary_ctxt_switches: 234 # 비자발적 (선점)
# 비율 해석:
# nonvoluntary >> voluntary → CPU 경쟁 심각, 프로세스/스레드 수 줄이기
# voluntary >> nonvoluntary → I/O 바운드, 비동기 I/O 검토
# 시스템 전체 비율
awk '/ctxt/ {sum[$1]+=$2} END {for(k in sum) print k, sum[k]}' \
/proc/*/status 2>/dev/null
높은 비자발적 CS: 비자발적 Context Switch가 자발적보다 훨씬 많으면 CPU 경쟁이 심한 것입니다. 프로세스/스레드 수를 CPU 수와 맞추거나, 실시간 우선순위(SCHED_FIFO)로 핵심 스레드를 격리하세요.
커널 선점 모델 (Preemption Models)
Linux 커널은 빌드 시 선택 가능한 4가지 선점 모델을 지원합니다. 선점 모델은 커널 코드 실행 중 언제 다른 태스크로 전환할 수 있는지를 결정하며, Context Switch 빈도와 응답 레이턴시에 직접적인 영향을 미칩니다.
/*
* 4가지 선점 모델과 선점 가능 지점
*
* 1. PREEMPT_NONE (="macro">CONFIG_PREEMPT_NONE)
* - 선점 포인트: 유저→커널 복귀 시, 명시적 schedule() 호출
* - 커널 코드는 선점 불가 → 높은 처리량, 긴 레이턴시
* - 서버용 배포판 기본값
*/
void scheduler_tick(void) {
/* 타이머 인터럽트: TIF_NEED_RESCHED 플래그 세트만 */
set_tsk_need_resched(current);
/* 실제 선점은 커널 복귀 직전(exit_to_user_mode)에 발생 */
}
/*
* 2. PREEMPT_VOLUNTARY (="macro">CONFIG_PREEMPT_VOLUNTARY)
* - PREEMPT_NONE + might_sleep() / might_resched() 포인트
* - 긴 루프에 cond_resched() 삽입으로 선점 기회 제공
*/
might_sleep(); /* = might_resched() → TIF_NEED_RESCHED 확인 */
cond_resched(); /* 반복 루프에서 명시적 양보: 비자발적 CS 감소 */
/*
* 3. PREEMPT_FULL (="macro">CONFIG_PREEMPT)
* - 커널 코드는 선점 불가 구간(spinlock, irq-off) 외 모두 선점 가능
* - preempt_count == 0 이면 언제든 선점 발생
* - TIF_NEED_RESCHED + irq/preempt-off 해제 시 즉시 schedule()
*/
preempt_disable(); /* preempt_count++ → 선점 금지 */
/* 이 구간은 선점 불가 */
critical_section();
preempt_enable(); /* preempt_count-- → 0이면 즉시 선점 */
/*
* 4. PREEMPT_RT (="macro">CONFIG_PREEMPT_RT)
* - 스핀락 → rtmutex 변환 (잠금 중 선점 가능)
* - 인터럽트 핸들러 → 커널 스레드로 변환 (스레드 IRQ)
* - 최악 레이턴시 수십 µs 보장
* - 오버헤드: 잠금당 ~100ns 추가 (뮤텍스 오버헤드)
*/
/* raw_spinlock: PREEMPT_RT에서도 선점 불가인 유일한 잠금 */
raw_spin_lock_irqsave(&lock, flags); /* 절대 불가 구간 */
/* 하드웨어 접근 등 극소 임계 구간만 사용 */
raw_spin_unlock_irqrestore(&lock, flags);
# 현재 커널 선점 모델 확인
zcat /proc/config.gz | grep -E "^CONFIG_PREEMPT"
# CONFIG_PREEMPT_VOLUNTARY=y → VOLUNTARY 모델
# 또는
uname -v | grep -oE "PREEMPT[_A-Z]*"
# 런타임 선점 모델 전환 (CONFIG_PREEMPT_DYNAMIC 지원 시)
cat /sys/kernel/debug/sched/preempt
# 출력: none / voluntary / full
echo full > /sys/kernel/debug/sched/preempt # full 선점으로 전환
echo voluntary > /sys/kernel/debug/sched/preempt
# 선점 지점에서 실제 Context Switch 확인
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_state == 0/ {
@involuntary[comm] = count(); /* 비자발적 (TASK_RUNNING) */
}
tracepoint:sched:sched_switch /args->prev_state != 0/ {
@voluntary[comm] = count(); /* 자발적 (대기 상태) */
}
interval:s:5 { print(@involuntary); print(@voluntary); clear(@); clear(@voluntary); }'
CONFIG_PREEMPT_DYNAMIC: Linux 5.14부터 도입된 이 기능은 커널 재컴파일 없이 런타임에 선점 모델을 전환할 수 있습니다. /sys/kernel/debug/sched/preempt에 none/voluntary/full을 쓰면 즉시 적용됩니다. 데이터베이스 벤치마크 시 none으로, 응답성 테스트 시 full로 동적 전환하여 비교할 수 있습니다.
모니터링 및 프로파일링
vmstat — Context Switch 빈도
vmstat 1
# cs 컬럼: 초당 Context Switches
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 0 123456 78901 234567 0 0 0 0 1234 5678 5 2 93 0 0
^^^^
Context Switches/s
# 주요 해석:
# cs < 1000/s → 정상 (배치 처리)
# cs 1000~10000 → 보통 (I/O 서버)
# cs > 10000/s → 높음 (경합 점검 필요)
perf — Context Switch 심층 분석
# 기본 카운트
perf stat -e context-switches,cpu-migrations,cache-misses,dTLB-load-misses ./myapp
# Context Switch 트레이싱 (CPU 전체, 5초)
perf record -e sched:sched_switch -ag -- sleep 5
perf report --sort comm,pid
# 프로세스별 실시간 컨텍스트 스위치 Top
perf top -e context-switches -s comm
# sched 레이턴시 분석 (전환 지연 분포)
perf sched record -- sleep 10
perf sched latency --sort max
perf sched timehist # 전환별 타임라인
# wakeup latency 분포
perf sched lat -s switch
/proc/[pid]/status
cat /proc/self/status | grep ctxt
voluntary_ctxt_switches: 1234 # 자발적 (I/O 대기 등)
nonvoluntary_ctxt_switches: 567 # 비자발적 (선점)
# 모든 프로세스 Context Switch 합계
awk '/ctxt_switches/{s+=$2} END{print s}' /proc/*/status 2>/dev/null
/proc/schedstat
# CPU별 스케줄러 통계
cat /proc/schedstat
# cpu0 0 0 0 0 0 0 NR_SWITCHES NR_PREEMPT 0
# ^^^^^^^^^^^ ^^^^^^^^^^
# 총 전환 수 선점 전환 수
# 도메인별 상세 (NUMA 토폴로지)
cat /proc/schedstat | awk 'NF==28{
printf "CPU%s: switches=%s preempt=%s\n", $1, $8, $9
}'
eBPF/bpftrace로 Context Switch 분석
eBPF를 사용하면 Context Switch의 소스 프로세스, 대상 프로세스, 전환 지연을 커널 수정 없이 실시간으로 분석할 수 있습니다.
기본 트레이싱
# 초당 Context Switch 수 (프로세스별)
bpftrace -e '
tracepoint:sched:sched_switch {
@[args->prev_comm, args->next_comm] = count();
}
interval:s:5 {
print(@); clear(@);
}'
# Context Switch 지연 분포 (히스토그램)
bpftrace -e '
tracepoint:sched:sched_switch {
@start[args->next_pid] = nsecs;
}
tracepoint:sched:sched_wakeup {
if (@start[args->pid]) {
@latency_us = hist((nsecs - @start[args->pid]) / 1000);
delete(@start[args->pid]);
}
}'
# 특정 프로세스의 Context Switch 추적
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_comm == "myapp"/ {
printf("OUT: %s(%d) -> %s(%d)\n",
args->prev_comm, args->prev_pid,
args->next_comm, args->next_pid);
}'
고급 분석
# Context Switch 원인 분석 (스택 트레이스)
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_comm == "myapp"/ {
printf("prev_state=%d\n", args->prev_state);
print(kstack); /* 커널 스택 역추적 */
}'
# prev_state: 0=RUNNING(비자발적), 1=INTERRUPTIBLE(자발적), 2=UNINTERRUPTIBLE
# CR3 쓰기 빈도 측정 (KPTI 영향)
bpftrace -e '
kprobe:write_cr3 {
@[comm] = count();
}'
# FPU 저장 빈도 (#NM 예외)
bpftrace -e '
kprobe:do_device_not_available {
@[comm] = count();
printf("FPU restore: %s(%d)\n", comm, pid);
}'
Off-CPU 분석
# Off-CPU Time: 프로세스가 CPU를 떠나 있던 시간 분석
# (BCC 도구 사용)
/usr/share/bcc/tools/offcputime -p $(pgrep myapp) 10
# 결과: 어떤 커널 경로(잠금, I/O, 페이지 폴트)에서 오래 대기하는지 표시
# 예시 출력:
# finish_task_switch.isra.0
# schedule
# futex_wait_queue_me ← mutex 대기
# myapp_worker_thread
# Duration: 150000 µs (150ms)
성능 최적화
Context Switch 감소 전략
- CPU Affinity 설정 — 프로세스를 특정 CPU에 고정해 캐시 재사용률 향상
- 스레드 풀 크기 최적화 — CPU 코어 수와 동일하게 설정(I/O 바운드는 2~4배)
- 비동기 I/O 사용 — io_uring, epoll로 블로킹 줄이기
- 배치 처리 — 여러 요청을 묶어서 처리, Context Switch 횟수 감소
- 실시간 정책 — SCHED_FIFO/RR로 비자발적 Context Switch 제거
- NUMA 인식 — numactl로 메모리와 CPU를 같은 노드에 할당
/* CPU Affinity 설정 */
#include <sched.h>
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset); /* CPU 0에 고정 */
sched_setaffinity(0, sizeof(cpuset), &cpuset);
/* 실시간 우선순위 + CPU 고정 */
struct sched_param param;
param.sched_priority = 80; /* 1(최저) ~ 99(최고) */
sched_setscheduler(0, SCHED_FIFO, ¶m);
/* 커널 스레드 생성 (mm=NULL, 주소 공간 전환 없음) */
struct task_struct *kthread = kthread_create(worker_fn, data, "worker");
kthread_bind(kthread, cpu_id); /* CPU 고정 */
wake_up_process(kthread);
io_uring으로 Context Switch 감소
#include <liburing.h>
struct io_uring ring;
/* 256개 항목 링 버퍼 초기화 */
io_uring_queue_init(256, &ring, 0);
/* 배치 I/O 요청 (Context Switch 없음) */
for (int i = 0; i < 64; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], bufs[i], BUF_SIZE, 0);
sqe->user_data = i;
}
/* 한 번의 시스템 콜로 64개 I/O 제출 */
io_uring_submit(&ring);
/* 완료 이벤트 대기 (블로킹 없음) */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
/* 결과: Context Switch 64회 → 1회로 감소 */
커널 스레드 vs 유저 스레드 비교
| 항목 | 유저 프로세스 간 | 유저→커널 스레드 | 커널 스레드 간 |
|---|---|---|---|
| CR3 변경 | 예 (switch_mm) | 아니오 (active_mm 차용) | 아니오 |
| TLB 플러시 | 예 (PCID 없으면) | 아니오 | 아니오 |
| 상대적 비용 | 높음 | 중간 | 낮음 |
| 예시 | nginx worker 간 | app → kworker | kworker ↔ ksoftirqd |
실사용 사례
고빈도 서버 최적화 (Nginx)
# Nginx — worker 수를 CPU 수에 맞추고 CPU 고정
# /etc/nginx/nginx.conf
worker_processes auto; # CPU 수 자동 감지
worker_cpu_affinity auto; # CPU 고정 (각 worker를 별도 CPU에)
# 결과: Context Switch 30~50% 감소
# worker 간 캐시 공유 최소화 → 캐시 히트율 향상
데이터베이스 최적화 (PostgreSQL)
# PostgreSQL — max_connections을 CPU 수의 2~4배로 제한
# /etc/postgresql/14/main/postgresql.conf
max_connections = 200 # 너무 많으면 Context Switch 폭발
# pg_bouncer로 연결 풀링 (실제 백엔드 수 줄이기)
# 결과: 5000 연결 → 50 백엔드로 Context Switch 100배 감소
# 성능 측정
watch -n1 "psql -c \"SELECT sum(xact_commit) FROM pg_stat_bgwriter\""
실시간 오디오 처리
/* 오디오 처리 스레드: Context Switch 완전 차단 */
struct sched_param param;
param.sched_priority = 99; /* 최고 우선순위 */
sched_setscheduler(0, SCHED_FIFO, ¶m);
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(2, &cpuset); /* 오디오 전용 CPU */
sched_setaffinity(0, sizeof(cpuset), &cpuset);
/* 메모리 락: 페이지 폴트로 인한 Context Switch 방지 */
mlockall(MCL_CURRENT | MCL_FUTURE);
/* isolcpus=2 3 nohz_full=2 3 rcu_nocbs=2 3 ← 커널 파라미터
CPU 2,3을 완전히 격리: 틱 인터럽트, RCU 콜백 없음
결과: 비자발적 Context Switch = 0 */
__schedule() 함수 심층 분석
__schedule()는 Linux 스케줄러의 핵심 진입점으로, 모든 Context Switch의 시작점입니다. 이 함수는 선점(preempt), 자발적 양보(voluntary), 협력적(cooperative) 세 가지 경로에서 호출되며, 각 경로마다 다른 선점 조건과 비용 특성을 가집니다.
/* kernel/sched/core.c — __schedule() 핵심 구현 */
static void __sched notrace __schedule(unsigned int sched_mode)
{
struct task_struct *prev, *next;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
/* ── 선점 모드 분기 ── */
if (sched_mode == SM_PREEMPT) {
/* 비자발적 선점: prev->state를 변경하지 않음
* prev는 여전히 TASK_RUNNING → 런큐에 유지
* 선점된 태스크는 즉시 다시 실행 대상이 됨 */
} else {
/* 자발적 양보: prev->state가 이미 TASK_INTERRUPTIBLE 등으로 변경됨
* dequeue_task()로 런큐에서 제거 (대기 큐에서 깨어날 때까지) */
if (!signal_pending_state(prev->__state, prev)) {
prev->sched_contributes_to_load =
(prev->__state & TASK_UNINTERRUPTIBLE) &&
!(prev->flags & PF_FROZEN);
deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
}
}
/* ── 다음 태스크 선택 ── */
next = pick_next_task(rq, prev, &rf);
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
/* ── 실제 문맥 전환 수행 ── */
rq = context_switch(rq, prev, next, &rf);
} else {
/* 선택된 태스크가 현재 태스크와 동일 → 전환 불필요 */
rq_unpin_lock(rq, &rf);
__balance_callbacks(rq);
raw_spin_rq_unlock_irq(rq);
}
}
/* 세 가지 호출 경로 */
/* 1. 자발적: schedule() → __schedule(SM_NONE) */
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
__schedule(SM_NONE);
} while (need_resched());
sched_update_worker(tsk);
}
/* 2. 선점: preempt_schedule_irq() → __schedule(SM_PREEMPT) */
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
do {
preempt_disable();
local_irq_enable();
__schedule(SM_PREEMPT);
local_irq_disable();
sched_preempt_enable_no_resched();
} while (need_resched());
}
/* 3. 협력적: cond_resched() */
int __sched _cond_resched(void)
{
if (should_resched(0)) {
preempt_schedule_common(); /* → __schedule(SM_NONE) */
return 1;
}
return 0;
}
pick_next_task 최적화: CFS 태스크만 존재하는 일반적인 경우, pick_next_task()는 DL/RT 클래스를 건너뛰고 곧바로 pick_next_task_fair()를 호출합니다. 이 fast path 덕분에 대부분의 스케줄링 결정은 수십 나노초 내에 완료됩니다.
| 호출 경로 | SM 모드 | prev->state | 런큐 유지 | 발생 시점 |
|---|---|---|---|---|
| preempt_schedule_irq() | SM_PREEMPT | TASK_RUNNING (변경 없음) | 예 | IRQ/예외 복귀, preempt_enable() |
| schedule() | SM_NONE | INTERRUPTIBLE/UNINTERRUPTIBLE | 아니오 (dequeue) | sleep, wait_event, mutex_lock |
| cond_resched() | SM_NONE | TASK_RUNNING | 예 | 긴 루프 중간, 소프트 잠금 방지 |
| sched_yield() | SM_NONE | TASK_RUNNING | 예 (vruntime 조정) | 명시적 양보 (비권장) |
FPU/XSTATE 전환 심화
x86-64 아키텍처의 확장 레지스터 상태(XSTATE)는 x87 FPU부터 AVX-512까지 세대별로 누적되어 최대 2.5KB에 달합니다. Context Switch 시 이 상태의 저장/복원 전략은 성능에 직접적인 영향을 미칩니다. 커널 5.x 이후로는 보안(Spectre) 이유로 Eager 모드가 기본값이지만, 내부 최적화로 실제 비용은 상당히 절감되었습니다.
/* arch/x86/kernel/fpu/context.h — Eager FPU 전환 (현대 커널) */
/* Context Switch 시 호출: FPU 상태 저장 */
static inline void switch_fpu_prepare(struct fpu *old_fpu, int cpu)
{
if (static_cpu_has(X86_FEATURE_FPU)) {
/* Eager 모드: 즉시 XSAVE/XSAVEOPT로 현재 FPU 상태 저장 */
copy_fpregs_to_fpstate(old_fpu);
/* XSAVEOPT: 변경된 컴포넌트만 실제 저장 (성능 최적화) */
}
}
/* Context Switch 시 호출: FPU 상태 복원 */
static inline void switch_fpu_finish(struct fpu *new_fpu)
{
if (static_cpu_has(X86_FEATURE_FPU)) {
if (!fpregs_state_valid(new_fpu, smp_processor_id())) {
/* XRSTOR: fpstate에서 레지스터 복원 */
copy_kernel_to_fpregs(&new_fpu->fpstate->regs);
}
/* fpregs_state_valid(): 같은 CPU에서 마지막 사용자가 동일하면
* XRSTOR 생략 가능 → 수백 ns 절약 */
}
}
/* XSAVE 영역 구조체 */
struct fpu {
unsigned int last_cpu; /* 마지막 실행 CPU */
unsigned long avx512_timestamp; /* AVX-512 사용 시점 */
struct fpstate *fpstate; /* XSAVE 영역 포인터 */
};
struct fpstate {
unsigned int size; /* 실제 크기 (사용 컴포넌트 의존) */
unsigned int user_size; /* ptrace/sigframe용 크기 */
u64 xfeatures; /* 활성 컴포넌트 비트맵 */
u64 user_xfeatures; /* 유저 공간 허용 컴포넌트 */
union fpregs_state regs; /* 실제 XSAVE 데이터 */
};
/* AVX-512 상태 추적: 주파수 다운클럭 감지 */
void fpu__track_avx512(struct fpu *fpu)
{
/* AVX-512 사용 시점 기록 → 스케줄러가 코어 주파수 영향 판단 */
fpu->avx512_timestamp = jiffies;
/* Intel CPU: AVX-512 사용 후 ~670us 동안 주파수 다운클럭
* 같은 코어에서 다른 태스크의 성능에도 영향 */
}
AVX-512 주파수 다운클럭: Intel CPU에서 AVX-512 명령을 실행하면 코어 주파수가 최대 20% 감소합니다. 이 다운클럭은 ~670us 동안 지속되며, 같은 물리 코어의 다른 하이퍼스레드에도 영향을 줍니다. perf stat -e cpu/event=0xc6,umask=0x01/로 AVX-512 사용 빈도를 확인하고, clearcpuid=avx512f 커널 파라미터로 비활성화할 수 있습니다.
| XSTATE 컴포넌트 | CPUID 비트 | 크기 | XSAVEOPT 비용 | 비고 |
|---|---|---|---|---|
| x87 FPU (ST0-ST7) | bit 0 | 160B | ~20ns | 레거시, 항상 저장 |
| SSE (XMM0-XMM15) | bit 1 | 256B | ~40ns | MXCSR 포함 |
| AVX (YMM hi128) | bit 2 | 256B | ~50ns | 변경 시에만 저장 |
| MPX (bnd0-bnd3) | bit 3-4 | 128B | ~15ns | Linux 5.6에서 제거 |
| AVX-512 (ZMM hi256 + Opmask) | bit 5-7 | ~1,664B | ~200ns | 주파수 다운클럭 유발 |
| PKRU (보호키) | bit 9 | 8B | ~5ns | MPK 사용 시 |
| AMX TILE (tmm0-tmm7) | bit 17-18 | 8,192B | ~800ns | Intel Sapphire Rapids+ |
KPTI 페이지 테이블 전환 심화
KPTI(Kernel Page-Table Isolation)는 Meltdown 취약점 완화를 위해 유저 공간과 커널 공간에 각각 별도의 페이지 테이블(PGD)을 사용합니다. 유저 PGD에는 커널 주소 공간 대부분이 매핑되지 않으므로, Meltdown을 통한 커널 메모리 읽기가 불가능합니다. 이 이중 PGD 구조가 Context Switch와 시스템 콜에 미치는 영향을 분석합니다.
/* arch/x86/mm/pti.c — KPTI 이중 PGD 구조 */
/* 커널 PGD와 유저 PGD는 연속된 2페이지에 할당됨
* kernel_pgd: 물리 페이지 N
* user_pgd: 물리 페이지 N+1 (= kernel_pgd + PAGE_SIZE)
*/
static pgd_t *kernel_to_user_pgdp(pgd_t *pgdp)
{
return (pgd_t *)(((unsigned long)pgdp) | PTI_USER_PGD_OFFSET);
/* PTI_USER_PGD_OFFSET = PAGE_SIZE = 0x1000 */
}
/* 유저 PGD 초기화: 커널 매핑 대부분 제거 */
void pti_init(void)
{
/* 유저 PGD에 최소한의 커널 매핑만 복사:
* - entry_SYSCALL_64 (시스템 콜 진입점)
* - IDT (인터럽트 디스크립터 테이블)
* - TSS (Task State Segment)
* - per-cpu 영역의 cpu_entry_area
* 나머지 커널 주소는 매핑 없음 → Meltdown 차단 */
pti_clone_entry_text();
pti_clone_user_shared();
}
/* 시스템 콜 진입 시 CR3 전환 (어셈블리 매크로) */
/* arch/x86/entry/calling.h */
.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
ALTERNATIVE "", "jmp .Ldone_\@", X86_FEATURE_XENPV
mov %cr3, \scratch_reg
ALTERNATIVE "jmp .Lend_\@", "", X86_FEATURE_PTI
/* CR3의 bit 12 클리어 → 커널 PGD 사용 */
andq $~PTI_USER_PGTABLE_MASK, \scratch_reg
mov \scratch_reg, %cr3
.Lend_\@:
.endm
.macro SWITCH_TO_USER_CR3_STACK scratch_reg:req
mov %cr3, \scratch_reg
/* CR3의 bit 12 세트 → 유저 PGD 사용 */
orq $PTI_USER_PGTABLE_MASK, \scratch_reg
mov \scratch_reg, %cr3
.endm
PCID와 KPTI 시너지: KPTI 활성화 시 유저 PGD와 커널 PGD에 각각 별도의 PCID를 할당합니다 (예: 유저=PCID+1, 커널=PCID). CR3 쓰기 시 NOFLUSH 비트를 세트하면 TLB를 보존하므로, 시스템 콜 진입/복귀 시 TLB 플러시 없이 PGD를 전환할 수 있습니다. PCID가 없는 구형 CPU에서는 매 전환마다 TLB 전체 플러시가 발생해 성능 저하가 극심합니다.
TLB 플러시 전략 심화
TLB(Translation Lookaside Buffer) 관리는 Context Switch 성능의 핵심입니다. 커널은 상황에 따라 전체 플러시, 단일 페이지 무효화, 원격 CPU IPI 기반 무효화 등 여러 전략을 사용합니다. PCID와 INVPCID 명령의 조합으로 불필요한 TLB 플러시를 최소화합니다.
/* arch/x86/mm/tlb.c — TLB 플러시 핵심 구현 */
/* 원격 CPU TLB 플러시 (IPI 기반) */
void flush_tlb_mm_range(struct mm_struct *mm,
unsigned long start, unsigned long end,
unsigned int stride_shift, bool freed_tables)
{
struct flush_tlb_info info = {
.mm = mm,
.start = start,
.end = end,
.stride_shift = stride_shift,
.freed_tables = freed_tables,
};
/* 1. 로컬 CPU TLB 무효화 */
if (mm == current->active_mm)
flush_tlb_func_local(&info, TLB_LOCAL_MM_SHOOTDOWN);
/* 2. 이 mm을 사용 중인 다른 CPU들에 IPI 전송 */
if (cpumask_any_but(mm_cpumask(mm),
smp_processor_id()) < nr_cpu_ids) {
flush_tlb_multi(mm_cpumask(mm), &info);
}
}
/* IPI 수신 핸들러: 원격 CPU에서 실행 */
static void flush_tlb_func(void *info)
{
struct flush_tlb_info *f = info;
struct mm_struct *loaded_mm = this_cpu_read(cpu_tlbstate.loaded_mm);
/* Lazy TLB 모드: mm이 활성 상태가 아니면 플래그만 세트 */
if (loaded_mm != f->mm) {
/* 나중에 이 mm으로 복귀할 때 TLB 전체 플러시 */
this_cpu_write(cpu_tlbstate.is_lazy, 1);
return;
}
/* 활성 mm: 요청된 범위만 무효화 */
if (f->end == TLB_FLUSH_ALL) {
/* 전체 플러시: 범위가 너무 넓거나 freed_tables인 경우 */
local_flush_tlb();
} else {
/* 범위 플러시: INVLPG 또는 INVPCID type 0 사용 */
unsigned long addr;
for (addr = f->start; addr < f->end;
addr += 1UL << f->stride_shift) {
invlpg(addr);
}
}
}
/* 페이지 수에 따른 플러시 전략 결정 */
/* tlb_single_page_flush_ceiling (기본 33):
* 무효화할 페이지 수가 이 값 이하면 INVLPG 반복,
* 초과하면 전체 플러시가 더 효율적 */
static unsigned long tlb_single_page_flush_ceiling = 33;
| TLB 플러시 트리거 | 플러시 범위 | 비용 | IPI 필요 |
|---|---|---|---|
| Context Switch (switch_mm) | PCID: 없음 / 미지원: 전체 | 50ns / 500ns | 아니오 |
| munmap() / mprotect() | 변경된 VA 범위 | 10ns/페이지 | 예 (mm 활성 CPU들) |
| 페이지 마이그레이션 | 이동된 페이지 | 10ns/페이지 | 예 |
| 커널 모듈 로드/해제 | 전체 (글로벌) | ~500ns | 예 (모든 CPU) |
| KPTI 유저/커널 전환 | PCID: 없음 / 미지원: 전체 | 30ns / 500ns | 아니오 |
TLB shootdown 비용: IPI 기반 원격 TLB 무효화는 대상 CPU에 인터럽트를 발생시키므로, 코어 수가 많은 시스템에서 munmap()이 수십 µs까지 지연될 수 있습니다. perf stat -e tlb:tlb_flush로 빈도를 측정하고, 가능하면 madvise(MADV_FREE)로 즉시 해제 대신 지연 해제를 사용하세요.
ftrace/perf/bpftrace 문맥전환 분석 실전
문맥전환 병목을 진단할 때는 단순 횟수뿐 아니라, 어떤 프로세스가 어떤 이유로 전환되는지, 전환 지연(스케줄링 레이턴시)이 얼마인지, 캐시/TLB 영향은 얼마인지를 종합적으로 분석해야 합니다. 이 섹션은 ftrace sched_switch 이벤트, perf sched 레이턴시, bpftrace 고급 분석 기법을 실전 예제로 다룹니다.
ftrace sched_switch 이벤트
# ftrace로 sched_switch 이벤트 활성화
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
# 출력 형식 확인
cat /sys/kernel/debug/tracing/trace_pipe | head -20
# 출력 예시:
# nginx-1234 [002] d..2 12345.678901: sched_switch:
# prev_comm=nginx prev_pid=1234 prev_prio=120 prev_state=S ==>
# next_comm=kworker/2:1 next_pid=5678 next_prio=120
#
# 필드 해석:
# prev_state: R=Running(비자발적), S=Sleeping(자발적),
# D=Uninterruptible, T=Stopped, X=Dead
# 특정 프로세스만 필터링
echo 'prev_comm == "myapp" || next_comm == "myapp"' > \
/sys/kernel/debug/tracing/events/sched/sched_switch/filter
# 함수 그래프 트레이서로 schedule() 내부 경로 추적
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo __schedule > /sys/kernel/debug/tracing/set_graph_function
cat /sys/kernel/debug/tracing/trace
# 출력: __schedule 내부 호출 트리 + 소요 시간
# | 0.312 us | pick_next_task_fair();
# | 0.156 us | switch_mm_irqs_off();
# | 0.891 us | __switch_to_asm();
# | 0.234 us | finish_task_switch();
# 비활성화
echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo nop > /sys/kernel/debug/tracing/current_tracer
perf sched 심층 레이턴시 분석
# 10초간 스케줄링 이벤트 기록
perf sched record -- sleep 10
# 프로세스별 스케줄링 레이턴시 분석 (최대 지연순 정렬)
perf sched latency --sort max
# 출력 예시:
# Task | Runtime | Switches | Avg delay | Max delay |
# nginx:1234 | 2345.678ms | 5678 | 0.012ms | 2.345ms |
# postgres:5678 | 1234.567ms | 3456 | 0.045ms | 15.678ms |
#
# Max delay가 높은 프로세스: 스케줄링 지연 심각
# 시간순 전환 타임라인 (각 전환별 지연 표시)
perf sched timehist --summary
# 출력: CPU별 전환 타임라인
# 12345.001 [002] nginx:1234 0.003ms → kworker/2:1:5678
# 12345.002 [002] kworker:5678 0.001ms → nginx:1234
# wakeup 지연 분석 (깨어난 후 실제 실행까지)
perf sched timehist -w
# wakeup_time 컬럼: wake_up() → 실제 CPU 획득까지의 지연
# Context Switch와 캐시 미스 상관관계 분석
perf stat -e context-switches,cache-misses,dTLB-load-misses,\
dTLB-store-misses,L1-dcache-load-misses -p $(pgrep myapp) sleep 10
# context-switches가 높을수록 cache-misses도 비례해서 증가하면
# → Context Switch 감소가 최우선 최적화 목표
bpftrace 문맥전환 고급 분석
# 문맥전환 원인별 분류 (스택 트레이스 포함)
bpftrace -e '
tracepoint:sched:sched_switch {
/* prev_state별 분류 */
if (args->prev_state == 0) {
@preempted[args->prev_comm] = count();
} else if (args->prev_state == 1) {
@sleeping[args->prev_comm] = count();
} else if (args->prev_state == 2) {
@disk_wait[args->prev_comm] = count();
}
}
interval:s:5 {
printf("--- 비자발적 (선점됨) ---\n");
print(@preempted, 10);
printf("--- 자발적 (sleep) ---\n");
print(@sleeping, 10);
printf("--- I/O 대기 ---\n");
print(@disk_wait, 10);
clear(@preempted); clear(@sleeping); clear(@disk_wait);
}'
# CPU별 문맥전환 빈도 히트맵
bpftrace -e '
tracepoint:sched:sched_switch {
@[cpu] = count();
}
interval:s:3 { print(@); clear(@); }'
# 스케줄링 레이턴시 히스토그램 (깨어남 → 실행 시작)
bpftrace -e '
tracepoint:sched:sched_wakeup {
@wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$ts = @wake[args->next_pid];
if ($ts > 0) {
$lat_us = (nsecs - $ts) / 1000;
@latency_us = hist($lat_us);
if ($lat_us > 1000) {
printf("HIGH LAT: %s pid=%d lat=%d us\n",
args->next_comm, args->next_pid, $lat_us);
}
delete(@wake[args->next_pid]);
}
}
END { print(@latency_us); }'
# switch_mm 빈도 분석 (주소 공간 전환 비용)
bpftrace -e '
kprobe:switch_mm_irqs_off {
@switch_mm[comm] = count();
}
kprobe:enter_lazy_tlb {
@lazy_tlb[comm] = count();
}
interval:s:5 {
printf("=== switch_mm (full TLB cost) ===\n");
print(@switch_mm, 5);
printf("=== lazy_tlb (no TLB cost) ===\n");
print(@lazy_tlb, 5);
clear(@switch_mm); clear(@lazy_tlb);
}'
실전 진단 순서: ① vmstat 1로 cs 빈도 확인 → ② /proc/PID/status로 자발적/비자발적 비율 확인 → ③ perf sched latency로 최대 지연 프로세스 식별 → ④ bpftrace로 원인 분류(선점/sleep/I/O) → ⑤ perf stat로 캐시/TLB 영향 정량화. 이 순서로 병목을 좁혀가면 최적화 대상을 빠르게 특정할 수 있습니다.
문맥전환 비용 정량 분석
Context Switch의 실제 비용은 레지스터 저장/복원의 직접 비용보다 캐시/TLB 오염으로 인한 간접 비용이 훨씬 큽니다. 이 섹션은 두 비용을 정량적으로 분석하고, CPU 세대별/워크로드별 벤치마크 결과를 비교합니다.
# Context Switch 직접 비용 측정 (lmbench)
# 2개 프로세스, 0 바이트 파이프 전달
lat_ctx -s 0 2
# "size=0k ovr=0.53 2 1.23" → 1.23us/전환
# 프로세스 수 증가 시 비용 변화 (캐시 오염 증가)
for n in 2 4 8 16 32; do
echo "=== $n processes ==="
lat_ctx -s 0 $n
done
# 프로세스 수 증가 → 캐시 오염 심화 → 비용 급증
# 2: 1.2us, 4: 1.8us, 8: 3.5us, 16: 7.2us, 32: 15.1us
# perf로 직접/간접 비용 분리 측정
perf stat -e context-switches,\
cache-references,cache-misses,\
L1-dcache-loads,L1-dcache-load-misses,\
dTLB-loads,dTLB-load-misses,\
branch-instructions,branch-misses \
-- lat_ctx -s 0 8 2>/dev/null
# 결과 해석 예시:
# context-switches: 100,000
# cache-misses: 2,500,000 → 25/CS (캐시 라인 오염)
# dTLB-load-misses: 800,000 → 8/CS (TLB 미스)
# branch-misses: 300,000 → 3/CS (분기 예측 실패)
# KPTI 영향 정량화 (nopti 커널 파라미터로 비교)
# KPTI 활성: lat_ctx -s 0 2 → 1.5us
# KPTI 비활성: lat_ctx -s 0 2 → 1.2us (약 20% 차이)
| 비용 항목 | 크기 (ns) | 측정 방법 | 최적화 전략 |
|---|---|---|---|
| 레지스터 push/pop | ~100 | ftrace function_graph | 최적화 불가 (하드웨어 한계) |
| CR3 전환 | 50~500 | bpftrace kprobe:write_cr3 | PCID 활성화 (50ns으로 절감) |
| FPU XSAVE/XRSTOR | 200~800 | perf stat cycles (함수별) | AVX-512 격리, init state 활용 |
| L1d 캐시 콜드 | 5,000~50,000 | perf stat L1-dcache-load-misses | CPU 어피니티, 워킹셋 축소 |
| TLB Miss (PCID 없음) | 20,000~200,000 | perf stat dTLB-load-misses | PCID 활성화, Huge Pages |
| NUMA 원격 접근 | 40,000~100,000 | numastat, perf c2c | numactl --membind, CPU 어피니티 |
선점 모델 심화: 동적 전환과 cond_resched()
Linux 커널의 선점 모델은 처리량(throughput)과 응답 레이턴시(latency) 사이의 트레이드오프를 결정합니다. 커널 6.x에서 도입된 CONFIG_PREEMPT_DYNAMIC은 런타임에 선점 모델을 전환할 수 있게 해, 워크로드에 따른 동적 최적화가 가능합니다.
/* kernel/sched/core.c — cond_resched() 구현 상세 */
/* PREEMPT_NONE/VOLUNTARY에서 커널 내 선점 기회를 제공하는 핵심 함수 */
static __always_inline bool should_resched(int preempt_offset)
{
/* preempt_count가 offset과 같고, TIF_NEED_RESCHED가 세트되면 true */
return unlikely(preempt_count() == preempt_offset &&
tif_need_resched());
}
int __sched _cond_resched(void)
{
if (should_resched(0)) {
preempt_schedule_common();
return 1; /* 전환 발생 */
}
rcu_all_qs(); /* RCU quiescent state 보고 */
return 0; /* 전환 불필요 */
}
/* might_sleep() — cond_resched() + 디버그 검사 */
void __might_sleep(const char *file, int line)
{
/* CONFIG_DEBUG_ATOMIC_SLEEP: 원자 컨텍스트에서 호출 시 경고 */
if (preempt_count() || irqs_disabled()) {
WARN_ONCE(1, "BUG: sleeping function called from invalid context");
}
_cond_resched();
}
/* ── CONFIG_PREEMPT_DYNAMIC: 런타임 선점 모델 전환 ── */
/* static_call로 구현: 조건문 오버헤드 0 */
DEFINE_STATIC_CALL(cond_resched, __cond_resched);
DEFINE_STATIC_CALL(might_resched, __cond_resched);
DEFINE_STATIC_CALL(preempt_schedule, __preempt_schedule);
/* none → cond_resched = nop (아무것도 안 함)
* voluntary → cond_resched = __cond_resched (체크 + 양보)
* full → preempt_schedule = __preempt_schedule (항상 선점) */
static int sched_dynamic_mode(const char *str)
{
if (!strcmp(str, "none")) {
static_call_update(cond_resched, __static_call_return0);
static_call_update(preempt_schedule, NULL);
} else if (!strcmp(str, "voluntary")) {
static_call_update(cond_resched, __cond_resched);
static_call_update(preempt_schedule, NULL);
} else if (!strcmp(str, "full")) {
static_call_update(cond_resched, __cond_resched);
static_call_update(preempt_schedule, __preempt_schedule);
}
return 0;
}
| 선점 모델 | CONFIG 이름 | 커널 내 선점 | cond_resched() | 최대 레이턴시 | 처리량 오버헤드 | 대표 용도 |
|---|---|---|---|---|---|---|
| PREEMPT_NONE | CONFIG_PREEMPT_NONE | 불가 | nop (무시) | ~100ms | 0% | 서버, DB, HPC |
| PREEMPT_VOLUNTARY | CONFIG_PREEMPT_VOLUNTARY | 명시적 포인트만 | 활성 (체크+양보) | ~10ms | ~1% | 데스크톱, 범용 |
| PREEMPT (FULL) | CONFIG_PREEMPT | 스핀락/IRQ외 가능 | 활성 | ~1ms | ~3~5% | 게임, 오디오, UI |
| PREEMPT_RT | CONFIG_PREEMPT_RT | 거의 모든 곳 | 활성 | ~50us | ~10~15% | 산업제어, 로봇 |
# 현재 선점 모델 확인 (3가지 방법)
zcat /proc/config.gz | grep -E "^CONFIG_PREEMPT"
uname -v # 예: #1 SMP PREEMPT_DYNAMIC ...
cat /sys/kernel/debug/sched/preempt # 런타임 상태
# CONFIG_PREEMPT_DYNAMIC 런타임 전환
# 서버 모드 (처리량 우선)
echo none > /sys/kernel/debug/sched/preempt
# 데스크톱 모드 (균형)
echo voluntary > /sys/kernel/debug/sched/preempt
# 응답성 모드 (레이턴시 우선)
echo full > /sys/kernel/debug/sched/preempt
# 선점 모델별 Context Switch 빈도 비교 테스트
for mode in none voluntary full; do
echo $mode > /sys/kernel/debug/sched/preempt
echo "=== $mode ==="
perf stat -e context-switches -- stress-ng --cpu 4 --timeout 5s 2>&1 | \
grep context-switches
done
# 예상 결과:
# none: ~2,000/s (최소 전환)
# voluntary: ~5,000/s (cond_resched 포인트에서 추가)
# full: ~15,000/s (커널 내 선점으로 대폭 증가)
# cyclictest로 최악 레이턴시 측정
cyclictest -t 4 -p 80 -n -l 100000
# T:0 Min: 1 Act: 5 Avg: 3 Max: 15 (PREEMPT_FULL)
# T:0 Min: 1 Act: 5 Avg: 3 Max: 2345 (PREEMPT_NONE)
PREEMPT_NONE 주의사항: PREEMPT_NONE에서는 커널 코드 실행 중 선점이 불가능하므로, 긴 커널 경로(대용량 파일 삭제, 대규모 메모리 해제 등)가 수십~수백 ms 동안 CPU를 독점할 수 있습니다. 서버에서도 cond_resched()가 삽입되지 않은 커널 경로에서 예상치 못한 레이턴시 스파이크가 발생할 수 있으므로, CONFIG_PREEMPT_VOLUNTARY이 안전한 기본값입니다.