컨텍스트 스위칭 (Context Switching)

Linux 커널의 Context Switching은 CPU 실행 주체를 전환하는 핵심 경로입니다. 이 문서는 schedule() 호출 시점부터 __switch_to 복귀까지 레지스터(Register)/커널 스택/메모리 컨텍스트 전환 단계를 해부하고, TLB/PCID 비용, FPU 상태 관리, KPTI 영향, ARM64 차이, perf·ftrace·eBPF 기반 병목(Bottleneck) 진단 절차까지 종합적으로 다룹니다.

전제 조건: 이 문서를 이해하려면 프로세스(Process) 관리(task_struct), 프로세스 스케줄러(Scheduler)(schedule 함수), MMU & TLB(페이지 테이블(Page Table), CR3) 기초 지식이 필요합니다.
일상 비유: Context Switching은 여러 프로젝트를 번갈아 처리하는 직원과 비슷합니다. 프로젝트 A를 멈출 때 현재 진행 상황(어디까지 했는지, 메모, 참고 자료)을 서랍에 넣어 보관하고(상태 저장), 프로젝트 B의 서랍을 꺼내 이전에 멈춘 지점부터 재개합니다(상태 복원). CPU는 이 과정을 마이크로초 단위로 수천 번 반복하며 여러 프로세스를 동시에 실행하는 것처럼 보이게 합니다.

핵심 요약

  • 레지스터 전환 — 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 트레이싱bpftracesched: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 주소를 미리 스택에 배치하여 첫 실행 경로를 구성합니다.

단계별 이해

  1. 트리거 이해
    Context Switch가 언제 발생하는지 파악합니다: 타임 슬라이스 만료(HZ 틱), 블로킹 시스템 콜(System Call), 우선순위(Priority) 선점, sched_yield(). vmstat 1의 cs 컬럼으로 초당 횟수를 확인하세요.
  2. schedule() → context_switch() 경로
    schedule()pick_next_task()context_switch() 순으로 호출됩니다. context_switch()가 실제 전환의 핵심으로, 주소 공간 전환과 레지스터 전환을 수행합니다.
  3. 주소 공간 전환 (switch_mm)
    유저 프로세스 간 전환 시 switch_mm_irqs_off()가 CR3를 교체합니다. PCID 지원 CPU에서는 CR3_PCID_NOFLUSH 비트로 TLB를 보존합니다.
  4. 레지스터 전환 (switch_to → __switch_to_asm)
    switch_to 매크로(Macro)가 callee-saved 레지스터(RBX, RBP, R12~R15)를 커널 스택에 push/pop합니다. __switch_to()는 TLS, I/O 비트맵(Bitmap), FPU 상태를 처리합니다.
  5. 커널 스택 전환 원리
    각 프로세스의 thread.sp(스택 포인터)를 교체합니다. 전환 후 새 프로세스의 스택에서 실행이 재개되고, ret 명령으로 이전에 중단된 지점으로 돌아갑니다.
  6. FPU 상태 처리
    FPU를 사용하지 않는 프로세스는 불필요한 XSAVE/XRSTOR를 수행하지 않습니다. CR0.TS 비트를 세트하여 다음 FPU 명령에서 #NM 예외를 발생시키고, 그때 이전 FPU 상태를 저장합니다.
  7. KPTI 추가 비용 확인
    Meltdown 취약 CPU에서는 커널/유저 전환마다 CR3를 두 번 씁니다(커널 페이지 테이블 → 유저 페이지 테이블). cat /sys/devices/system/cpu/vulnerabilities/meltdown으로 상태를 확인하세요.
  8. 성능 분석 및 최적화
    /proc/[pid]/status의 voluntary/nonvoluntary ctxt_switches로 원인을 파악하고, CPU 어피니티·실시간 우선순위·비동기 I/O로 최적화합니다.
  9. 선점 모델 확인
    zcat /proc/config.gz | grep PREEMPT로 현재 커널의 선점 모델을 확인합니다. 서버는 PREEMPT_NONE, 데스크톱은 PREEMPT_VOLUNTARY/FULL, 실시간 시스템은 PREEMPT_RT를 사용합니다. 선점 모델에 따라 비자발적 Context Switch 빈도가 크게 달라집니다.
  10. 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는 다음 시점에 발생합니다:

  1. 타임 슬라이스 만료 — 스케줄러 틱 인터럽트(CONFIG_HZ, 기본 250Hz)
  2. 블로킹 시스템 콜 — I/O 대기, sleep(), wait(), mutex_lock()
  3. 우선순위 선점 — 높은 우선순위 프로세스가 깨어남(wake_up())
  4. 명시적 양보(Yield) — sched_yield(), cond_resched()
  5. 인터럽트 후 선점 — 인터럽트 핸들러(Handler) 종료 후 TIF_NEED_RESCHED 플래그 확인
ℹ️

빈도: 일반적인 리눅스 서버는 초당 100~10,000번의 Context Switch가 발생합니다. CPU-bound 워크로드는 낮고, I/O-bound 워크로드는 높습니다. vmstat 1cs 컬럼으로 확인 가능합니다. 데이터베이스 서버는 수만 회까지 올라갈 수 있습니다.

Context Switch 전체 흐름 Process A (Running) schedule() pick_next_task context_switch() switch_mm (CR3) switch_to (RSP) __switch_to (TLS) Process B (Running) [ 상태 저장 — Process A ] ① callee-saved 레지스터 push (RBX, RBP, R12-R15) → 커널 스택 ② thread.sp ← RSP (스택 포인터 저장) ③ FPU 상태: CR0.TS 세트 (Lazy — 다음 FPU 사용 시까지 지연) ④ CR3 ← next-pgd | PCID (주소 공간 전환, NOFLUSH 비트로 TLB 보존) [ 상태 복원 — Process B ] ① RSP ← thread.sp (스택 포인터 복원) ② callee-saved 레지스터 pop (RBX, RBP, R12-R15) ← 커널 스택 ③ TLS(FS/GS base), I/O 비트맵, 디버그 레지스터 복원 ④ ret → finish_task_switch() → Process B 사용자 공간 재개 범례 실행 중 프로세스 스케줄러 선택 핵심 전환 함수 전환 완료 프로세스

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_threadsptask_structthread.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()까지 이어지는 전체 호출 체인을 추적합니다. 각 계층이 어떤 역할을 분담하는지 확인하세요.

schedule() → context_switch() 호출 체인 schedule() kernel/sched/core.c __schedule(sched_mode) IRQ 비활성화 + rq_lock + pick_next_task SM_PREEMPT / SM_NONE 분기 context_switch(rq, prev, next) ① switch_mm_irqs_off() — 주소 공간 전환 ② switch_to(prev, next, prev) — 레지스터/스택 전환 ③ finish_task_switch(prev) switch_mm_irqs_off() arch/x86/mm/tlb.c CR3 교체 + PCID/TLB 관리 __switch_to_asm() arch/x86/entry/entry_64.S callee-saved 저장/복원 + RSP 교체 __switch_to() arch/x86/kernel/process_64.c FPU/TLS/IO 비트맵/디버그 레지스터 주소 공간 레지스터/스택
schedule() → context_switch() 두 갈래 전환 경로: 주소 공간(switch_mm_irqs_off)과 레지스터/스택(__switch_to_asm → __switch_to)

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_mmmm은 유저 공간 주소 공간을 나타냅니다. 커널 스레드는 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)할 수 있으므로 지연시킵니다.
context_switch() mm 분기: 유저 프로세스 vs 커널 스레드 context_switch(rq, prev, next) mm = next->mm 검사 next->mm != NULL ? Yes (유저) 유저 프로세스 경로 ① membarrier_switch_mm() ② switch_mm_irqs_off(oldmm, mm) → CR3 = next->mm->pgd | PCID ③ prev가 커널 스레드면 active_mm 반환 비용: CR3 쓰기 50~500ns No (커널) 커널 스레드 경로 (active_mm 차용) ① next->active_mm = prev->active_mm ② mmgrab_lazy_tlb(oldmm) — 참조 카운트↑ ③ enter_lazy_tlb() — Lazy TLB 모드 → CR3 변경 없음 (커널 매핑 동일) 비용: ~0ns (주소 공간 전환 생략) switch_to(prev, next, prev) RSP 교체 → CPU 제어권 전환 (돌아올 수 없는 지점) finish_task_switch(prev) — 새 프로세스 관점에서 실행 ① prev->on_cpu = 0 — 이전 태스크 "실행 중" 해제 ② rq->prev_mm이면 mmdrop_lazy_tlb() — 차용한 mm 반환 ③ prev가 TASK_DEAD면 put_task_struct() — 좀비 회수 ④ balance_callbacks() — 부하 분산 콜백 처리

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_structactive_mm으로 차용합니다. 커널 주소 공간 매핑은 모든 프로세스에서 동일하므로(상위 256TB, PGD 상반부) 이것이 안전합니다.

active_mm 차용: 커널 스레드의 페이지 테이블 공유 유저 프로세스 A mm = A_mm (유저 매핑) active_mm = A_mm pgd: 유저 VA(하위 128TB) + 커널 VA(상위 128TB) 전환 커널 스레드 kworker mm = NULL (유저 매핑 없음) active_mm = A_mm (차용!) CR3 변경 없음 → TLB 유지 커널 VA만 접근 → 안전 전환 유저 프로세스 B mm = B_mm (유저 매핑) active_mm = B_mm CR3 = B_mm->pgd | PCID_B finish: mmdrop(A_mm) 페이지 테이블 공유가 안전한 이유 하위 PGD (유저 공간 0~128TB) — 프로세스마다 다름 상위 PGD (커널 공간 128TB~256TB) — 모든 프로세스 동일 active_mm 차용 시 참조 카운트 관리 A→kworker: mmgrab(A_mm) → mm_count++ (구조체 유지) kworker→B: prev->active_mm = NULL, rq->prev_mm = A_mm finish_task_switch(): mmdrop(A_mm) → mm_count-- Lazy TLB 모드와 원격 플러시 커널 스레드 실행 중 TLB shootdown IPI 수신 시: → is_lazy 플래그만 세트 (실제 TLB 무효화 생략) → 다음 유저 전환 시 TLB 전체 플러시로 처리
💡

성능 함의: 커널 스레드(kworker, ksoftirqd 등) 간 Context Switch는 주소 공간 전환이 없어 매우 빠릅니다(~300ns). 반면 유저 프로세스 간 전환은 CR3 교체, TLB 관리, PCID 할당 등의 비용이 추가됩니다. kthread_create()로 생성한 워커 스레드가 io_uring이나 workqueue를 통해 커널 내부에서 작업을 처리하면, 유저/커널 경계를 넘지 않으므로 Context Switch 비용이 절감됩니다.

thread_struct 구조체 필드 분석

thread_structtask_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_asmmovq %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_asmpush 명령들이 이 구조체와 정확히 대응됩니다.

/* 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_asmpushq %rbp부터 pushq %r15까지 순서대로 push하면 메모리 레이아웃은 구조체 선언의 역순이 됩니다. 이 구조체는 그 역순을 보정해 C 코드에서 필드 이름으로 직접 접근할 수 있게 합니다.
  • r15~bx 필드x86-64 System V ABI의 callee-saved 레지스터 6개입니다. 함수 호출 규약상 이 레지스터들은 호출된 함수(callee)가 보존해야 합니다. __switch_to_asmcall이 아닌 어셈블리 경계에서 스택을 교체하므로 반드시 직접 저장·복원해야 합니다.
  • 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_idtlb_gen은 원격 TLB 플러시(IPI 기반) 추적에 사용됩니다. 다른 CPU가 이 mm의 페이지 테이블을 수정해 tlb_gen을 증가시키면, 다음 번 이 CPU에서 해당 mm으로 전환될 때 choose_new_asid()need_flush = true를 반환합니다.
switch_mm_irqs_off() — PCID/TLB 분기 흐름 switch_mm_irqs_off(prev, next) IRQ 비활성화 상태에서 호출 prev == next? Yes load_new_mm_cr3(pgd, 0) No choose_new_asid(next, ctx_id) CPU 로컬 PCID 슬롯 탐색 → new_asid, need_flush need_flush? Yes (플러시 필요) load_new_mm_cr3(pgd, asid, true) CR3 쓰기 + TLB 전체 무효화 (PCID 미지원 또는 슬롯 재사용) No (PCID 히트) write_cr3(cr3 | NOFLUSH) CR3_PCID_NOFLUSH 비트 세트 TLB 엔트리 보존 (고성능 경로) next_tlb→ctx_id/tlb_gen 갱신 (원격 플러시 추적)
switch_mm_irqs_off() 내부 PCID 분기: need_flush 여부에 따라 TLB 전체 플러시 또는 NOFLUSH CR3 쓰기로 분기

커널 스택 구조와 전환

각 프로세스(스레드)는 독립적인 커널 스택을 가집니다. x86-64에서 기본 크기는 16KB(4페이지(Page))이며, CONFIG_THREAD_SIZE_ORDER로 설정됩니다.

커널 스택 전환: switch_to 전후 Process A 커널 스택 (16KB, 전환 전 RSP) thread_info (스택 최하단, 8B) ... 커널 함수 호출 프레임 ... schedule() 프레임 context_switch() 프레임 R15 (push) R14 (push) R13 (push) R12 (push) RBX (push) RBP (push) ← RSP (thread.sp 저장) (미사용 영역) switch_to() thread.sp 교환 Process B 커널 스택 (16KB, 전환 후 RSP) thread_info (스택 최하단, 8B) ... 커널 함수 호출 프레임 ... schedule() 프레임 context_switch() 프레임 R15 (복원 대기) R14 (복원 대기) R13 (복원 대기) R12 (복원 대기) RBX (복원 대기) RBP (복원 대기) ← RSP (thread.sp 복원) (미사용 영역)
/* 커널 스택 구조 (높은 주소 → 낮은 주소) */
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.spswitch_to()에서 교환되는 커널 스택 포인터입니다. __switch_to_asmprev->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)가 발생하여 인접 메모리 훼손을 방지합니다.

Guard Page (4KB, unmapped) S + THREAD_SIZE = task_top_of_stack() pt_regs (syscall/인터럽트 진입 상태) 커널 함수 호출 프레임 (RBP 체인, 지역 변수, callee-saved) ← RSP (현재 스택 포인터) 미사용 스택 공간 (오버플로 시 아래 방향으로 소진) thread_info (flags, syscall_work) STACK_END_MAGIC = 0x57AC6E9D S (task->stack) = end_of_stack() Guard Page (4KB, unmapped) 높은 주소 낮은 주소 THREAD_SIZE = 16KB (4 pages) 오버플로 방향 ↓ Guard Page (매핑 없음 — 접근 시 #PF) 사용 가능 스택 영역
/* 스택 경계 계산 함수 */

/* 스택 최하단 (낮은 주소) — 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)
 */
VMAP_STACK 비활성 시: 가드 페이지가 없으므로 스택 최하단(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
VLA 제거 역사: Linux 커널은 v4.20(2018)에서 모든 VLA를 제거했습니다(-Wvla 빌드 플래그 추가). VLA는 컴파일 타임에 스택 사용량을 예측할 수 없게 하여 CONFIG_FRAME_WARN 검사를 무력화하며, 공격자가 크기 매개변수를 제어하면 스택 고갈을 유발할 수 있었습니다.

스택 오버플로 전파와 훼손 영역

커널 스택은 높은 주소에서 낮은 주소로 성장합니다. 함수 내부에서 로컬 버퍼 오버플로가 발생하면, 데이터는 높은 주소 방향(호출자의 프레임)으로 전파되면서 단계적으로 더 심각한 훼손을 유발합니다.

높은 주소 thread_info (pre-4.9, 스택 최상단) cred 포인터, flags, preempt_count 저장된 RIP (리턴 주소) 저장된 RBP (프레임 포인터) 스택 카나리 (Stack Canary) 호출자(caller)의 지역 변수 포인터, 인덱스, 플래그 등 호출자 프레임 경계 현재 함수 지역 변수 (정상 영역) char buf[64] ← 오버플로 시작점 memcpy(buf, src, 256) — 경계 초과! 낮은 주소 오버플로 전파 방향 ↑ Phase 1 — 데이터 손상 미묘한 논리 오류, 간헐적 크래시 Phase 2 — 디버깅 불가 RBP 체인 파괴 → 스택 트레이스 손실 Phase 3 — 코드 실행 RIP 변조 → ROP/임의 코드 실행 Phase 4 — 권한 상승 thread_info→cred 변조 (pre-4.9) 감지선 CONFIG_THREAD_INFO_IN_TASK (v4.9+, x86_64 기본) • thread_info를 task_struct 내부로 이동 → 스택에서 제거 • Phase 4(thread_info 덮어쓰기) 공격 경로 완전 차단 • 스택 오버플로 시 thread_info 대신 Guard Page에 도달 → 즉시 #PF 방어 조합 효과 VMAP_STACK + THREAD_INFO_IN_TASK + Stack Protector: ✓ Phase 1: 카나리가 함수 반환 시 감지 ✓ Phase 2~3: 카나리가 RBP/RIP 변조 전에 감지 ✓ Phase 4: thread_info가 스택에 없으므로 공격 불가 ✓ 스택 깊이 고갈: Guard Page가 즉시 하드웨어 트랩 발생
/* 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) 효과 → 커널 메모리 임의 접근
 */
관련 문서: 스택 훼손에 대한 방어 메커니즘의 종합 비교와 실제 CVE 사례는 커널 하드닝 — 스택 훼손 방어 체계 종합을 참조하십시오.

스택 훼손 디버깅

커널 스택 훼손은 발생 시점과 증상 발현 시점이 다를 수 있어 디버깅이 까다롭습니다. 다음 도구들을 활용하면 스택 문제를 사전에 탐지하거나 사후에 분석할 수 있습니다.

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
 --- <유효하지 않은 프레임 포인터 — 추적 중단> ---
디버깅 팁: RBP/RIP에 반복 패턴(0x41414141, 0xCC 등)이 보이면 버퍼 오버플로를 의심합니다. KASAN이 활성화되어 있다면 0xCC 패턴은 KASAN 레드존 마커일 수 있습니다. 0x57AC6E9D(STACK_END_MAGIC)가 훼손되었다면 스택 깊이 고갈이 원인입니다.

새 태스크의 첫 번째 실행

fork()나 clone()으로 생성된 새 프로세스는 아직 단 한 번도 CPU를 얻은 적이 없습니다. switch_to()는 이전에 저장해 둔 레지스터를 복원하는 방식으로 동작하는데, 새 프로세스에는 "저장해 둔" 상태가 없습니다. 이 문제를 해결하기 위해 copy_thread()가 커널 스택을 미리 셋업하고, ret_from_fork 주소를 복귀 주소로 배치합니다.

새 태스크의 첫 실행: copy_thread → ret_from_fork fork() / clone() 새 task_struct 할당 커널 스택 할당 (16KB) copy_thread() ① pt_regs 복사 (부모 상태) ② childregs->ax = 0 (자식=0) ③ thread.sp ← 스택 셋업 ④ ip ← ret_from_fork switch_to() RSP ← thread.sp pop R15..RBP (모두 0) ret_from_fork schedule_tail() 호출 유저 공간 복귀 copy_thread()가 설정하는 새 태스크 커널 스택 레이아웃 높은 주소 (스택 최상단) pt_regs (유저 레지스터 상태) (예약 영역) R15=0, R14=0, R13=0 R12=0, RBX=0, RBP=0 ret_from_fork 주소 ← thread.ip / RSP switch_to() 후 ret 명령으로 ret_from_fork 진입 ret_from_fork 처리 순서 ① schedule_tail(prev) — prev 태스크 정리 ② 커널 스레드: 등록된 fn() 직접 호출 ③ 유저 프로세스: syscall_exit → sysret
/* 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.ccopy_thread()arch/x86/entry/entry_64.Sret_from_fork은 새 태스크의 첫 번째 Context Switch를 준비합니다.

  • copy_thread() 커널 스레드 경로args->fn이 설정된 경우 커널 스레드입니다. pt_regs를 0으로 초기화하고, R12에 실행 함수, R13에 인자를 저장합니다. thread.ipret_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.spchildregs - 6 * sizeof(long)으로 설정하여 __switch_to_asmpop 시퀀스와 정확히 매칭됩니다. 이 트릭 덕분에 새 태스크도 기존 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_forkschedule_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 과정이 명확해집니다.

task_struct ↔ thread_struct ↔ 커널 스택 메모리 관계 task_struct (슬랩 할당) 약 6~8KB, kmem_cache pid, tgid, state, flags *stack → 커널 스택 베이스 주소 *mm, *active_mm → mm_struct sched_entity, prio, policy on_cpu, on_rq, wake_entry thread_struct (임베드됨) sp — 커널 스택 포인터 (핵심!) sp0 — Ring-0 스택 최상단 (TSS용) fsbase, gsbase — TLS 포인터 fpu — FPU/XSTATE 상태 (최대 2.5KB) 커널 스택 (16KB, 페이지 할당) 낮은 주소 (스택 최하단) thread_info (8B) ... 가용 스택 공간 ... schedule() 프레임 context_switch() 프레임 inactive_task_frame R15 R14 R13 R12 RBX ret_addr (RIP 역할) ← thread.sp 포인터 위치 (미사용 스택 영역) pt_regs (유저 레지스터) RIP, RSP, RFLAGS, CS, SS 시스콜/인터럽트 진입 시 저장 ← sp0 (TSS, 스택 최상단) 높은 주소 (스택 최상단) *stack thread.sp 핵심 포인터 관계 task->stack = 스택 베이스 주소 thread.sp = 현재 RSP 저장 위치 thread.sp0 = 스택 최상단 (TSS) task_pt_regs(p) = stack + THREAD_SIZE - pt_regs thread_info = stack 최하단 주소 current = per-CPU 변수 (GS 기반) switch_to 시 변경 포인트 ① prev->thread.sp ← RSP ② RSP ← next->thread.sp ③ TSS.sp0 ← next->thread.sp0 ④ per-CPU current ← next
레지스터 종류 예시 (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 동작 원리

Lazy FPU 저장 메커니즘: CR0.TS → #NM 예외 → XSAVE/XRSTOR Context Switch switch_fpu_prepare() stts() → CR0.TS=1 전환 Process B 실행 정수/메모리 연산 FPU 미사용 → 정상 FPU 명령 #NM 예외 발생 CR0.TS=1 이므로 FPU 명령 실행 시 Device Not Available do_device_not_available() ① XSAVE: A 상태 저장 ② XRSTOR: B 상태 복원 ③ clts() → CR0.TS=0 XSAVE 컴포넌트 구조 (크기별) x87 FPU ST0~ST7, FPU 제어 160B SSE (XMM) XMM0~XMM15 (128bit×16) 256B (MXCSR 포함) AVX (YMM) YMM 상위 128bit×16 256B 추가 AVX-512 (ZMM+Opmask) ZMM 상위 256bit×32 + k0~k7 ~1,664B 추가 합계 (최대) AVX-512 사용 시: ~2.5KB SSE만: ~576B Eager 방식 (구형): 매 Context Switch마다 XSAVE/XRSTOR FPU 미사용 프로세스도 2.5KB 저장/복원 → 낭비 AVX-512 시: ~2,500ns 추가 비용/스위치 현재는 보안상 Eager 모드 기본값 (Spectre 완화) Lazy 방식 (현대): FPU 실제 사용 시에만 저장/복원 FPU 미사용 프로세스: 추가 비용 없음 FPU 사용 프로세스: #NM 예외 1회 + XSAVE/XRSTOR CONFIG_X86_DISABLE_EXTENDED_SIMD_SAVE로 제어
/* 전환 시: 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(&current->thread.fpu);

    /* 현재 프로세스의 FPU 상태 복원 */
    fpu__restore(&current->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 기반 TLB 관리: Context Switch 전후 비교 PCID 미사용 (전통 방식) CR3 = pgd(B) → TLB 전체 무효화! Context Switch 전 TLB (Process A) VA:0x7f00 → PA:0x1a00 [A] VA:0x4000 → PA:0x2b00 [A] VA:0x5000 → PA:0x3c00 [A] 전체 TLB 플러시 Context Switch 후 TLB (Process B) (비어있음 — 모두 TLB Miss) (페이지 테이블 워크 필요) 성능 저하: 전환 후 TLB Miss 100% PCID 사용 (현대 방식) CR3 = pgd(B) | PCID_B | NOFLUSH → TLB 유지! CR3 레지스터 구조 (64비트) bit63 bits[51:12] = 페이지 테이블 물리 주소 bits[11:0] = PCID (12비트) NOFLUSH Context Switch 후 TLB (태그로 공존) [PCID=1/A] VA:0x7f00 → PA:0x1a00 (재사용 가능) [PCID=1/A] VA:0x4000 → PA:0x2b00 (재사용 가능) [PCID=2/B] VA:0x8000 → PA:0x4d00 (Process B 항목) [PCID=2/B] VA:0x3000 → PA:0x5e00 (Process B 항목) 최대 4096개(2¹²) PCID 동시 보유 성능 향상: TLB Miss ~5% (95% 절감) vs

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 단위)

간접 비용 (훨씬 큼)

⚠️

간접 비용 함정: 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) 공격을 방지합니다.

Spectre V2 완화: Context Switch 시 분기 예측기 격리 IBPB (Indirect Branch Prediction Barrier) Context Switch 시 BTB 전체 플러시 이전 프로세스의 분기 예측 이력 제거 WRMSR IA32_PRED_CMD = 1 비용: ~100-200ns/전환 조건: ASID 변경 시에만 발행 STIBP (Single Thread Indirect Branch Predictors) SMT(하이퍼스레딩) 형제 코어 간 분기 예측기 공유 방지 IA32_SPEC_CTRL.STIBP = 1 비용: 상시 5~10% IPC 감소 조건: SMT 활성 + 다른 프로세스 eIBRS (Enhanced IBRS) 하드웨어 레벨 분기 예측 격리 Ring 전환 시 자동 BTB 분리 IBPB 빈도 감소 가능 비용: ~0ns (하드웨어 처리) Intel Ice Lake+, AMD Zen 3+ Context Switch IBPB 불필요 Context Switch 시 IBPB 발행 조건 (switch_mm_irqs_off 내부) IBPB 발행 결정 로직 (arch/x86/mm/tlb.c) ① 다른 ASID(프로세스)로 전환 AND IBPB 미발행 상태 → IBPB 발행 ② TIF_SPEC_IB(indirect branch 제한 플래그) 설정된 태스크 간 전환 → IBPB 발행 ③ SECCOMP 적용 프로세스 → dumpable이 아니면 IBPB 강제 (prctl PR_SET_SPECULATION_CTRL) ④ eIBRS 지원 CPU → 대부분의 경우 IBPB 생략 가능 (하드웨어 격리 의존) CPU 세대별 Spectre 완화 비용 (Context Switch 추가분) Skylake (2015) IBPB+STIBP: +200~400ns Coffee Lake (2018) IBPB+STIBP: +150~300ns Alder Lake+ (eIBRS) eIBRS 하드웨어: +~0ns AMD Zen 3+ (eIBRS) AutoIBRS: +~0ns 참고: mitigations=off 커널 파라미터로 모든 완화 비활성화 가능 (보안 위험! 벤치마크/테스트 전용)
/* 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를 완전히 콜드 상태로 만들기 때문에 비용이 매우 큽니다.

SMP CPU 마이그레이션: 부하 분산 vs 캐시 비용 CPU 0 런큐: P1(높은부하), P2, P3 L1/L2 Cache: P1 데이터 HOT TLB: P1 VA→PA 엔트리 HOT P1 실행 중 P2 대기 NUMA 노드 0 부하: 80% (불균형) CPU 1 런큐: (비어있음) L1/L2 Cache: P1 데이터 COLD TLB: P1 엔트리 없음 P1 이동 후 실행 (캐시/TLB 워밍업 필요) NUMA 노드 1 부하: 40% (균형 후) 부하 분산기 load_balance() sched_domain 순회 __migrate_task() 주기: 1ms~200ms ⟶ P1 마이그레이션 ⟶ 마이그레이션 비용 (같은 NUMA 노드) L1 캐시 콜드: +5~50µs / 접근당 ~4ns 추가 TLB 워밍업: +10~100µs (전환 직후) 마이그레이션 비용 (NUMA 간) 원격 메모리 접근: 40~100ns (로컬 대비 2~5배) LLC Miss: 원격 DRAM 직접 접근 (~80ns)
/* 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

자발적(Voluntary) vs 비자발적(Involuntary) Context Switch 자발적 Context Switch sleep() mutex_lock() wait_event() / I/O set_current_state(INTERRUPTIBLE) schedule() → SM_NONE deactivate_task(prev) → 런큐에서 제거 context_switch() 수행 prev: 대기 큐에서 이벤트 대기 wake_up() 호출 시 런큐 복귀 비자발적 Context Switch 타임 슬라이스 만료 고우선순위 깨어남 IRQ 후 선점 TIF_NEED_RESCHED 플래그 설정 preempt_schedule_irq() → SM_PREEMPT prev->state = RUNNING (런큐 유지!) context_switch() 수행 prev: 런큐에 남아 즉시 재실행 가능 pick_next_task에서 다시 선택될 수 있음
종류 발생 원인 커널 경로 성능 영향 최적화
자발적 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 감소 전략

  1. CPU Affinity 설정 — 프로세스를 특정 CPU에 고정해 캐시 재사용률 향상
  2. 스레드 풀 크기 최적화 — CPU 코어 수와 동일하게 설정(I/O 바운드는 2~4배)
  3. 비동기 I/O 사용 — io_uring, epoll로 블로킹 줄이기
  4. 배치 처리 — 여러 요청을 묶어서 처리, Context Switch 횟수 감소
  5. 실시간 정책 — SCHED_FIFO/RR로 비자발적 Context Switch 제거
  6. 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, &param);

/* 커널 스레드 생성 (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, &param);

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) 세 가지 경로에서 호출되며, 각 경로마다 다른 선점 조건과 비용 특성을 가집니다.

__schedule() 내부 분기: 세 가지 호출 경로 Preempt 경로 preempt_schedule_irq() TIF_NEED_RESCHED + IRQ 복귀 Voluntary 경로 schedule() 직접 호출 sleep/wait/mutex/yield Cooperative 경로 cond_resched() might_sleep() / 긴 루프 __schedule(SM_PREEMPT) SM_NONE / SM_PREEMPT 구분 ① rq_lock(rq) + local_irq_disable() 런큐 잠금 + 인터럽트 비활성화 (임계 구간 시작) ② pick_next_task(rq, prev, rf) DL > RT > CFS > IDLE 순 탐색 fast path: CFS 단독이면 pick_next_task_fair() 직행 prev == next ? Yes 전환 없음 (rq unlock) No ③ context_switch(rq, prev, next, rf) switch_mm_irqs_off() → 주소 공간 전환 switch_to(prev, next, prev) → 레지스터/스택 전환 ④ finish_task_switch(prev) prev 참조 해제, 시그널/TIF 처리, rq unlock 선점 조건 검사 preempt_count == 0? TIF_NEED_RESCHED? SM_PREEMPT이면 prev->state 변경 없음 (RUNNING 유지) SM 모드 SM_PREEMPT: 비자발적 SM_NONE: 자발적 cond_resched: 협력적 context_switch: 전환 핵심
/* 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 모드가 기본값이지만, 내부 최적화로 실제 비용은 상당히 절감되었습니다.

FPU/XSTATE 전환: Lazy vs Eager 모드 비교 Lazy 모드 (구형, 5.0 이전 기본) ① Context Switch: CR0.TS = 1 (FPU 저장 생략) ② 새 프로세스: 정수 연산만 → 정상 동작 ③ FPU 명령 실행 → #NM 예외 발생! ④ 핸들러: XSAVE(prev) + XRSTOR(next) ⑤ CR0.TS = 0 → 이후 FPU 정상 사용 장점: FPU 미사용 시 비용 0 단점: Spectre v1 사이드 채널 취약 Eager 모드 (현대, 5.0+ 기본) ① Context Switch: 즉시 XSAVE(prev) ② XRSTOR(next) 또는 init state 로드 ③ 새 프로세스: FPU 즉시 사용 가능 ④ #NM 예외 없음 → 예측 가능한 지연 ⑤ XSAVEOPT: 변경된 컴포넌트만 저장 init state 감지 → XRSTOR 생략 최적화 단점: 항상 XSAVE/XRSTOR 비용 발생 장점: Spectre 안전, 예측 가능한 지연 XSTATE 컴포넌트별 저장 비용 (XSAVEOPT 기준) x87 (160B) ~20ns SSE/XMM (256B) ~40ns AVX/YMM (256B) ~50ns AVX-512/ZMM+Opmask (~1.6KB) ~200ns AMX TILE (8KB) ~800ns 합계: ~2.5KB+ ~1,100ns (최악) XSAVEOPT / XSAVES / XRSTORS 최적화 기법 XSAVEOPT: XSTATE_BV 비트맵에서 변경된 컴포넌트만 저장 (미변경 시 메모리 쓰기 생략) XSAVES: 커널 모드 전용, compacted format으로 저장 (메모리 절약) init state 감지: 컴포넌트가 초기값이면 XRSTOR 시 하드웨어가 자동으로 0 초기화 (메모리 읽기 생략)
/* 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 fpulast_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와 시스템 콜에 미치는 영향을 분석합니다.

KPTI 이중 PGD: 유저/커널 페이지 테이블 분리 유저 PGD (User CR3) 유저 코드/데이터 매핑 (전체) 커널 진입점 최소 매핑 entry_SYSCALL_64, IDT, TSS만 커널 나머지: 매핑 없음 (보호) CR3 = pgd + 0x1000 | PCID_USER 커널 PGD (Kernel CR3) 유저 코드/데이터 매핑 (전체) 커널 코드/데이터 (전체) vmalloc, 모듈, per-cpu 커널 스택, 직접 매핑 전부 CR3 = pgd | PCID_KERN CR3 전환 타이밍 syscall 진입: User CR3 → Kernel CR3 syscall 복귀: Kernel CR3 → User CR3 switch_mm: A.Kernel → B.Kernel CR3 Process A → Process B Context Switch (KPTI 활성 시) CR3 전환 타임라인 시간 → Process A User CR3(A) CR3=K(A) ~30ns Kernel (A) schedule() CR3=K(B) ~50ns switch_to(A→B) 레지스터/스택 전환 Kernel (B) finish_task_switch CR3=U(B) ~30ns Process B User CR3(B) KPTI 비활성화 시 CR3 쓰기: 1회 (switch_mm만) 추가 비용: ~50ns AMD Zen2+, 신형 Intel: 기본 비활성화 KPTI 활성화 시 CR3 쓰기: 3회 (진입 + switch_mm + 복귀) 추가 비용: ~110ns (시스템 콜당 ~60ns) PCID 사용 시 NOFLUSH로 TLB 보존 → 영향 최소화
/* 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.carch/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 플러시를 최소화합니다.

TLB 플러시 전략: 로컬 vs 원격 무효화 전체 TLB 플러시 CR3 재로드 (PCID 미사용) mov cr3, new_pgd 비용: ~500ns (TLB 전체 무효화) 간접 비용: TLB Miss 폭풍 사용: PCID 미지원 구형 CPU PCID + NOFLUSH CR3 bit63 = 1 (NOFLUSH) mov cr3, pgd | PCID | NOFLUSH 비용: ~50ns (TLB 유지) TLB Hit 95%+ 보존 사용: Context Switch (switch_mm) INVPCID 선택적 무효화 특정 PCID의 특정 VA만 무효화 invpcid type=0: addr+PCID 비용: ~10ns (단일 엔트리) 나머지 TLB 완전 보존 사용: munmap, mprotect, page migration 원격 TLB 무효화 (IPI 기반) CPU 0 (요청자) munmap(addr, len) PTE 업데이트 완료 로컬 TLB 무효화 flush_tlb_mm_range() IPI 전송 CPU 1 (대상) IPI 수신 → 인터럽트 flush_tlb_func() 해당 VA/PCID 무효화 ACK 반환 IPI 전송 CPU 2 (대상) mm이 활성 상태이면 TLB 무효화 수행 비활성이면 lazy 플래그만 ACK 반환 INVPCID 명령 타입별 동작 Type 0: 개별 특정 PCID + 특정 VA 가장 정밀, ~10ns Type 1: PCID 전체 특정 PCID의 모든 VA 프로세스별 TLB 클리어 Type 2: 전체 모든 PCID + 모든 VA CR3 재로드와 동일 Type 3: 글로벌 제외 전체 Global 비트 TLB 유지 커널 매핑 보존 Lazy TLB 모드: 커널 스레드의 TLB 최적화 커널 스레드(mm=NULL)로 전환 시 enter_lazy_tlb() 호출 → CR3 변경 없이 이전 mm의 페이지 테이블 차용 (active_mm) 커널 공간은 모든 mm에서 동일하므로 TLB 유지가 안전. 원격 TLB shootdown IPI 수신 시 lazy 플래그만 세트하고, 다음 유저 전환 시 처리
/* 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 비용 구조: 직접 비용 vs 간접 비용 직접 비용 (Direct Cost) 레지스터/상태 저장·복원 시간 callee-saved push/pop: ~100ns thread.sp 교환: ~10ns CR3 쓰기 (PCID): ~50ns CR3 쓰기 (PCID 없음): ~500ns TSS sp0 업데이트: ~20ns TLS MSR (WRMSR x2): ~50ns XSAVE (SSE만): ~200ns XSAVE (AVX-512): ~800ns 총 직접 비용: 300ns ~ 1.5us 간접 비용 (Indirect Cost) 캐시/TLB/분기예측기 오염 비용 L1d 캐시 콜드 스타트 32~64KB 교체: 5~50us L1i 캐시 콜드 스타트 코드 패턴 변경: 3~30us L2 캐시 미스 256KB~1MB 영역: 10~100us TLB Miss 폭풍 (PCID 없음) 전체 VA 재번역: 20~200us BTB 오염 분기 예측 실패: 2~20us 총 간접 비용: 10us ~ 500us CPU 세대별 Context Switch 비용 벤치마크 (lmbench lat_ctx) Intel Skylake (2015) ~2.5us (KPTI + PCID) Intel Alder Lake (2021) ~1.4us (하드웨어 완화) AMD Zen 4 (2022) ~1.0us (KPTI 불필요) Apple M2 (ARM, 2022) ~0.8us (ASID 16-bit) AWS Graviton 3 (ARM) ~0.9us (Neoverse V1) 참고: 위 수치는 2-프로세스 ping-pong 벤치마크 (lmbench lat_ctx -s 0 2) 기준. 실제 워크로드에서는 간접 비용이 추가되어 10~100배 높을 수 있음
# 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 & 선점 모델을 참고하세요.

참고 링크