컨텍스트 스위칭 (Context Switching)
Linux 커널의 Context Switching은 CPU 실행 주체를 전환하는 핵심 경로입니다. 이 문서는 schedule() 호출 시점부터 __switch_to 복귀까지 레지스터(Register)/커널 스택/메모리 컨텍스트 전환 단계를 해부하고, TLB/PCID 비용, FPU 상태 관리, KPTI 영향, ARM64 차이, perf·ftrace·eBPF 기반 병목(Bottleneck) 진단 절차까지 종합적으로 다룹니다.
핵심 요약
- 레지스터 전환 — CPU의 범용 레지스터(RAX~R15), 명령 포인터(RIP), 플래그(RFLAGS)를 task_struct의 thread 구조체(Struct)에 저장·복원합니다.
- 커널 스택 전환 — 모든 프로세스는 독립적인 커널 스택(8KB 또는 16KB)을 가지며, RSP/RBP 레지스터를 교체하여 전환합니다.
- 주소 공간(Address Space) 전환 — CR3 레지스터(페이지 테이블 베이스)를 변경해 가상 주소 공간을 교체합니다. 커널 스레드(Kernel Thread)는 이 단계를 건너뜁니다.
- TLB 관리 — PCID(Process Context Identifier)를 사용하면 주소 공간을 바꿔도 TLB를 비우지 않아 성능이 크게 향상됩니다.
- Lazy FPU — FPU/SIMD 레지스터(최대 2KB)는 실제 사용 시점까지 저장을 지연(Latency)해 불필요한 비용을 줄입니다.
- KPTI 영향 — Meltdown 취약점(Vulnerability) 완화(KPTI)로 커널/유저 공간 전환 시 CR3를 두 번 교체하는 추가 비용이 발생합니다.
- 직접 비용 vs 간접 비용 — 레지스터 저장·복원의 직접 비용(1~2µs)보다 캐시(Cache) 오염·TLB 미스로 인한 간접 비용이 훨씬 큽니다.
- eBPF 트레이싱 —
bpftrace와sched:sched_switch트레이스포인트로 Context Switch를 실시간(Real-time) 분석할 수 있습니다. - 선점(Preemption) 모델 — 커널은 PREEMPT_NONE(서버 기본), PREEMPT_VOLUNTARY, PREEMPT_FULL, PREEMPT_RT 4가지 모델을 지원합니다. 선점 모델이 Context Switch 빈도와 레이턴시를 결정합니다.
- CPU 마이그레이션 — 부하 분산기(Load Balancer)가 태스크(Task)를 다른 CPU로 이동하면 캐시·TLB가 모두 콜드 상태가 되어 성능 비용이 큽니다. CPU 어피니티로 마이그레이션을 제한할 수 있습니다.
- 새 태스크 첫 실행 — fork()된 새 프로세스는 switch_to()로 복원할 레지스터가 없어, copy_thread()가 ret_from_fork 주소를 미리 스택에 배치하여 첫 실행 경로를 구성합니다.
단계별 이해
- 트리거 이해
Context Switch가 언제 발생하는지 파악합니다: 타임 슬라이스 만료(HZ 틱), 블로킹 시스템 콜(System Call), 우선순위(Priority) 선점, 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매크로(Macro)가 callee-saved 레지스터(RBX, RBP, R12~R15)를 커널 스택에 push/pop합니다.__switch_to()는 TLS, I/O 비트맵(Bitmap), 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())
- 명시적 양보(Yield) — sched_yield(), cond_resched()
- 인터럽트 후 선점 — 인터럽트 핸들러(Handler) 종료 후 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() 함수 심층 분석
어셈블리(Assembly) 래퍼: __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)
코드 설명
- 1행
arch/x86/entry/entry_64.S: x86-64 커널 진입/종료 어셈블리 파일.__switch_to_asm은 이 파일에서SYM_FUNC_START매크로로 정의된 공개 커널 심볼입니다. - 5~10행callee-saved 레지스터 6개(RBP, RBX, R12~R15)를 현재 프로세스(prev)의 커널 스택에 순서대로
pushq합니다. x86-64 System V ABI에 따라 이 6개 레지스터는 함수 호출 전후로 값이 보존되어야 하므로__switch_to_asm이 직접 저장합니다. - 12행
movq %rsp, TASK_threadsp(%rdi):%rdi는 첫 번째 인자prev태스크 포인터입니다.TASK_threadsp는task_struct내thread.sp필드의 컴파일 타임 오프셋 상수입니다. 현재 스택 포인터(RSP)를 prev의thread.sp에 저장하여, 나중에 이 프로세스로 돌아올 때 정확한 스택 위치를 복원할 수 있게 합니다. - 15행
movq TASK_threadsp(%rsi), %rsp:%rsi는 두 번째 인자next태스크 포인터입니다. next의thread.sp를 RSP에 로드하는 순간 CPU는 next 프로세스의 커널 스택으로 전환됩니다. 이 한 줄이 실질적인 스택 전환입니다. - 18~23행next 프로세스의 스택에서 callee-saved 레지스터 6개를 pop합니다. next가 이전에
__switch_to_asm을 통해 스왑 아웃될 때 push한 순서의 역순으로 복원되므로, next 프로세스의 레지스터 상태가 정확히 복원됩니다. - 26행
jmp __switch_to: call이 아닌 jmp를 사용합니다. 반환 주소(RIP)는 이미 next 스택의 최상단에 있으므로,__switch_to()가return prev로 끝나면 자동으로 next 프로세스가 이전에 중단된 지점(context_switch 호출 직후)으로 돌아갑니다.
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);
}
코드 설명
- 1행
kernel/sched/core.c: 스케줄러 핵심 파일.context_switch()는__schedule()내부에서prev != next가 확인된 뒤 호출됩니다. - 2~3행
static __always_inline struct rq *: 인라인 강제 지시자와 함께 런큐(runqueue) 포인터를 반환합니다.prev는 전환 전 현재 태스크,next는 전환 후 실행될 태스크입니다. - 6~9행커널 스레드 분기(
!next->mm):mm이 NULL인 커널 스레드는 독립적인 주소 공간이 없습니다. 이전 프로세스의active_mm을 차용(borrow)하고mmgrab()으로 참조 카운트를 올립니다.enter_lazy_tlb()는 CR3를 교체하지 않아도 된다는 힌트를 TLB 서브시스템에 전달합니다. - 10~13행유저 프로세스 분기:
switch_mm_irqs_off()가 CR3 레지스터를 next의 페이지 테이블 베이스 주소로 교체합니다.lru_gen_use_mm()은 MGLRU(Multi-Gen LRU) 서브시스템에 이 mm이 활성화됐음을 통보합니다. - 15행
rq->curr = next: 런큐의 현재 실행 태스크 포인터를 next로 업데이트합니다. 이 시점부터 스케줄러는 next를 현재 CPU의 실행 태스크로 인식합니다. - 16~17행
switch_to(prev, next, prev)는 매크로로, 내부에서__switch_to_asm(prev, next)를 호출합니다. 세 번째 인자는prev를 갱신하는 용도(다중 CPU 환경에서 실제 prev가 바뀔 수 있음)입니다.barrier()는 컴파일러 메모리 재정렬 방지 장벽입니다. - 19행
finish_task_switch(prev): 전환이 완료된 뒤 next의 컨텍스트에서 실행됩니다. prev 태스크의 참조 해제,TIF_NOTIFY_RESUME처리, 런큐 잠금 해제를 수행하고 런큐 포인터를 반환합니다.
호출 체인 소스 분석: schedule() → context_switch()
실제 커널 소스에서 schedule()부터 context_switch()까지 이어지는 전체 호출 체인을 추적합니다. 각 계층이 어떤 역할을 분담하는지 확인하세요.
context_switch() 함수 소스 분석
context_switch()는 주소 공간 전환과 레지스터/스택 전환을 오케스트레이션하는 핵심 함수입니다. 커널 스레드(Kernel Thread)의 mm=NULL 처리, active_mm 차용(Borrowing), membarrier 동기화까지 모두 이 함수에서 수행됩니다.
/* kernel/sched/core.c — context_switch() 핵심 구현 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
/* ── Phase 1: 주소 공간 전환 준비 ── */
struct mm_struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
/*
* membarrier 시스템 콜 지원: 다른 CPU에서 실행 중인
* 스레드에 메모리 장벽을 강제하기 위해 컨텍스트 스위치
* 시점에 rq->membarrier_state를 갱신합니다.
*/
prepare_task_switch(rq, prev, next);
/* ── Phase 2: 주소 공간 전환 (mm 기반 분기) ── */
if (!mm) {
/*
* 커널 스레드(Kernel Thread): mm == NULL
* 자신만의 주소 공간이 없으므로 이전 프로세스의
* active_mm을 "차용(Borrow)"합니다.
* CR3를 변경하지 않아 TLB 플러시 없음 → 매우 저렴
*/
next->active_mm = oldmm;
mmgrab_lazy_tlb(oldmm);
enter_lazy_tlb(oldmm, next);
} else {
/*
* 유저 프로세스: mm != NULL
* CR3를 새 프로세스의 pgd로 교체해 주소 공간 전환
* PCID 지원 시 NOFLUSH 비트로 TLB 보존
*/
membarrier_switch_mm(rq, oldmm, mm);
switch_mm_irqs_off(oldmm, mm, next);
lru_gen_use_mm(mm);
if (!prev->mm) {
/* prev가 커널 스레드였으면 차용한 mm 반환 */
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
}
/* ── Phase 3: 다음 태스크를 현재 태스크로 설정 ── */
prepare_lock_switch(rq, next, rf);
/* ── Phase 4: 레지스터/스택 전환 — 여기서 CPU 제어권이 넘어감 ── */
switch_to(prev, next, prev);
/*
* ── Phase 5: 후처리 (새 프로세스의 관점에서 실행) ──
* switch_to() 이후의 코드는 "next" 프로세스가 실행합니다.
* prev 변수는 switch_to 매크로의 3번째 인자로 갱신되어
* "이전에 실행 중이던 진짜 프로세스"를 가리킵니다.
*/
return finish_task_switch(prev);
}
코드 설명
- mm vs active_mm
mm은 유저 공간 주소 공간을 나타냅니다. 커널 스레드는mm=NULL이지만 커널 공간 접근을 위해active_mm을 통해 이전 프로세스의 페이지 테이블을 빌려 사용합니다. 커널 주소 공간 매핑은 모든 mm에서 동일하기 때문에 이것이 안전합니다. - mmgrab_lazy_tlb()차용하는 mm의 참조 카운트(
mm_count)를 증가시킵니다. 커널 스레드가 active_mm으로 보유하는 동안 mm이 해제되지 않도록 보장합니다. 이는mm_users가 아닌mm_count를 증가시켜, 유저 프로세스가 종료되어도 mm 구조체 자체는 유지됩니다. - enter_lazy_tlb()Lazy TLB 모드에 진입합니다. 원격 TLB shootdown IPI를 수신해도 TLB를 즉시 무효화하지 않고 플래그만 세트합니다. 다음에 유저 프로세스로 전환될 때 한꺼번에 처리합니다.
- switch_to 3번째 인자
switch_to(prev, next, prev)에서 3번째prev는 출력 인자입니다. 스택 전환 후 새 프로세스의 관점에서 "진짜 이전 프로세스"를 알 수 있게 합니다. A→B→C 체인에서 C가 깨어났을 때, prev는 B(직전 실행 태스크)를 가리킵니다. - rq->prev_mm이전 프로세스가 커널 스레드였고 active_mm을 차용 중이었다면, 차용한 mm에 대한
mmdrop_lazy_tlb()를finish_task_switch()에서 처리합니다. 락(Lock)을 들고 있는 동안mmdrop()을 호출하면 슬립(Sleep)할 수 있으므로 지연시킵니다.
finish_task_switch() 후처리 분석
switch_to()로 CPU 제어권이 넘어간 후, 새로 실행되는 프로세스가 이전 프로세스의 후처리를 담당합니다. 이 함수는 참조 카운트 해제, 좀비(Zombie) 프로세스 회수, mm 구조체 반환 등 중요한 정리 작업을 수행합니다.
/* kernel/sched/core.c — finish_task_switch() */
static struct rq *finish_task_switch(struct task_struct *prev)
__releases(rq->lock)
{
struct rq *rq = this_rq();
struct mm_struct *mm = rq->prev_mm;
unsigned int prev_state;
rq->prev_mm = NULL;
/*
* 이전 태스크의 on_cpu를 해제하기 전에 모든 스토어(Store)가
* 완료되었음을 보장합니다. smp_store_release는 메모리 장벽을
* 포함하여 다른 CPU가 올바른 순서로 변경사항을 관찰합니다.
*/
smp_store_release(&prev->on_cpu, 0);
prev_state = READ_ONCE(prev->__state);
/* 차용한 mm 반환 (커널 스레드가 active_mm을 빌려쓴 경우) */
if (mm) {
mmdrop_lazy_tlb(mm);
}
/* 좀비 프로세스 회수: exit()가 완료된 태스크 정리 */
if (unlikely(prev_state == TASK_DEAD)) {
/* perf 이벤트 정리 */
perf_event_task_sched_in(prev, current);
/* task_struct 해제: 참조 카운트 감소 → 최종 해제 */
put_task_struct_rcu_user(prev);
}
/* 스케줄러 통계 업데이트 및 부하 분산 콜백 */
tick_nohz_task_switch();
finish_lock_switch(rq);
fire_sched_in_preempt_notifiers(current);
balance_callback(rq);
return rq;
}
코드 설명
- smp_store_release(&prev->on_cpu, 0)이전 태스크의
on_cpu필드를 0으로 해제합니다.smp_store_release는 이전의 모든 메모리 쓰기가 완료된 후에 이 값이 보이도록 메모리 장벽을 포함합니다. 다른 CPU의wait_task_inactive()나task_rq_lock()이 이 필드를 확인하여 프로세스의 상태를 안전하게 조작할 수 있습니다. - mmdrop_lazy_tlb(mm)이전 프로세스가 커널 스레드였으면 차용한 mm의 참조 카운트를 감소시킵니다. 이 작업을 context_switch() 내부가 아닌 여기서 수행하는 이유는, context_switch()가 rq 락을 보유한 상태에서 mmdrop()을 호출하면 슬리퍼블(sleepable) 컨텍스트에서 문제가 될 수 있기 때문입니다.
- TASK_DEAD 처리
do_exit()로 종료된 프로세스는TASK_DEAD상태입니다. 자기 자신의 task_struct를 해제할 수 없으므로(실행 중이니까), 다음에 같은 CPU에서 실행되는 프로세스가 이 정리 작업을 대신합니다. 이것이 "context switch가 좀비를 회수한다"는 의미입니다. - balance_callback()rq 락이 해제된 후 실행되는 부하 분산 콜백입니다. 마이그레이션 요청, IPI 기반 태스크 이동 등이 이 시점에 처리됩니다. 락 내부에서 실행할 수 없는 작업들을 지연 실행합니다.
active_mm 차용(Borrowing) 메커니즘
커널 스레드(Kernel Thread)는 유저 공간 매핑이 없으므로 task_struct.mm = NULL입니다. 하지만 커널 공간 접근을 위한 페이지 테이블이 필요하므로, 이전에 실행 중이던 유저 프로세스의 mm_struct를 active_mm으로 차용합니다. 커널 주소 공간 매핑은 모든 프로세스에서 동일하므로(상위 256TB, PGD 상반부) 이것이 안전합니다.
성능 함의: 커널 스레드(kworker, ksoftirqd 등) 간 Context Switch는 주소 공간 전환이 없어 매우 빠릅니다(~300ns). 반면 유저 프로세스 간 전환은 CR3 교체, TLB 관리, PCID 할당 등의 비용이 추가됩니다. kthread_create()로 생성한 워커 스레드가 io_uring이나 workqueue를 통해 커널 내부에서 작업을 처리하면, 유저/커널 경계를 넘지 않으므로 Context Switch 비용이 절감됩니다.
thread_struct 구조체 필드 분석
thread_struct는 task_struct 내부에 임베드된 아키텍처 의존 구조체로, Context Switch 시 저장·복원이 필요한 CPU 상태를 보관합니다. x86-64에서는 arch/x86/include/asm/processor.h에 정의됩니다.
/* arch/x86/include/asm/processor.h — thread_struct (x86-64 핵심 필드) */
struct thread_struct {
/* ─── 레지스터/스택 전환 필드 ─── */
unsigned long sp; /* 커널 스택 포인터 — __switch_to_asm이 저장·복원 */
unsigned long sp0; /* Ring-0 스택 최상단 — TSS에 기록해 인터럽트/시스콜용 */
/* ─── TLS (Thread-Local Storage) ─── */
struct desc_struct tls_array[GDT_ENTRY_TLS_ENTRIES]; /* set_thread_area() 용 GDT 슬롯 */
unsigned long fsbase; /* FS.base — glibc TLS 포인터 (MSR_FS_BASE) */
unsigned long gsbase; /* GS.base — per-CPU 포인터 (MSR_KERNEL_GS_BASE) */
/* ─── FPU / XSTATE ─── */
struct fpu fpu; /* FPU 상태 컨테이너 — xsave_area, 초기화 플래그 포함 */
/* ─── 디버그 레지스터 ─── */
unsigned long debugreg[8]; /* DR0~DR7 — ptrace 하드웨어 브레이크포인트 */
unsigned long debugreg_active; /* DR 활성 여부 플래그 — unlikely() 최적화 경로 */
/* ─── I/O 권한 비트맵 ─── */
struct io_bitmap *io_bitmap; /* ioperm() 사용 시 할당 — NULL이면 비용 없음 */
/* ─── 오류 주소 (시그널/폴트 처리) ─── */
unsigned long cr2; /* 최근 페이지 폴트(Page Fault) 주소 — #PF 핸들러가 기록 */
unsigned long trap_nr; /* 최근 예외 번호 — die() 경로에서 사용 */
unsigned long error_code; /* 최근 예외 에러 코드 */
};
코드 설명
- sp 필드커널 스택 포인터 저장 위치입니다.
__switch_to_asm의movq %rsp, TASK_threadsp(%rdi)가 이 필드에 현재 RSP를 저장하고,movq TASK_threadsp(%rsi), %rsp로 복원합니다. Context Switch의 핵심 필드입니다. - sp0 필드Ring-0(커널 모드) 스택의 최상단 주소입니다.
__switch_to()내부에서this_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next))를 통해 TSS(Task State Segment)의sp0필드에 기록됩니다. 유저 모드에서 시스템 콜이나 인터럽트가 발생할 때 CPU가 이 값을 RSP로 로드합니다. - fsbase / gsbase 필드x86-64 TLS(Thread-Local Storage) 구현에 사용됩니다.
fsbase는 glibc의__thread변수 포인터(pthread 구조체 주소),gsbase는 커널 per-CPU 영역 포인터입니다.load_TLS()와 WRMSRL 명령으로 MSR을 업데이트합니다. - fpu 필드FPU/SIMD 상태 컨테이너입니다. Lazy FPU 방식에서는 Context Switch 시 즉시 저장하지 않고 CR0.TS를 세트하여 다음 FPU 명령에서 #NM 예외가 발생할 때 저장합니다. XSAVE 영역, 초기화 플래그, 퍼미션 비트를 포함합니다.
- debugreg 필드하드웨어 브레이크포인트 레지스터 DR0~DR7의 소프트웨어 복사본입니다.
debugreg_active가 0이면unlikely()힌트로 전환 비용이 없습니다. ptrace나 perf 하드웨어 브레이크포인트 사용 시에만 비용이 발생합니다. - io_bitmap 필드
ioperm()시스템 콜로 I/O 포트 직접 접근 권한을 부여받은 프로세스만 이 포인터가 non-NULL입니다.tss_update_io_bitmap()은 TSS의 I/O 비트맵 오프셋을 업데이트합니다. 일반 프로세스는 NULL이므로unlikely()경로입니다.
inactive_task_frame 구조체: 커널 스택의 스냅샷
inactive_task_frame은 프로세스가 스왑 아웃될 때 커널 스택 최상단에 저장되는 레지스터 프레임입니다. __switch_to_asm의 push 명령들이 이 구조체와 정확히 대응됩니다.
/* arch/x86/include/asm/switch_to.h */
struct inactive_task_frame {
#ifdef CONFIG_X86_32
unsigned long flags; /* x86-32 전용: EFLAGS 저장 */
unsigned long si; /* x86-32 전용: ESI */
unsigned long di; /* x86-32 전용: EDI */
#endif
unsigned long r15; /* callee-saved #6: pushq %r15 순서 역순 */
unsigned long r14; /* callee-saved #5 */
unsigned long r13; /* callee-saved #4 */
unsigned long r12; /* callee-saved #3 */
unsigned long bx; /* callee-saved #2: RBX */
/*
* ret_addr: 스택 전환 후 실행을 재개할 주소.
* 일반 프로세스: context_switch() 복귀 주소 (__schedule 내부)
* 새 fork 프로세스: ret_from_fork — 첫 실행 진입점
*/
unsigned long ret_addr; /* 재개 주소 (RIP 역할) */
unsigned long bp; /* callee-saved #1: RBP (frame pointer) */
};
코드 설명
- 구조체 배치 원리스택은 높은 주소에서 낮은 주소 방향으로 자랍니다.
__switch_to_asm이pushq %rbp부터pushq %r15까지 순서대로 push하면 메모리 레이아웃은 구조체 선언의 역순이 됩니다. 이 구조체는 그 역순을 보정해 C 코드에서 필드 이름으로 직접 접근할 수 있게 합니다. - r15~bx 필드x86-64 System V ABI의 callee-saved 레지스터 6개입니다. 함수 호출 규약상 이 레지스터들은 호출된 함수(callee)가 보존해야 합니다.
__switch_to_asm은call이 아닌 어셈블리 경계에서 스택을 교체하므로 반드시 직접 저장·복원해야 합니다. - ret_addr 필드가장 중요한 필드입니다.
__switch_to()가ret명령을 실행하면 이 주소로 점프합니다. 일반 프로세스는 이전에__switch_to_asm을 호출한context_switch()내부 주소이고,fork()로 갓 생성된 프로세스는copy_thread()가ret_from_fork주소를 미리 채워둡니다. - bp 필드RBP(frame pointer)는 스택 프레임 체인의 연결 포인터입니다.
CONFIG_FRAME_POINTER가 활성화된 커널에서bp필드를 통해 스택 언와인딩(unwinding)이 가능합니다. perf/ftrace의 콜 스택 추적이 이 체인에 의존합니다. - x86-32 추가 필드x86-32에서는
flags(EFLAGS),si(ESI),di(EDI)를 추가로 저장합니다. x86-64는 ABI상 이 레지스터들이 caller-saved이므로 저장하지 않습니다.
switch_mm_irqs_off() 소스 분석
주소 공간 전환의 핵심 함수입니다. PCID(Process Context Identifier) 지원 여부에 따라 TLB 플러시 비용이 크게 달라집니다.
/* arch/x86/mm/tlb.c — switch_mm_irqs_off() 핵심 경로 */
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
struct tlb_context *next_tlb;
unsigned long new_cr3;
u16 new_asid;
/* 같은 mm이면 CR3 재기록 불필요 (커널 스레드 → 유저 복귀 등) */
if (prev == next) {
load_new_mm_cr3(next->pgd, 0, false);
return;
}
/* PCID(ASID) 할당: CPU 별 TLB 슬롯 번호 결정 */
new_asid = choose_new_asid(next, next->context.ctx_id,
&next_tlb, &need_flush);
/* CR3 값 구성: 페이지 디렉터리 물리 주소 + PCID 필드 */
new_cr3 = build_cr3(next->pgd, new_asid);
if (need_flush) {
/* TLB 전체 플러시: PCID 미지원이거나 슬롯 재사용 */
load_new_mm_cr3(next->pgd, new_asid, true);
} else {
/* NOFLUSH: CR3_PCID_NOFLUSH 비트 세트 → TLB 보존 */
new_cr3 |= X86_CR3_PCID_NOFLUSH;
write_cr3(new_cr3);
}
/* next CPU에서 이 mm의 최신 tlb_gen 기록 (원격 TLB 플러시 추적용) */
next_tlb->ctx_id = next->context.ctx_id;
next_tlb->tlb_gen = atomic64_read(&next->context.tlb_gen);
}
코드 설명
- 함수 시그니처
irqs_off접미사는 이 함수가 IRQ가 비활성화된 상태에서만 호출되어야 함을 명시합니다. 실제로context_switch()는__schedule()→local_irq_disable()이후에 호출되므로 이 불변 조건이 항상 보장됩니다. - prev == next 분기커널 스레드가 유저 프로세스로 전환되는 경우처럼
active_mm이 같을 때 발생합니다. 이 경우에도load_new_mm_cr3()를 호출하는 이유는 KPTI 활성화 시 커널/유저 CR3 쌍을 정확히 유지해야 하기 때문입니다. - choose_new_asid()CPU별로 최대 12비트(4096개) PCID 슬롯 중 하나를 next mm에 할당합니다. 이미 이 CPU에서 사용된 적 있는 mm이면 동일한 ASID를 재사용하고(
need_flush = false), 신규 또는 TLB 세대가 바뀐 경우 플러시가 필요합니다. - build_cr3()CR3 레지스터에 기록할 64비트 값을 생성합니다. 상위 비트는 페이지 글로벌 디렉터리(PGD)의 물리 주소, 하위 12비트 중 [11:0]은 PCID 값입니다. x86-64에서 CR3[63]이 CR3_PCID_NOFLUSH 비트입니다.
- X86_CR3_PCID_NOFLUSH 분기PCID 지원 CPU에서 TLB 플러시가 불필요한 경우, CR3에 이 비트를 세트하고
write_cr3()를 호출합니다. CPU는 이 비트를 보고 CR3 교체 시 TLB를 비우지 않습니다. 이 경로가 Context Switch 비용을 대폭 줄이는 핵심 최적화입니다. - next_tlb 갱신
ctx_id와tlb_gen은 원격 TLB 플러시(IPI 기반) 추적에 사용됩니다. 다른 CPU가 이 mm의 페이지 테이블을 수정해tlb_gen을 증가시키면, 다음 번 이 CPU에서 해당 mm으로 전환될 때choose_new_asid()가need_flush = true를 반환합니다.
커널 스택 구조와 전환
각 프로세스(스레드)는 독립적인 커널 스택을 가집니다. x86-64에서 기본 크기는 16KB(4페이지(Page))이며, 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 상태 */
/* ... */
};
코드 설명
커널 스택의 메모리 레이아웃과 Context Switch에서 사용되는 핵심 자료구조입니다.
- struct thread_info스택 최하단(또는 v4.9+에서는
task_struct내부)에 위치하며,TIF_NEED_RESCHED등 스케줄링 플래그를 담고 있습니다.flags필드는 인터럽트 복귀 경로에서 매번 검사되어 선점 여부를 결정합니다. - task_top_of_stack()
task->stack + THREAD_SIZE(기본 16KB)로 스택 최상단 주소를 계산합니다. 이 값은TSS.sp0에 기록되어, 유저 모드에서 시스템 콜/인터럽트 진입 시 CPU가 자동으로 커널 스택으로 전환하는 데 사용됩니다. - thread_struct.sp
switch_to()에서 교환되는 커널 스택 포인터입니다.__switch_to_asm이prev->thread.sp에 현재 RSP를 저장하고,next->thread.sp에서 RSP를 복원하여 스택 전환을 완료합니다. - thread_struct.fs/gsx86-64에서 FS는 유저 공간 TLS(Thread Local Storage), GS는 커널 per-CPU 데이터 접근에 사용됩니다.
__switch_to()에서 MSR 쓰기를 통해 교환됩니다. - thread_struct.fpuFPU/SIMD 상태(XSAVE 영역)를 가리킵니다. Context Switch 시
switch_fpu_prepare()/switch_fpu_finish()에서 별도로 저장/복원됩니다.
커널 스택 가드 페이지와 오버플로 탐지
CONFIG_VMAP_STACK=y(x86_64 기본)가 활성화되면 커널 스택은 vmalloc 영역에 할당되며, 스택 양쪽 끝에 매핑되지 않은 가드 페이지(Guard Page)가 자연스럽게 배치됩니다. 스택 오버플로우가 가드 페이지를 침범하면 즉시 페이지 폴트(Page Fault)가 발생하여 인접 메모리 훼손을 방지합니다.
/* 스택 경계 계산 함수 */
/* 스택 최하단 (낮은 주소) — include/linux/sched/task_stack.h */
static inline unsigned long *end_of_stack(const struct task_struct *task)
{
return task->stack;
}
/* 스택 최상단 (높은 주소) */
static inline unsigned long task_top_of_stack(struct task_struct *task)
{
return (unsigned long)(task->stack) + THREAD_SIZE;
}
/* Guard page 위치 (VMAP_STACK 활성 시, vmalloc 갭)
*
* [Guard 4KB] [task->stack ··· task->stack + THREAD_SIZE] [Guard 4KB]
* S - 4KB S (end_of_stack) S + 16KB S + 20KB
* (unmapped) (스택 최하단) (스택 최상단) (unmapped)
*/
end_of_stack())에 STACK_END_MAGIC(0x57AC6E9D)을 배치하여 schedule() 호출 시점에 훼손 여부를 검사합니다. 이는 즉시 감지가 아닌 사후 감지이므로, VMAP_STACK 활성화를 권장합니다. 보안 관점의 상세 분석은 커널 하드닝 — VMAP_STACK을 참조하십시오.
커널 스택 훼손의 원인 분류
커널 스택 훼손(Stack Corruption)은 다양한 경로로 발생합니다. 원인을 체계적으로 이해해야 적절한 방어 메커니즘을 선택할 수 있습니다.
| 원인 | 설명 | 방어 메커니즘 |
|---|---|---|
| 스택 버퍼 오버플로 | memcpy(), strcpy() 등으로 로컬 버퍼 경계 초과 쓰기 | Stack Protector, FORTIFY_SOURCE |
| 스택 깊이 고갈 | 깊은 재귀 호출 (파일시스템 경로 해석, symlink 중첩, 네트워크 프로토콜 체인) | VMAP_STACK, ftrace stack tracer |
| DMA→스택 메모리 | 디바이스가 스택 영역의 물리 페이지에 DMA 쓰기 수행 | IOMMU, bounce buffer, dma_alloc_coherent() |
| 스택 변수 use-after-free | 지역 변수 포인터를 함수 반환 후 참조 (댕글링 포인터(Dangling Pointer)) | -Wreturn-local-addr, KASAN |
| thread_info 덮어쓰기 | pre-4.9 커널에서 스택 바닥의 thread_info 변조 → 권한 상승 | CONFIG_THREAD_INFO_IN_TASK |
| VLA/alloca 과다 사용 | 런타임에 결정되는 가변 길이 배열(Variable-Length Array)로 스택 프레임 크기 폭증 | -Wvla, 커널 VLA 제거 (v4.20+) |
| 중첩 인터럽트 | Per-CPU IRQ 스택 미사용 시 인터럽트가 프로세스 스택을 소비 | Per-CPU IRQ 스택, IST |
-Wvla 빌드 플래그 추가). VLA는 컴파일 타임에 스택 사용량을 예측할 수 없게 하여 CONFIG_FRAME_WARN 검사를 무력화하며, 공격자가 크기 매개변수를 제어하면 스택 고갈을 유발할 수 있었습니다.
스택 오버플로 전파와 훼손 영역
커널 스택은 높은 주소에서 낮은 주소로 성장합니다. 함수 내부에서 로컬 버퍼 오버플로가 발생하면, 데이터는 높은 주소 방향(호출자의 프레임)으로 전파되면서 단계적으로 더 심각한 훼손을 유발합니다.
/* v4.9+ : thread_info는 task_struct 내부에 임베드 */
/* include/linux/sched.h */
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info; /* 스택이 아닌 task_struct 내부 */
#endif
unsigned int __state;
void *stack; /* 커널 스택 포인터 */
/* ... */
};
/* pre-4.9 : thread_info가 스택 바닥에 위치 (취약) */
/*
* [스택 최상단 (높은 주소)]
* │ pt_regs, 함수 프레임 ... │ ← RSP가 여기서 아래로 성장
* │ ... (미사용 공간) ... │
* │ thread_info │ ← 스택 오버플로 시 덮어쓰기 가능!
* [스택 최하단 (낮은 주소)]
*
* 공격 시나리오: 스택 오버플로 → thread_info.addr_limit 변조
* → set_fs(KERNEL_DS) 효과 → 커널 메모리 임의 접근
*/
스택 훼손 디버깅
커널 스택 훼손은 발생 시점과 증상 발현 시점이 다를 수 있어 디버깅이 까다롭습니다. 다음 도구들을 활용하면 스택 문제를 사전에 탐지하거나 사후에 분석할 수 있습니다.
ftrace 스택 트레이서 (Stack Tracer)
ftrace의 스택 트레이서는 커널 실행 중 관측된 최대 스택 깊이를 실시간으로 추적합니다. 스택 오버플로가 임박한 함수 경로를 사전에 식별할 수 있습니다.
# 스택 트레이서 활성화
echo 1 > /sys/kernel/debug/tracing/stack_tracer_enabled
# 관측된 최대 스택 깊이 확인
cat /sys/kernel/debug/tracing/stack_max_size
# 출력 예: 14384 (16384바이트 중 87.7% 사용)
# 최대 깊이를 기록한 콜 체인 확인
cat /sys/kernel/debug/tracing/stack_trace
# Depth Size Location
# ----- ---- --------
# 0) 14384 16 lock_acquire+0x1c4/0x4e0
# 1) 14368 240 _raw_spin_lock_irqsave+0x4c/0x68
# 2) 14128 96 try_to_wake_up+0x230/0x7b0
# ...
# 14) 432 208 ext4_readdir+0x2c0/0xb20
# 15) 224 224 iterate_dir+0x78/0x1c0
# 최대값 리셋 (측정 재시작)
echo 0 > /sys/kernel/debug/tracing/stack_max_size
CONFIG_DEBUG_STACK_USAGE
이 옵션을 활성화하면 커널이 새 스택을 할당할 때 0xCC 패턴으로 초기화하고, 프로세스 종료 시 패턴이 남아있는 영역을 역산하여 최대 스택 사용량을 dmesg에 출력합니다.
# dmesg 출력 예:
# [ 123.456] kworker/0:1 (456) used greatest stack depth: 13872 bytes left
# → 16384 - 13872 = 2512 바이트 사용, 여유 공간 84.6%
# "greatest stack depth" 로그 필터링
dmesg | grep "greatest stack depth"
CONFIG_SCHED_STACK_END_CHECK
이 옵션은 schedule()이 호출될 때마다 STACK_END_MAGIC의 무결성을 검사합니다. VMAP_STACK이 비활성인 환경에서 스택 오버플로를 가능한 빨리(다음 스케줄링 시점에) 감지합니다.
/* kernel/sched/core.c — schedule_debug() 내부 */
static inline void schedule_debug(struct task_struct *prev, bool preempt)
{
#ifdef CONFIG_SCHED_STACK_END_CHECK
if (task_stack_end_corrupted(prev))
panic("corrupted stack end detected inside scheduler\n");
#endif
}
/* include/linux/sched/task_stack.h */
static inline bool task_stack_end_corrupted(const struct task_struct *tsk)
{
return *end_of_stack(tsk) != STACK_END_MAGIC;
}
KASAN 스택 계측 (CONFIG_KASAN_STACK)
CONFIG_KASAN_STACK=y를 활성화하면 KASAN이 스택 변수에 대해서도 레드존(Redzone)을 배치합니다. 로컬 배열의 경계를 초과하는 읽기/쓰기가 발생하면 즉시 리포트를 생성합니다.
# KASAN 스택 OOB 탐지 예시 리포트:
BUG: KASAN: stack-out-of-bounds in vulnerable_function+0x48/0x80
Write of size 128 at addr ffff88800d4a7c40 by task exploit/1234
addr ffff88800d4a7c40 is located in stack of task exploit/1234
at offset 64 in frame:
vulnerable_function+0x0/0x80
this frame has 1 object:
[32, 96) 'buf' <== Memory access at offset 64 overflows this variable
/proc/<pid>/stack
실행 중인 프로세스의 현재 커널 콜 스택을 확인합니다. CONFIG_STACKTRACE 활성 시 사용 가능합니다.
# PID 1234의 현재 커널 스택 확인
cat /proc/1234/stack
# [<0>] __schedule+0x2f0/0x870
# [<0>] schedule+0x5e/0xd0
# [<0>] schedule_timeout+0x1d5/0x2b0
# [<0>] wait_for_completion+0x8e/0xf0
# [<0>] __do_sys_wait4+0xb0/0x100
손상된 스택 트레이스 해석
스택이 훼손된 상태에서 크래시가 발생하면, 스택 트레이스에 특징적인 패턴이 나타납니다.
# 정상 스택 트레이스:
Call Trace:
schedule+0x5e/0xd0
schedule_timeout+0x1d5/0x2b0
wait_for_completion+0x8e/0xf0
# 훼손된 스택 트레이스 (Phase 2 — RBP 체인 파괴):
Call Trace:
<TASK>
? vulnerable_function+0x48/0x80 ← 마지막 유효 프레임
? 0xdeadbeefcafebabe ← 가비지 주소 (RBP 손상)
? 0x4141414141414141 ← 오버플로 데이터 패턴
</TASK>
# crash 유틸리티에서 bt 명령:
crash> bt
PID: 1234 TASK: ffff88800d4a0000 CPU: 0 COMMAND: "exploit"
#0 [ffff88800d4a7c00] die at ffffffff81023456
#1 [ffff88800d4a7c30] page_fault at ffffffff81a00ace
[exception RIP: 0x4141414141414141] ← RIP가 오버플로 패턴
RBP: 4141414141414141 RSP: ffff88800d4a7c98
--- <유효하지 않은 프레임 포인터 — 추적 중단> ---
0x41414141, 0xCC 등)이 보이면 버퍼 오버플로를 의심합니다. KASAN이 활성화되어 있다면 0xCC 패턴은 KASAN 레드존 마커일 수 있습니다. 0x57AC6E9D(STACK_END_MAGIC)가 훼손되었다면 스택 깊이 고갈이 원인입니다.
새 태스크의 첫 번째 실행
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)
코드 설명
arch/x86/kernel/process.c의 copy_thread()와 arch/x86/entry/entry_64.S의 ret_from_fork은 새 태스크의 첫 번째 Context Switch를 준비합니다.
- copy_thread() 커널 스레드 경로
args->fn이 설정된 경우 커널 스레드입니다.pt_regs를 0으로 초기화하고, R12에 실행 함수, R13에 인자를 저장합니다.thread.ip를ret_from_kernel_thread로 설정하여 첫 스케줄링 시 해당 함수를 호출합니다. - copy_thread() 유저 프로세스 경로부모의
pt_regs를 복사하고childregs->ax = 0으로 설정하여 자식에서fork()반환값이 0이 되게 합니다. 스택에 callee-saved 레지스터 6개(R15~RBP)를 0으로 배치하고, 반환 주소로ret_from_fork을 설정합니다. - __switch_to_asm 시뮬레이션
thread.sp를childregs - 6 * sizeof(long)으로 설정하여__switch_to_asm의pop시퀀스와 정확히 매칭됩니다. 이 트릭 덕분에 새 태스크도 기존 Context Switch 코드 경로를 그대로 사용합니다. - ret_from_fork 진입
schedule_tail(prev)로 이전 태스크의 참조 카운터를 정리합니다. 이후 R12 값에 따라 커널 스레드(R12 != 0)이면CALL_NOSPEC r12로 함수를 호출하고, 유저 프로세스(R12 == 0)이면syscall_exit_to_user_mode()를 거쳐 유저 공간으로 복귀합니다. - 커널 스레드 종료커널 스레드의
fn()실행이 완료되면do_exit()을 호출하여 태스크를 종료합니다. 커널 스레드는 유저 공간으로 복귀하지 않습니다.
호출 체인: fork()/clone() → copy_thread()(스택 셋업) → 첫 switch_to() → ret_from_fork → schedule_tail() → 유저 복귀 또는 fn() 실행
exec() 후 첫 실행: exec()는 기존 프로세스의 주소 공간을 교체합니다. execve 시스템 콜이 완료되면 start_thread()로 pt_regs의 RIP과 RSP를 새 바이너리의 진입점(Entry Point)과 유저 스택으로 설정합니다. 이후 sysret으로 복귀할 때 새 바이너리의 첫 명령이 실행됩니다.
저장·복원되는 레지스터
task_struct ↔ thread_struct ↔ 커널 스택 메모리 관계
Context Switch에 관련된 세 가지 핵심 자료구조 — task_struct, thread_struct, 커널 스택 — 의 메모리 배치와 상호 참조 관계를 이해하면 switch_to 과정이 명확해집니다.
| 레지스터 종류 | 예시 (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() | 스레드(Thread) 로컬 변수용 |
| 페이지 테이블 | 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) */
코드 설명
Lazy FPU 전환 방식(v4.2 이전)과 XSAVE 컴포넌트 마스크 정의입니다. 현대 커널은 Eager 모드가 기본이지만, Lazy 방식의 이해는 전환 비용 분석에 중요합니다.
- switch_fpu_prepare() — LazyCR0의 TS(Task Switched) 비트를 세트합니다(
stts()). 이후 FPU/SIMD 명령 실행 시 CPU가#NM(Device Not Available) 예외를 발생시킵니다. FPU를 사용하지 않는 태스크는 저장/복원 비용이 0입니다. - do_device_not_available()
#NM예외 핸들러입니다. 이전 태스크의 FPU 상태를fpu__save()로 저장하고, 현재 태스크의 상태를fpu__restore()로 복원한 뒤,clts()로 TS 비트를 클리어하여 이후 FPU 명령이 정상 실행되게 합니다. - copy_kernel_to_fpregs()XRSTOR 명령으로
fpstate에서 FPU 레지스터를 복원합니다.xstate_bv비트맵에 따라 사용되는 컴포넌트만 선택적으로 복원하여 불필요한 메모리 읽기를 생략합니다. - XFEATURE_MASK_*XSAVE 컴포넌트별 비트 마스크입니다. x87 FPU(576B), SSE/XMM(256B), AVX/YMM(256B), AVX-512/ZMM+Opmask(~1.5KB)로 구성됩니다. AVX-512를 모두 사용하면 XSAVE 영역이 2.5KB 이상이 되어 전환 비용이 크게 증가합니다.
참고: Lazy 방식은 Spectre 취약점으로 인해 v4.2 이후 Eager 모드로 대체되었습니다. #NM 예외 지연이 사이드 채널 공격에 악용될 수 있기 때문입니다.
| 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 워크로드를 격리(Isolation)하면 일관된 성능을 얻을 수 있습니다.
TLB 관리와 PCID
PCID (Process-Context Identifier)
x86에서 CR3(페이지 테이블 포인터)를 변경하면 기본적으로 TLB 전체가 무효화(Invalidation)됩니다. 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");
}
}
코드 설명
arch/x86/mm/tlb.c의 PCID 기반 주소 공간 전환과 TLB 관리입니다. Context Switch에서 switch_mm_irqs_off()가 핵심 함수입니다.
- switch_mm_irqs_off()
context_switch()에서 호출되어 페이지 테이블 베이스(CR3)를 교체합니다. PCID 지원 CPU에서는CR3_PCID_NOFLUSH비트(bit 63)를 세트하여 TLB 플러시 없이 주소 공간을 전환합니다. - PCID 할당각
mm_struct에 12비트 PCID(0~4095)를 할당합니다. TLB 항목에 PCID가 태그되므로, 다른 프로세스의 TLB 항목과 공존할 수 있어 전환 후 TLB miss가 ~95% 감소합니다. - KPTI 연동KPTI 활성화 시 유저 PGD에 별도 PCID를 할당합니다(
PTI_USER_PCID_BITS). 시스템 콜 진입/복귀 시 커널/유저 PGD 전환에도 NOFLUSH를 사용하여 TLB 보존이 가능합니다. - flush_tlb_range_vm()특정 VA 범위의 TLB만 무효화합니다.
INVPCID지원 시 특정 PCID의 특정 VA만 선택적으로 무효화하고, 미지원 시INVLPG로 페이지 단위 무효화합니다.
호출 체인: context_switch() → switch_mm_irqs_off(prev_mm, next_mm) → write_cr3(new_pgd | asid | NOFLUSH)
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 / 플러시(Flush): ~500ns
- TSS sp0 업데이트 — ~20ns
- TLS MSR 업데이트 — ~50ns (WRMSR × 2)
- FPU 상태 저장 (비Lazy) — XSAVE: 200~800ns (크기 의존)
- 총 직접 비용 — ~300ns (PCID, Lazy FPU) ~ 2.5µs (플러시, XSAVE)
간접 비용 (훨씬 큼)
- L1/L2 캐시 오염 — 새 프로세스의 데이터가 캐시를 채워 이전 프로세스 데이터 축출(Eviction). 64KB L1 캐시 완전 교체 시 수십 µs 추가 레이턴시
- LLC (L3) 캐시 미스 — 공유되지만 접근 패턴이 바뀌면 수십 µs
- TLB Miss 폭풍 — PCID 없는 경우, 전환 후 모든 메모리 접근이 페이지 테이블 워크 필요
- 분기 예측(Branch Prediction)기 오염 — 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 활성화 | 시스템 콜 오버헤드(Overhead) |
|---|---|---|---|
| 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 명령으로 선택적 무효화가 가능합니다.
Spectre 완화와 Context Switch 비용
Meltdown(KPTI) 외에도 Spectre V2(Branch Target Injection, CVE-2017-5715)와 관련 변종 완화가 Context Switch에 직접적인 비용을 추가합니다. 특히 IBPB(Indirect Branch Prediction Barrier), STIBP(Single Thread Indirect Branch Predictors), IBRS(Indirect Branch Restricted Speculation)는 프로세스 간 전환 시 분기 예측기(Branch Predictor)를 격리하여 추측 실행(Speculative Execution) 공격을 방지합니다.
/* arch/x86/mm/tlb.c — Context Switch 시 IBPB 발행 */
static inline void cond_ibpb(struct task_struct *next)
{
/*
* IBPB가 필요한 경우:
* 1. spectre_v2_user_ibpb == SPECTRE_V2_USER_STRICT
* → 모든 프로세스 전환 시 IBPB (가장 안전, 가장 느림)
* 2. spectre_v2_user_ibpb == SPECTRE_V2_USER_PRCTL
* → PR_SPEC_INDIRECT_BRANCH 활성화된 태스크만
* 3. spectre_v2_user_ibpb == SPECTRE_V2_USER_SECCOMP
* → SECCOMP 적용 + non-dumpable 프로세스
*/
if (!static_branch_likely(&switch_mm_cond_ibpb))
return;
/* 같은 프로세스 내 스레드 간 전환이면 IBPB 불필요 */
if (next == current || (next->flags & PF_KTHREAD))
return;
/* TIF_SPEC_IB 플래그 설정 확인 */
if (test_tsk_thread_flag(next, TIF_SPEC_IB) ||
test_tsk_thread_flag(current, TIF_SPEC_IB)) {
/* IBPB 발행: 간접 분기 예측 이력 전체 클리어 */
indirect_branch_prediction_barrier();
}
}
/* 실제 IBPB 실행 (MSR 쓰기) */
static inline void indirect_branch_prediction_barrier(void)
{
/* IA32_PRED_CMD MSR에 IBPB 비트 쓰기 */
wrmsrl(MSR_IA32_PRED_CMD, PRED_CMD_IBPB);
}
/*
* Spectre V2 완화 전략 (커널 파라미터):
* spectre_v2=auto — CPU에 맞는 최적 전략 자동 선택
* spectre_v2=retpoline — 간접 분기를 retpoline으로 대체
* spectre_v2=ibrs — IBRS 모드 (MSR 기반)
* spectre_v2=eibrs — Enhanced IBRS (하드웨어 격리)
* spectre_v2=eibrs,retpoline — eIBRS + retpoline 병용
*
* IBPB 정책 (커널 파라미터):
* spectre_v2_user=auto — SECCOMP/PRCTL 기반 선택적 IBPB
* spectre_v2_user=on — 모든 전환 시 IBPB (strict)
* spectre_v2_user=off — IBPB 비활성화 (보안 위험)
*/
코드 설명
arch/x86/mm/tlb.c의 Spectre V2 완화를 위한 IBPB(Indirect Branch Prediction Barrier) 구현입니다. Context Switch 시 분기 예측 이력을 클리어하여 프로세스 간 간접 분기 예측 공격을 방지합니다.
- cond_ibpb()
switch_mm_irqs_off()에서 주소 공간 전환 직후 호출됩니다.static_branch_likely()로 IBPB 정책이 활성화되었는지 먼저 확인하며, 비활성화 시 분기 예측으로 즉시 반환됩니다(오버헤드 ~0ns). - 같은 프로세스 필터링같은
task_struct또는 커널 스레드(PF_KTHREAD)로 전환하는 경우 IBPB를 생략합니다. 동일 프로세스의 스레드들은 동일 주소 공간을 공유하므로 분기 예측 격리가 불필요합니다. - TIF_SPEC_IB 플래그
prctl(PR_SET_SPECULATION_CTRL)또는 SECCOMP 정책으로 설정됩니다. 현재 태스크 또는 다음 태스크 중 하나라도 이 플래그가 세트되어 있으면 IBPB를 발행합니다. - indirect_branch_prediction_barrier()
MSR_IA32_PRED_CMD(0x49) MSR에PRED_CMD_IBPB비트를 쓰는 단일WRMSR명령입니다. CPU의 간접 분기 예측 버퍼(BTB/BHB)를 전체 클리어하며, ~100~200ns의 비용이 발생합니다. - Spectre V2 완화 전략커널 파라미터로 선택합니다.
retpoline은 간접 분기를 ret 기반 루프로 대체하고,eibrs는 하드웨어가 권한 레벨별 예측을 자동 격리합니다. eIBRS/AutoIBRS 지원 CPU에서는 IBPB 없이도 안전하여 추가 비용이 ~0ns입니다.
호출 체인: context_switch() → switch_mm_irqs_off() → cond_ibpb(next) → indirect_branch_prediction_barrier() → wrmsrl(MSR_IA32_PRED_CMD)
# 현재 시스템의 Spectre V2 완화 상태 확인
cat /sys/devices/system/cpu/vulnerabilities/spectre_v2
# 예: "Mitigation: Enhanced / Automatic IBRS; IBPB: conditional;
# STIBP: conditional; RSB filling; PBRSB-eIBRS: SW sequence;
# BHI: SW loop, KVM: SW loop"
# Spectre V2 완화별 성능 영향 측정
# IBPB 비활성화 vs 활성화 비교 (테스트 환경만!)
perf stat -e context-switches,branch-misses \
-- lat_ctx -s 0 2
# 특정 프로세스의 Spectre 완화 정책 확인
cat /proc/$(pgrep myapp | head -1)/status | grep Speculation
# Speculation_Store_Bypass: thread force mitigated
# 프로세스별 IBPB 제어 (prctl)
# PR_SET_SPECULATION_CTRL + PR_SPEC_INDIRECT_BRANCH
| 완화 기법 | 적용 시점 | Context Switch 추가 비용 | 필요 CPU |
|---|---|---|---|
| IBPB | ASID 변경 시 (switch_mm) | ~100-200ns (WRMSR) | Intel Skylake+, AMD Zen+ |
| STIBP | 상시 (SMT 활성 시) | IPC 5~10% 감소 | Intel Skylake+, AMD Zen+ |
| Retpoline | 간접 호출마다 | 간접 호출당 ~5ns | 소프트웨어 (모든 CPU) |
| eIBRS/AutoIBRS | Ring 전환 시 (하드웨어) | ~0ns (마이크로코드) | Intel Ice Lake+, AMD Zen 3+ |
| RSB 채우기 | Context Switch 시 | ~30ns (32엔트리 채우기) | Spectre V2 RSB 변종 |
보안 vs 성능 트레이드오프: mitigations=off 커널 파라미터는 모든 하드웨어 취약점 완화를 비활성화하여 최대 성능을 얻지만, 심각한 보안 위험이 따릅니다. 프로덕션(Production) 환경에서는 절대 사용하지 마세요. 대신 최신 CPU(eIBRS 지원)로 업그레이드하면 완화 비용 없이 보안을 유지할 수 있습니다. spectre_v2_user=prctl로 선택적 IBPB를 적용하면 신뢰할 수 있는 워크로드에서 불필요한 비용을 줄일 수 있습니다.
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) |
RISC-V Context Switch 구현
RISC-V 아키텍처의 Context Switch는 x86-64와 ARM64에 비해 구조가 단순합니다. 레지스터가 깔끔하게 분류되어 있고, 특권 레벨 전환이 CSR(Control and Status Register) 기반이며, ASID를 SATP 레지스터에 직접 인코딩합니다.
__switch_to 어셈블리
/* arch/riscv/kernel/entry.S — RISC-V Context Switch */
SYM_FUNC_START(__switch_to)
/* a0 = prev task_struct, a1 = next task_struct */
/* prev의 callee-saved 레지스터 저장 (s0~s11, ra, sp) */
sd ra, TASK_THREAD_RA_RA(a0) /* 반환 주소 */
sd sp, TASK_THREAD_SP(a0) /* 스택 포인터 */
sd s0, TASK_THREAD_S0(a0) /* s0(fp) ~ s11: callee-saved */
sd s1, TASK_THREAD_S1(a0)
sd s2, TASK_THREAD_S2(a0)
sd s3, TASK_THREAD_S3(a0)
sd s4, TASK_THREAD_S4(a0)
sd s5, TASK_THREAD_S5(a0)
sd s6, TASK_THREAD_S6(a0)
sd s7, TASK_THREAD_S7(a0)
sd s8, TASK_THREAD_S8(a0)
sd s9, TASK_THREAD_S9(a0)
sd s10, TASK_THREAD_S10(a0)
sd s11, TASK_THREAD_S11(a0)
/* next의 callee-saved 레지스터 복원 */
ld ra, TASK_THREAD_RA_RA(a1)
ld sp, TASK_THREAD_SP(a1)
ld s0, TASK_THREAD_S0(a1)
ld s1, TASK_THREAD_S1(a1)
ld s2, TASK_THREAD_S2(a1)
ld s3, TASK_THREAD_S3(a1)
ld s4, TASK_THREAD_S4(a1)
ld s5, TASK_THREAD_S5(a1)
ld s6, TASK_THREAD_S6(a1)
ld s7, TASK_THREAD_S7(a1)
ld s8, TASK_THREAD_S8(a1)
ld s9, TASK_THREAD_S9(a1)
ld s10, TASK_THREAD_S10(a1)
ld s11, TASK_THREAD_S11(a1)
/* tp(thread pointer) = next task_struct 주소 */
move tp, a1
ret /* ra로 점프 → next 프로세스 재개 */
SYM_FUNC_END(__switch_to)
RISC-V 주소 공간 전환
/* arch/riscv/include/asm/mmu_context.h */
static inline void switch_mm(struct mm_struct *prev,
struct mm_struct *next,
struct task_struct *task)
{
if (unlikely(prev != next)) {
/*
* SATP (Supervisor Address Translation and Protection):
* [63:60] MODE — Sv39(8), Sv48(9), Sv57(10)
* [59:44] ASID — 16비트 (최대 65536개)
* [43:0] PPN — 페이지 테이블 루트의 물리 프레임 번호
*/
unsigned long asid = atomic_long_read(&next->context.id);
csr_write(CSR_SATP,
virt_to_pfn(next->pgd) |
((asid & 0xFFFF) << 44) |
SATP_MODE);
/* SFENCE.VMA: TLB 플러시 (ASID 지정 시 선택적) */
local_flush_tlb_all();
}
}
RISC-V vs x86-64 vs ARM64 비교
| 항목 | x86-64 | ARM64 | RISC-V (RV64) |
|---|---|---|---|
| 레지스터 전환 함수 | __switch_to_asm + __switch_to | cpu_switch_to | __switch_to |
| callee-saved 수 | 6개 (RBX,RBP,R12~R15) | 12개 (x19~x28,fp,sp) | 14개 (s0~s11,ra,sp) |
| 저장/복원 명령 | push/pop (스택 기반) | stp/ldp (메모리 직접) | sd/ld (메모리 직접) |
| 페이지 테이블 레지스터 | CR3 (하나, PCID 포함) | TTBR0/TTBR1 (분리) | SATP (MODE+ASID+PPN) |
| TLB 태그 (ASID) | PCID 12비트 | ASID 8/16비트 | ASID 16비트 |
| TLB 무효화 명령 | INVLPG, INVPCID | TLBI VAE1/ASIDE1 | SFENCE.VMA |
| FPU 저장 | XSAVE/XRSTOR | FPSIMD (stp/ldp) | fsd/fld (F/D 확장) |
| Meltdown 취약 | Intel (KPTI 필요) | 일부 (CSV3) | 미취약 (대부분) |
| Thread 포인터 | FS/GS base (MSR) | TPIDR_EL0/EL1 | tp 레지스터 (x4) |
RISC-V의 단순성: RISC-V는 x86-64의 복잡한 TSS, I/O 비트맵, 세그먼트 레지스터가 없어 Context Switch 구현이 간결합니다. Thread 포인터는 전용 레지스터(tp)로 MSR 쓰기 없이 단순 레지스터 교체로 처리됩니다. 다만 Vector 확장(V Extension)이 활성화되면 AVX-512와 유사한 대용량 레지스터 상태 관리 비용이 추가됩니다.
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로 오프로드
| 마이그레이션 범위 | 대기 시간(Latency) | 캐시 영향 | NUMA 영향 |
|---|---|---|---|
| 같은 코어 (HT 형제) | 거의 없음 | L1/L2 공유 → 최소 | 없음 |
| 같은 LLC (소켓(Socket) 내) | ~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), 잠금 경합(Lock Contention) 감소 |
| 비자발적 | 타임 슬라이스 만료, 고우선순위 깨어남 | 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 커널은 PREEMPT_NONE, PREEMPT_VOLUNTARY, PREEMPT(FULL), PREEMPT_RT, PREEMPT_LAZY 등 다양한 선점 모델을 지원하며, 이는 Context Switch 빈도와 응답 레이턴시에 직접적인 영향을 미칩니다. CONFIG_PREEMPT_DYNAMIC(v5.14+)으로 런타임 전환도 가능합니다.
| 선점 모델 | 커널 내 선점 | 비자발적 CS 빈도 | 응답 레이턴시 | 처리량 | 대상 워크로드 |
|---|---|---|---|---|---|
| PREEMPT_NONE | 불가 (cond_resched만) | 최저 | 수~수십 ms | 최고 | 서버, 배치 처리 |
| PREEMPT_VOLUNTARY | might_resched() 지점만 | 낮음 | 1~10 ms | 높음 | 데스크톱 (기본값) |
| PREEMPT (FULL) | spinlock 밖 어디서든 | 중간 | 0.1~1 ms | 중간 | 저지연 서버, 임베디드 |
| PREEMPT_LAZY (v6.13+) | 비RT: 유저 복귀 시 / RT: 즉시 | 낮음~중간 | 0.5~2 ms | 높음 | FULL 대체 (처리량+레이턴시 균형) |
| PREEMPT_RT | 거의 모든 곳 (rtmutex) | 높음 | <100 µs (보장) | 낮음 | 실시간, 산업용, 오디오 |
# 현재 선점 모델 확인
zcat /proc/config.gz | grep PREEMPT
# CONFIG_PREEMPT_NONE=y → 서버 기본
# CONFIG_PREEMPT_VOLUNTARY=y → 데스크톱
# CONFIG_PREEMPT=y → FULL
# PREEMPT_DYNAMIC: 런타임 전환 (v5.14+)
cat /sys/kernel/debug/sched/preempt
# "none" / "voluntary" / "full"
# 런타임 전환 예시 (재부팅 불필요)
echo full > /sys/kernel/debug/sched/preempt
# 선점 모델별 Context Switch 빈도 비교 (동일 워크로드)
# NONE: ~2,000 cs/s (서버: 높은 처리량)
# VOLUNTARY: ~5,000 cs/s (데스크톱: 적절한 반응)
# FULL: ~15,000 cs/s (저지연: 빈번한 선점)
# RT: ~30,000 cs/s (실시간: 모든 선점 허용)
선점 모델 심층 분석: preempt_count 비트 레이아웃, 각 모델별 동작 차이, PREEMPT_DYNAMIC 런타임 전환 방법, PREEMPT_LAZY의 TIF_NEED_RESCHED_LAZY 메커니즘은 preempt_count & 선점 모델 페이지에서 상세히 다룹니다.
모니터링 및 프로파일링(Profiling)
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 */
컨테이너(Container) 환경 최적화
Docker/Kubernetes 환경에서는 cgroup v2의 CPU 대역폭 제어가 Context Switch 패턴에 큰 영향을 미칩니다. cpu.max(CFS 대역폭 제한)에 의한 스로틀링(Throttling)은 비자발적 Context Switch를 급증시킵니다.
# 컨테이너 CPU 대역폭 제한과 Context Switch
# cgroup v2: period=100ms, quota=50ms → 50% CPU
echo "50000 100000" > /sys/fs/cgroup/mycontainer/cpu.max
# 스로틀링 횟수 확인 (높으면 Context Switch 폭발)
cat /sys/fs/cgroup/mycontainer/cpu.stat
# nr_throttled 42381 ← 스로틀링 횟수
# throttled_usec 19238412 ← 총 스로틀링 시간(µs)
# Kubernetes: resources 설정에 따른 CFS 대역폭
# limits.cpu: "500m" → quota=50ms/100ms → 50% 제한
# 팁: limits=requests로 맞추면 QoS=Guaranteed → 스로틀링 최소화
# cpuset으로 컨테이너 CPU 격리 (마이그레이션 차단)
echo "0-3" > /sys/fs/cgroup/mycontainer/cpuset.cpus
echo "0" > /sys/fs/cgroup/mycontainer/cpuset.mems
# 컨테이너별 Context Switch 모니터링
perf stat -e context-switches -G mycontainer -a sleep 10
# 네임스페이스(Namespace) 오버헤드: Context Switch 자체에 추가 비용 없음
# (네임스페이스는 커널 객체 참조만 바꾸므로 전환 경로에 영향 없음)
KVM 가상화 환경
KVM 게스트 VM에서의 Context Switch는 호스트 커널의 Context Switch 위에 VM Exit/Entry(VMCS 저장/복원) 비용이 추가됩니다. 특히 게스트 내부 Context Switch가 VM Exit를 유발하면 비용이 수 배로 증가합니다.
# KVM VM Exit/Entry 횟수 확인
perf kvm stat live -p $(pgrep qemu-system)
# Event Samples Time %Time
# EXTERNAL_INTERRUPT 12345 5.2ms 42.3% ← 인터럽트 VM Exit
# HLT 8901 3.1ms 25.2% ← 유휴 상태
# EPT_VIOLATION 2345 1.8ms 14.6% ← 메모리 가상화
# MSR_WRITE 1234 0.5ms 4.1% ← MSR 접근
# KVM Context Switch 비용 비교
# 베어메탈 CS: ~1.2µs (직접 전환)
# 게스트 내부 CS: ~1.5µs (VM Exit 없으면 유사)
# VM Exit 동반 CS: ~5-15µs (VMCS 저장/복원 + 호스트 스케줄링)
# KVM 최적화: posted interrupt (VM Exit 없이 인터럽트 전달)
# 커널 파라미터: kvm_intel.enable_apicv=1
# 효과: 외부 인터럽트에 의한 VM Exit 대폭 감소
# vCPU 고정으로 마이그레이션 방지
# virsh vcpupin myvm 0 2 ← vCPU 0을 물리 CPU 2에 고정
# virsh vcpupin myvm 1 3 ← vCPU 1을 물리 CPU 3에 고정
# 게스트 내부에서도 CPU 어피니티 적용
# 호스트 + 게스트 이중 어피니티 → 가장 낮은 Context Switch 비용
컨테이너 vs VM Context Switch 비용: 컨테이너는 호스트 커널을 공유하므로 Context Switch 비용이 베어메탈과 동일합니다(cgroup 오버헤드 ~0). KVM 게스트는 VM Exit/Entry 오버헤드(~5µs)가 추가됩니다. 성능 민감 워크로드에서 컨테이너가 VM보다 Context Switch 비용이 3~10배 낮은 이유입니다. 단, cpu.max 스로틀링이 발생하면 컨테이너 환경에서도 Context Switch가 급증할 수 있으므로, nr_throttled 지표를 모니터링하세요.
__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;
}
코드 설명
kernel/sched/core.c의 __schedule()은 모든 Context Switch의 단일 진입점으로, 세 가지 호출 경로를 sched_mode 인자로 구분합니다.
- __schedule(SM_PREEMPT)비자발적 선점 경로입니다.
prev->__state를 변경하지 않으므로TASK_RUNNING상태가 유지되고, 태스크는 런큐에 남아 즉시 재스케줄링 대상이 됩니다. 인터럽트 복귀 시preempt_schedule_irq()가 이 경로를 호출합니다. - __schedule(SM_NONE)자발적 양보 경로입니다. 태스크가 이미
TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE로 상태를 변경한 후schedule()을 호출합니다.signal_pending_state()확인 후deactivate_task()로 런큐에서 제거됩니다. - pick_next_task()스케줄러 클래스 우선순위(DL > RT > CFS > IDLE) 순으로 다음 실행 태스크를 선택합니다. CFS 태스크만 존재하는 fast path에서는
pick_next_task_fair()를 직접 호출합니다. - context_switch()
prev != next인 경우에만 실제 문맥 전환을 수행합니다. 주소 공간 전환(switch_mm)과 레지스터 전환(switch_to)을 포함합니다. - schedule() 래퍼
sched_submit_work()로 보류 중인 작업(블록 I/O 플러그 등)을 제출한 뒤,need_resched()가 해제될 때까지__schedule(SM_NONE)을 반복 호출합니다. - _cond_resched()협력적 양보 경로입니다.
should_resched(0)으로 선점 카운터가 0이고TIF_NEED_RESCHED가 설정된 경우에만preempt_schedule_common()을 호출합니다.
호출 체인: schedule()/preempt_schedule_irq()/cond_resched() → __schedule() → pick_next_task() → context_switch()
pick_next_task 최적화: CFS 태스크만 존재하는 일반적인 경우, pick_next_task()는 DL/RT 클래스를 건너뛰고 곧바로 pick_next_task_fair()를 호출합니다. 이 fast path 덕분에 대부분의 스케줄링 결정은 수십 나노초 내에 완료됩니다.
| 호출 경로 | SM 모드 | prev->state | 런큐(Runqueue) 유지 | 발생 시점 |
|---|---|---|---|---|
| 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 | 예 | 긴 루프 중간, 소프트 잠금(Lock) 방지 |
| 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 동안 주파수 다운클럭
* 같은 코어에서 다른 태스크의 성능에도 영향 */
}
코드 설명
arch/x86/kernel/fpu/context.h의 Eager FPU 전환 구현입니다. 현대 커널(v4.2+)은 Lazy 방식을 폐기하고 매 Context Switch마다 FPU 상태를 즉시 저장/복원합니다.
- switch_fpu_prepare()현재 태스크의 FPU/SIMD 레지스터를
old_fpu->fpstate->regs에 저장합니다. 내부적으로copy_fpregs_to_fpstate()가 XSAVEOPT/XSAVES 명령을 실행하며,XSTATE_BV비트맵에서 변경된 컴포넌트만 실제 메모리에 기록합니다. - switch_fpu_finish()새 태스크의 FPU 상태를 레지스터로 복원합니다.
fpregs_state_valid()로 동일 CPU에서 마지막 FPU 사용자가 같은 태스크인지 확인하고, 일치하면 XRSTOR을 생략하여 수백 ns를 절약합니다. - struct fpu
last_cpu필드로 FPU 레지스터 캐싱 최적화를 구현합니다. 태스크가 이전과 같은 CPU에서 실행되면 레지스터가 이미 올바른 상태이므로 복원이 불필요합니다. - struct fpstateXSAVE 영역의 메타데이터를 관리합니다.
xfeatures비트맵은 활성 컴포넌트(x87, SSE, AVX, AVX-512 등)를 나타내며,size는 실제 사용 크기(576B~2.5KB+)를 추적합니다. - fpu__track_avx512()AVX-512 사용 시점을
jiffies로 기록합니다. Intel CPU에서 AVX-512 실행 후 ~670us 동안 코어 주파수가 다운클럭되므로, 스케줄러가 동일 코어의 다른 태스크 성능 영향을 판단하는 데 활용됩니다.
호출 체인: context_switch() → switch_fpu_prepare(prev) → switch_to() → switch_fpu_finish(next)
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 취약점 완화를 위해 유저 공간과 커널 공간(Kernel Space)에 각각 별도의 페이지 테이블(PGD)을 사용합니다. 유저 PGD에는 커널 주소 공간 대부분이 매핑(Mapping)되지 않으므로, 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
코드 설명
arch/x86/mm/pti.c와 arch/x86/entry/calling.h의 KPTI(Kernel Page Table Isolation) 구현입니다. Meltdown 취약점 완화를 위해 커널/유저 공간에 별도의 페이지 테이블을 사용합니다.
- kernel_to_user_pgdp()커널 PGD 주소에
PTI_USER_PGD_OFFSET(=PAGE_SIZE, 0x1000)을 OR하여 유저 PGD 주소를 계산합니다. 두 PGD는 연속된 2페이지에 할당되므로 비트 연산으로 빠르게 변환됩니다. - pti_init()유저 PGD를 초기화하며, 시스템 콜 진입점(
entry_SYSCALL_64), IDT, TSS,cpu_entry_area등 최소한의 커널 매핑만 복사합니다. 나머지 커널 주소는 매핑되지 않아 유저 모드에서 커널 메모리 접근이 불가능합니다. - SWITCH_TO_KERNEL_CR3시스템 콜/인터럽트 진입 시 실행되는 어셈블리 매크로입니다. CR3의 bit 12를 클리어하여(
andq $~PTI_USER_PGTABLE_MASK) 커널 PGD로 전환합니다.ALTERNATIVE매크로로 PTI 비활성화 시 이 코드를 NOP으로 패치합니다. - SWITCH_TO_USER_CR3_STACK유저 공간 복귀 시 CR3의 bit 12를 세트하여(
orq $PTI_USER_PGTABLE_MASK) 유저 PGD로 전환합니다. PCID와 NOFLUSH를 병용하면 TLB 플러시 없이 전환이 가능합니다.
비용: KPTI 활성화 시 시스템 콜당 CR3 쓰기 2회 추가(진입 + 복귀), Context Switch 시 총 3회. PCID 미지원 CPU에서는 매번 TLB 전체 플러시가 발생하여 성능 저하가 심각합니다.
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;
코드 설명
arch/x86/mm/tlb.c의 TLB 플러시 핵심 구현입니다. 페이지 테이블 변경 시 다른 CPU의 stale TLB 항목을 무효화하는 메커니즘입니다.
- flush_tlb_mm_range()페이지 테이블 변경(munmap, mprotect 등) 후 호출됩니다. 로컬 CPU의 TLB를 먼저 무효화한 뒤,
mm_cpumask()로 해당 mm을 사용 중인 원격 CPU 목록을 확인하여 IPI(Inter-Processor Interrupt)를 전송합니다. - flush_tlb_func() — IPI 핸들러원격 CPU에서 IPI 수신 시 실행됩니다.
loaded_mm이 요청된 mm과 다르면(Lazy TLB 모드) 플래그만 세트하고 즉시 반환합니다. 활성 mm이면 요청 범위에 따라 INVLPG(개별) 또는 전체 플러시를 수행합니다. - Lazy TLB 최적화커널 스레드는 자체 mm이 없으므로(
mm == NULL), 이전 태스크의active_mm을 차용합니다. 이때 원격 TLB shootdown IPI를 받으면is_lazy플래그만 세트하고, 다음에 유저 태스크로 전환할 때 일괄 처리합니다. - TLB_FLUSH_ALL무효화 범위가
tlb_single_page_flush_ceiling(기본 33페이지)을 초과하거나 페이지 테이블 자체가 해제된 경우(freed_tables), 개별 INVLPG 반복보다 전체 플러시가 효율적이므로local_flush_tlb()를 호출합니다. - tlb_single_page_flush_ceilingINVLPG 반복과 전체 플러시의 비용 교차점입니다. INVLPG는 페이지당 ~100ns이고 전체 플러시(CR3 재로드)는 ~300ns이므로, 33페이지 이상이면 전체 플러시가 유리합니다.
| TLB 플러시 트리거 | 플러시 범위 | 비용 | IPI 필요 |
|---|---|---|---|
| Context Switch (switch_mm) | PCID: 없음 / 미지원: 전체 | 50ns / 500ns | 아니오 |
| munmap() / mprotect() | 변경된 VA 범위 | 10ns/페이지 | 예 (mm 활성 CPU들) |
| 페이지 마이그레이션 | 이동된 페이지 | 10ns/페이지 | 예 |
| 커널 모듈(Kernel Module) 로드/해제 | 전체 (글로벌) | ~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()와 자발적 양보
cond_resched()는 PREEMPT_NONE/PREEMPT_VOLUNTARY 모델에서 긴 커널 경로의 레이턴시를 줄이기 위한 자발적 양보 지점입니다. TIF_NEED_RESCHED가 설정되어 있으면 schedule()을 호출하여 Context Switch를 유발합니다.
/* cond_resched() — 커널 루프 내 자발적 양보 */
int __sched _cond_resched(void)
{
if (should_resched(0)) {
preempt_schedule_common();
return 1;
}
return 0;
}
선점 모델과 cond_resched() 관계: PREEMPT(FULL) 모델에서는 cond_resched()가 사실상 no-op입니다(커널이 이미 완전 선점 가능). PREEMPT_LAZY(v6.13+)는 비실시간 태스크에 대해 cond_resched()와 유사한 지연 선점을 자동으로 적용합니다. 각 모델별 상세 비교는 preempt_count & 선점 모델을 참고하세요.
참고 링크
- Kernel Documentation — Scheduler — 커널 공식 스케줄러 문서로 context switch를 유발하는 스케줄링 정책과 메커니즘을 설명합니다
- Kernel Documentation — x86 Architecture — x86 아키텍처별 context switch 구현에 관련된 레지스터, 세그먼트, TSS 등의 공식 문서입니다
- Kernel Documentation — ARM64 Architecture — ARM64 아키텍처의 context switch, TTBR 전환, ASID 관리 관련 공식 문서입니다
- Kernel Documentation — Proper Locking Under a Preemptible Kernel — 선점 가능 커널에서의 올바른 락 사용법과 preempt_count의 역할을 설명합니다
- Kernel Documentation — CPU Idle — CPU 유휴 상태 진입 시 context switch와 관련된 전력 관리 메커니즘을 다룹니다
- 커널 소스: kernel/sched/core.c — __schedule(), context_switch() 함수가 구현된 스케줄러 핵심 소스입니다
- 커널 소스: arch/x86/kernel/process_64.c — x86_64의 __switch_to() 함수 구현이 포함되어 있습니다
- 커널 소스: arch/x86/entry/entry_64.S — x86_64 엔트리 포인트와 스택 전환 어셈블리 코드가 구현되어 있습니다
- 커널 소스: arch/x86/kernel/fpu/core.c — FPU/XSTATE 상태 저장 및 복원 로직이 구현되어 있습니다
- 커널 소스: arch/x86/mm/tlb.c — switch_mm_irqs_off()와 TLB 플러시, PCID 관리 코드가 구현되어 있습니다
- 커널 소스: arch/arm64/kernel/process.c — ARM64의 cpu_switch_to()와 __switch_to() 구현입니다
- 커널 소스: arch/riscv/kernel/process.c — RISC-V의 __switch_to() 함수 구현이 포함되어 있습니다
- 커널 소스: kernel/fork.c — copy_thread()를 통한 새 태스크의 초기 커널 스택 설정과 ret_from_fork 준비 과정이 포함되어 있습니다
- LWN: x86 — the end of lazy FPU switching — Lazy FPU 전환의 폐지 배경과 eager FPU 방식으로의 전환 과정을 설명합니다
- LWN: The x86 context switch — x86 아키텍처에서의 context switch 메커니즘을 상세히 분석하는 기사입니다
- LWN: KAISER — hiding the kernel from user space — KPTI(Kernel Page-Table Isolation)의 도입 배경과 context switch에 미치는 영향을 다룹니다
- LWN: The current state of kernel page-table isolation — Meltdown 대응을 위한 KPTI 구현 세부 사항과 성능 영향을 분석합니다
- LWN: PCID is now a critical performance/security feature — PCID(Process Context Identifier)가 TLB 관리와 context switch 성능에 미치는 영향을 설명합니다
- LWN: The search for a more flexible scheduler — 스케줄러 설계와 context switch 빈도에 영향을 주는 선점 정책 논의입니다
- LWN: Preemption and context switching in the Linux kernel — 커널 선점 모델과 context switch의 관계를 종합적으로 설명합니다
- LWN: Software overhead of context switching — context switch의 직접/간접 비용을 정량적으로 분석한 연구를 소개합니다
- sched_setaffinity(2) — CPU 어피니티 설정을 통한 context switch 및 마이그레이션 제어 매뉴얼입니다
- perf-stat(1) — context-switches, cpu-migrations 등 context switch 관련 하드웨어/소프트웨어 이벤트 측정 도구입니다
- proc(5) — /proc/[pid]/status — voluntary_ctxt_switches와 nonvoluntary_ctxt_switches 카운터를 통한 프로세스별 context switch 모니터링 방법을 설명합니다