Context Switching

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

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

핵심 요약

  • 레지스터 전환 — CPU의 범용 레지스터(RAX~R15), 명령 포인터(RIP), 플래그(RFLAGS)를 task_struct의 thread 구조체에 저장·복원합니다.
  • 커널 스택 전환 — 모든 프로세스는 독립적인 커널 스택(8KB 또는 16KB)을 가지며, RSP/RBP 레지스터를 교체하여 전환합니다.
  • 주소 공간 전환 — CR3 레지스터(페이지 테이블 베이스)를 변경해 가상 주소 공간을 교체합니다. 커널 스레드는 이 단계를 건너뜁니다.
  • TLB 관리 — PCID(Process Context Identifier)를 사용하면 주소 공간을 바꿔도 TLB를 비우지 않아 성능이 크게 향상됩니다.
  • Lazy FPU — FPU/SIMD 레지스터(최대 2KB)는 실제 사용 시점까지 저장을 지연해 불필요한 비용을 줄입니다.
  • KPTI 영향 — Meltdown 취약점 완화(KPTI)로 커널/유저 공간 전환 시 CR3를 두 번 교체하는 추가 비용이 발생합니다.
  • 직접 비용 vs 간접 비용 — 레지스터 저장·복원의 직접 비용(1~2µs)보다 캐시 오염·TLB 미스로 인한 간접 비용이 훨씬 큽니다.
  • eBPF 트레이싱bpftracesched:sched_switch 트레이스포인트로 Context Switch를 실시간 분석할 수 있습니다.
  • 선점 모델 — 커널은 PREEMPT_NONE(서버 기본), PREEMPT_VOLUNTARY, PREEMPT_FULL, PREEMPT_RT 4가지 모델을 지원합니다. 선점 모델이 Context Switch 빈도와 레이턴시를 결정합니다.
  • CPU 마이그레이션 — 부하 분산기가 태스크를 다른 CPU로 이동하면 캐시·TLB가 모두 콜드 상태가 되어 성능 비용이 큽니다. CPU 어피니티로 마이그레이션을 제한할 수 있습니다.
  • 새 태스크 첫 실행 — fork()된 새 프로세스는 switch_to()로 복원할 레지스터가 없어, copy_thread()가 ret_from_fork 주소를 미리 스택에 배치하여 첫 실행 경로를 구성합니다.

단계별 이해

  1. 트리거 이해
    Context Switch가 언제 발생하는지 파악합니다: 타임 슬라이스 만료(HZ 틱), 블로킹 시스템 콜, 우선순위 선점, 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 매크로가 callee-saved 레지스터(RBX, RBP, R12~R15)를 커널 스택에 push/pop합니다. __switch_to()는 TLS, I/O 비트맵, 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. 명시적 양보 — sched_yield(), cond_resched()
  5. 인터럽트 후 선점 — 인터럽트 핸들러 종료 후 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() 함수 심층 분석

어셈블리 래퍼: __switch_to_asm

switch_to 매크로가 최종적으로 호출하는 어셈블리 함수입니다. callee-saved 레지스터만 저장하면 되는 이유는 컴파일러 ABI상 나머지 레지스터는 호출자가 이미 저장했기 때문입니다:

/* arch/x86/entry/entry_64.S */
SYM_FUNC_START(__switch_to_asm)
    /*
     * prev를 저장하고 next를 복원한다.
     * callee-saved 레지스터만 저장: RBX, RBP, R12-R15
     */
    pushq   %rbp
    pushq   %rbx
    pushq   %r12
    pushq   %r13
    pushq   %r14
    pushq   %r15

    /* prev->thread.sp = RSP (스택 포인터 저장) */
    movq    %rsp, TASK_threadsp(%rdi)

    /* RSP = next->thread.sp (스택 포인터 복원) */
    movq    TASK_threadsp(%rsi), %rsp

    /* next의 스택에서 레지스터 복원 */
    popq    %r15
    popq    %r14
    popq    %r13
    popq    %r12
    popq    %rbx
    popq    %rbp

    /* __switch_to(prev, next) 호출 후 ret */
    jmp     __switch_to
SYM_FUNC_END(__switch_to_asm)
ℹ️

RIP은 저장하지 않는다: jmp __switch_to로 끝나기 때문에 반환 주소(RIP)는 이미 스택에 있습니다. __switch_to()return prev로 끝나면 스택에서 pop한 주소로 돌아가는데, 이 주소가 이전에 switch_to를 호출한 지점이므로 Process B의 실행이 자연스럽게 재개됩니다.

__switch_to() C 구현

/* arch/x86/kernel/process_64.c */
__visible struct task_struct *
__switch_to(struct task_struct *prev, struct task_struct *next)
{
    struct thread_struct *prev_thread = &prev->thread;
    struct thread_struct *next_thread = &next->thread;
    int cpu = smp_processor_id();

    /* 1. FPU/SIMD 저장 준비 (Lazy: CR0.TS 세트만 함) */
    switch_fpu_prepare(prev, cpu);

    /* 2. 커널 스택 최상단 주소를 TSS에 기록 (시스템 콜/인터럽트용) */
    this_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next));

    /* 3. TLS (Thread Local Storage) 전환: FS/GS base MSR 업데이트 */
    load_TLS(next_thread, cpu);
    arch_end_context_switch(next);

    /* 4. 디버그 레지스터 복원 (DR0-DR7, 하드웨어 브레이크포인트) */
    if (unlikely(next_thread->debugreg_active))
        switch_to_thread_hw_breakpoint(next);

    /* 5. I/O 권한 비트맵 전환 (ioperm() 사용 시) */
    if (unlikely(prev_thread->io_bitmap || next_thread->io_bitmap))
        tss_update_io_bitmap();

    /* 6. FPU/SIMD 상태 복원 준비 */
    switch_fpu_finish(next);

    /* 7. 특수 레지스터: DS, ES 등은 일반적으로 고정값 */

    return prev;
}

/* context_switch() - kernel/sched/core.c */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next)
{
    /* 주소 공간 전환 */
    if (!next->mm) {
        /* 커널 스레드: 이전 mm 차용 (페이지 테이블 전환 없음) */
        next->active_mm = prev->active_mm;
        if (prev->mm)
            mmgrab(prev->active_mm);
        enter_lazy_tlb(prev->active_mm, next);
    } else {
        /* 유저 프로세스: mm 전환 */
        switch_mm_irqs_off(prev->active_mm, next->mm, next);
        lru_gen_use_mm(next->mm);
    }

    rq->curr = next;
    /* 실제 레지스터 전환 (위 어셈블리) */
    switch_to(prev, next, prev);
    barrier();

    return finish_task_switch(prev);
}

커널 스택 구조와 전환

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

커널 스택 전환: 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 상태 */
    /* ... */
};

새 태스크의 첫 번째 실행

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)
ℹ️

exec() 후 첫 실행: exec()는 기존 프로세스의 주소 공간을 교체합니다. execve 시스템 콜이 완료되면 start_thread()로 pt_regs의 RIP과 RSP를 새 바이너리의 진입점과 유저 스택으로 설정합니다. 이후 sysret으로 복귀할 때 새 바이너리의 첫 명령이 실행됩니다.

저장·복원되는 레지스터

레지스터 종류 예시 (x86-64) 저장 방식 비고
callee-saved RBX, RBP, R12~R15 __switch_to_asm (push/pop) ABI 상 함수가 직접 보존
명령 포인터 RIP 스택의 반환 주소 call/ret으로 자동 관리
스택 포인터 RSP thread.sp 필드 switch_to_asm 핵심 교환
플래그 RFLAGS 스택 (인터럽트 프레임) 인터럽트 재진입 시 복원
TLS 세그먼트 FS, GS base MSR __switch_to() / load_TLS() 스레드 로컬 변수용
페이지 테이블 CR3 switch_mm (write_cr3) PCID 포함
FPU/SIMD x87, SSE, AVX, AVX-512 Lazy XSAVE/XRSTOR 최대 2.5KB, 지연 저장
디버그 DR0~DR7 switch_to_thread_hw_breakpoint() 하드웨어 브레이크포인트 사용 시만

Lazy FPU/SIMD 상태 관리

FPU(x87)/SSE/AVX/AVX-512 레지스터 집합은 크기가 크기 때문에(최대 2.5KB) 매 Context Switch마다 저장하면 심각한 성능 저하가 발생합니다. Linux 커널은 Lazy 저장 전략을 사용합니다.

Lazy FPU 동작 원리

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) */
FPU 상태 크기 명령 저장 조건
x87 FPU + XMM (SSE) 512 B FXSAVE/FXRSTOR 구형 (XSAVE 미지원 시)
XSAVE (컴포넌트 선택) 576B ~ 2.5KB XSAVEOPT/XRSTORS 실제 사용 컴포넌트만
AVX-512 추가 +1,664 B ZMM 레지스터 포함 AVX-512 사용 프로세스만
💡

성능 팁: AVX-512를 사용하는 프로세스와 사용하지 않는 프로세스가 같은 CPU에서 Context Switch되면, FPU 상태 크기 차이(512B vs 2.5KB)로 인해 불균등한 비용이 발생합니다. CPU 어피니티로 AVX-512 워크로드를 격리하면 일관된 성능을 얻을 수 있습니다.

TLB 관리와 PCID

PCID (Process-Context Identifier)

x86에서 CR3(페이지 테이블 포인터)를 변경하면 기본적으로 TLB 전체가 무효화됩니다. PCID는 각 주소 공간에 12비트 태그를 부여해 TLB 항목을 구분하므로, 주소 공간 전환 후에도 이전 TLB 항목을 재사용할 수 있습니다:

PCID 기반 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");
    }
}

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 활성화 시스템 콜 오버헤드
Intel Core i7 (Skylake 이전) 예 (기본) 5~30%
Intel Core (Coffee Lake 이후) 일부 CPU 의존 2~10%
AMD Zen 2 이상 아니오 비활성화 0%
ARM Cortex-A (ARMv8.5+) 일부 모델 CSV3 완화 1~5%
ℹ️

INVPCID + PCID 조합: KPTI 활성화 시에도 PCID를 사용하면 유저/커널 페이지 테이블 각각 별도 PCID를 부여해 TLB 플러시를 최소화합니다. Intel Haswell 이상에서 INVPCID 명령으로 선택적 무효화가 가능합니다.

ARM64 Context Switch 구현

ARM64(AArch64)의 Context Switch는 x86-64와 유사하지만, TTBR0_EL1(유저 페이지 테이블)과 TTBR1_EL1(커널 페이지 테이블)이 분리된 레지스터를 사용합니다.

cpu_switch_to 어셈블리

/* arch/arm64/kernel/entry.S */
SYM_FUNC_START(cpu_switch_to)
    mov     x10, #THREAD_CPU_CONTEXT
    add     x8, x0, x10        /* prev->thread.cpu_context */
    mov     x9, sp

    /* callee-saved 레지스터 저장: x19-x28, fp(x29), lr(x30), sp */
    stp     x19, x20, [x8], #16
    stp     x21, x22, [x8], #16
    stp     x23, x24, [x8], #16
    stp     x25, x26, [x8], #16
    stp     x27, x28, [x8], #16
    stp     x29, x9,  [x8], #16
    str     lr,       [x8]     /* pc = lr (반환 주소) */

    /* next의 레지스터 복원 */
    add     x8, x1, x10        /* next->thread.cpu_context */
    ldp     x19, x20, [x8], #16
    ldp     x21, x22, [x8], #16
    ldp     x23, x24, [x8], #16
    ldp     x25, x26, [x8], #16
    ldp     x27, x28, [x8], #16
    ldp     x29, x9,  [x8], #16
    ldr     lr,       [x8]
    mov     sp, x9             /* 스택 포인터 전환 */

    ret                        /* lr(pc)로 점프 → next 프로세스 재개 */
SYM_FUNC_END(cpu_switch_to)

ARM64 주소 공간 전환

/* arch/arm64/include/asm/mmu_context.h */
static inline void
switch_mm(struct mm_struct *prev, struct mm_struct *next,
          struct task_struct *tsk)
{
    if (prev != next) {
        /*
         * TTBR0_EL1: 유저 공간 페이지 테이블 베이스 레지스터
         * ASID (ARM의 PCID 대응): 16비트, TLB 공유 가능
         */
        cpu_switch_mm(next->pgd, next);
    }
}

static inline void cpu_switch_mm(pgd_t *pgd, struct mm_struct *mm)
{
    BUG_ON(pgd == swapper_pg_dir);

    /* ASID 포함 TTBR0 값 계산 */
    uintptr_t ttbr1 = read_sysreg(ttbr1_el1);
    unsigned long asid = ASID(mm);

    /* TTBR0_EL1 업데이트 (ASID 포함) */
    write_sysreg(__phys_to_ttbr0(virt_to_phys(pgd)) |
                 ((u64)asid <;< 48), ttbr0_el1);
    isb(); /* 명령 장벽: 이후 TLB 워크가 새 TTBR0 사용 */
}

/*
 * ARM64 ASID (Address Space ID):
 * - 8비트 (256개) 또는 16비트 (65536개, CPU 의존)
 * - x86의 PCID와 동일한 역할: TLB 태그로 사용
 * - ASID 소진 시 global TLB 플러시 후 재할당
 */

ARM64 vs x86-64 비교

항목 x86-64 ARM64
레지스터 전환 함수 __switch_to_asm + __switch_to cpu_switch_to (하나로 통합)
callee-saved 수 6개 (RBX,RBP,R12~R15) 12개 (x19~x28,fp,sp)
페이지 테이블 레지스터 CR3 (하나, PCID 포함) TTBR0_EL1 + TTBR1_EL1 (분리)
TLB 태그 PCID (12비트) ASID (8비트 또는 16비트)
FPU 저장 XSAVE/XRSTOR FPSIMD (ld1/st1 명령)
Meltdown 완화 KPTI (CR3 이중 교체) 대부분 미취약 (일부 CSV3)

SMP CPU 마이그레이션과 Context Switch

멀티코어 시스템에서 부하 분산기(Load Balancer)는 태스크를 한 CPU에서 다른 CPU로 이동합니다. 이 CPU 마이그레이션은 Context Switch와 밀접하게 연관되어 있으며, 캐시와 TLB를 완전히 콜드 상태로 만들기 때문에 비용이 매우 큽니다.

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로 오프로드
마이그레이션 범위 대기 시간 캐시 영향 NUMA 영향
같은 코어 (HT 형제) 거의 없음 L1/L2 공유 → 최소 없음
같은 LLC (소켓 내) ~10~50µs L3 공유 → 낮음 없음
다른 LLC (소켓 내) ~50~200µs L3 미스 → 중간 없음
다른 NUMA 노드 ~200µs~2ms 전체 미스 → 높음 원격 메모리 접근 2~5배

자발적 vs 비자발적 Context Switch

종류 발생 원인 커널 경로 성능 영향 최적화
자발적 sleep(), I/O 대기, mutex_lock(), wait_event() schedule() 직접 호출 불가피하지만 낮음 비동기 I/O(io_uring), 잠금 경합 감소
비자발적 타임 슬라이스 만료, 고우선순위 깨어남 TIF_NEED_RESCHED → 인터럽트 후 선점 높음 (CPU 경쟁 지표) 프로세스 수 감소, CPU 어피니티, SCHED_FIFO
# 프로세스별 자발적/비자발적 Context Switch 조회
cat /proc/$(pgrep nginx | head -1)/status | grep ctxt
voluntary_ctxt_switches:        12345  # 자발적 (I/O 대기 등)
nonvoluntary_ctxt_switches:       234  # 비자발적 (선점)

# 비율 해석:
# nonvoluntary >> voluntary → CPU 경쟁 심각, 프로세스/스레드 수 줄이기
# voluntary >> nonvoluntary → I/O 바운드, 비동기 I/O 검토

# 시스템 전체 비율
awk '/ctxt/ {sum[$1]+=$2} END {for(k in sum) print k, sum[k]}' \
    /proc/*/status 2>/dev/null
⚠️

높은 비자발적 CS: 비자발적 Context Switch가 자발적보다 훨씬 많으면 CPU 경쟁이 심한 것입니다. 프로세스/스레드 수를 CPU 수와 맞추거나, 실시간 우선순위(SCHED_FIFO)로 핵심 스레드를 격리하세요.

커널 선점 모델 (Preemption Models)

Linux 커널은 빌드 시 선택 가능한 4가지 선점 모델을 지원합니다. 선점 모델은 커널 코드 실행 중 언제 다른 태스크로 전환할 수 있는지를 결정하며, Context Switch 빈도와 응답 레이턴시에 직접적인 영향을 미칩니다.

커널 선점 모델별 Context Switch 가능 시점 실행 구간 PREEMPT_NONE PREEMPT_VOLUNTARY PREEMPT_FULL PREEMPT_RT 유저 공간 실행 ✓ 항상 가능 ✓ 항상 가능 ✓ 항상 가능 ✓ 항상 가능 시스템 콜 (커널 실행 중) ✗ 불가 △ 명시적 might_sleep()만 ✓ 선점 가능 ✓ 선점 가능 스핀락 보유 중 ✗ 불가 ✗ 불가 ✗ 불가 ✓ 가능 (rtmutex로 변환) IRQ 핸들러 실행 중 ✗ 불가 ✗ 불가 ✗ 불가 ✓ 가능 (스레드 IRQ) 최대 레이턴시 수십 ms 수 ms 수백 µs 수십 µs 처리량 오버헤드 최소 (서버 최적) 낮음 중간 높음 대표 사용처 서버, 클라우드 데스크톱 데스크톱, 게임 실시간 제어 CONFIG 이름 PREEMPT_NONE PREEMPT_VOLUNTARY PREEMPT PREEMPT_RT 커널 배포판 예시 RHEL/Ubuntu Server Debian, Arch Ubuntu Desktop Fedora, openSUSE Linux-rt, Xenomai
/*
 * 4가지 선점 모델과 선점 가능 지점
 *
 * 1. PREEMPT_NONE (="macro">CONFIG_PREEMPT_NONE)
 *    - 선점 포인트: 유저→커널 복귀 시, 명시적 schedule() 호출
 *    - 커널 코드는 선점 불가 → 높은 처리량, 긴 레이턴시
 *    - 서버용 배포판 기본값
 */
void scheduler_tick(void) {
    /* 타이머 인터럽트: TIF_NEED_RESCHED 플래그 세트만 */
    set_tsk_need_resched(current);
    /* 실제 선점은 커널 복귀 직전(exit_to_user_mode)에 발생 */
}

/*
 * 2. PREEMPT_VOLUNTARY (="macro">CONFIG_PREEMPT_VOLUNTARY)
 *    - PREEMPT_NONE + might_sleep() / might_resched() 포인트
 *    - 긴 루프에 cond_resched() 삽입으로 선점 기회 제공
 */
might_sleep(); /* = might_resched() → TIF_NEED_RESCHED 확인 */
cond_resched(); /* 반복 루프에서 명시적 양보: 비자발적 CS 감소 */

/*
 * 3. PREEMPT_FULL (="macro">CONFIG_PREEMPT)
 *    - 커널 코드는 선점 불가 구간(spinlock, irq-off) 외 모두 선점 가능
 *    - preempt_count == 0 이면 언제든 선점 발생
 *    - TIF_NEED_RESCHED + irq/preempt-off 해제 시 즉시 schedule()
 */
preempt_disable(); /* preempt_count++ → 선점 금지 */
    /* 이 구간은 선점 불가 */
    critical_section();
preempt_enable();  /* preempt_count-- → 0이면 즉시 선점 */

/*
 * 4. PREEMPT_RT (="macro">CONFIG_PREEMPT_RT)
 *    - 스핀락 → rtmutex 변환 (잠금 중 선점 가능)
 *    - 인터럽트 핸들러 → 커널 스레드로 변환 (스레드 IRQ)
 *    - 최악 레이턴시 수십 µs 보장
 *    - 오버헤드: 잠금당 ~100ns 추가 (뮤텍스 오버헤드)
 */
/* raw_spinlock: PREEMPT_RT에서도 선점 불가인 유일한 잠금 */
raw_spin_lock_irqsave(&lock, flags); /* 절대 불가 구간 */
    /* 하드웨어 접근 등 극소 임계 구간만 사용 */
raw_spin_unlock_irqrestore(&lock, flags);
# 현재 커널 선점 모델 확인
zcat /proc/config.gz | grep -E "^CONFIG_PREEMPT"
# CONFIG_PREEMPT_VOLUNTARY=y  → VOLUNTARY 모델

# 또는
uname -v | grep -oE "PREEMPT[_A-Z]*"

# 런타임 선점 모델 전환 (CONFIG_PREEMPT_DYNAMIC 지원 시)
cat /sys/kernel/debug/sched/preempt
# 출력: none / voluntary / full
echo full > /sys/kernel/debug/sched/preempt   # full 선점으로 전환
echo voluntary > /sys/kernel/debug/sched/preempt

# 선점 지점에서 실제 Context Switch 확인
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_state == 0/ {
    @involuntary[comm] = count(); /* 비자발적 (TASK_RUNNING) */
}
tracepoint:sched:sched_switch /args->prev_state != 0/ {
    @voluntary[comm] = count();  /* 자발적 (대기 상태) */
}
interval:s:5 { print(@involuntary); print(@voluntary); clear(@); clear(@voluntary); }'
💡

CONFIG_PREEMPT_DYNAMIC: Linux 5.14부터 도입된 이 기능은 커널 재컴파일 없이 런타임에 선점 모델을 전환할 수 있습니다. /sys/kernel/debug/sched/preempt에 none/voluntary/full을 쓰면 즉시 적용됩니다. 데이터베이스 벤치마크 시 none으로, 응답성 테스트 시 full로 동적 전환하여 비교할 수 있습니다.

모니터링 및 프로파일링

vmstat — Context Switch 빈도

vmstat 1
# cs 컬럼: 초당 Context Switches
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 2  0      0 123456  78901 234567    0    0     0     0 1234 5678  5  2 93  0  0
                                                          ^^^^
                                                      Context Switches/s

# 주요 해석:
# cs < 1000/s  → 정상 (배치 처리)
# cs 1000~10000 → 보통 (I/O 서버)
# cs > 10000/s → 높음 (경합 점검 필요)

perf — Context Switch 심층 분석

# 기본 카운트
perf stat -e context-switches,cpu-migrations,cache-misses,dTLB-load-misses ./myapp

# Context Switch 트레이싱 (CPU 전체, 5초)
perf record -e sched:sched_switch -ag -- sleep 5
perf report --sort comm,pid

# 프로세스별 실시간 컨텍스트 스위치 Top
perf top -e context-switches -s comm

# sched 레이턴시 분석 (전환 지연 분포)
perf sched record -- sleep 10
perf sched latency --sort max
perf sched timehist   # 전환별 타임라인

# wakeup latency 분포
perf sched lat -s switch

/proc/[pid]/status

cat /proc/self/status | grep ctxt
voluntary_ctxt_switches:        1234   # 자발적 (I/O 대기 등)
nonvoluntary_ctxt_switches:      567   # 비자발적 (선점)

# 모든 프로세스 Context Switch 합계
awk '/ctxt_switches/{s+=$2} END{print s}' /proc/*/status 2>/dev/null

/proc/schedstat

# CPU별 스케줄러 통계
cat /proc/schedstat
# cpu0 0 0 0 0 0 0 NR_SWITCHES NR_PREEMPT 0
#                              ^^^^^^^^^^^ ^^^^^^^^^^
#                              총 전환 수  선점 전환 수

# 도메인별 상세 (NUMA 토폴로지)
cat /proc/schedstat | awk 'NF==28{
    printf "CPU%s: switches=%s preempt=%s\n", $1, $8, $9
}'

eBPF/bpftrace로 Context Switch 분석

eBPF를 사용하면 Context Switch의 소스 프로세스, 대상 프로세스, 전환 지연을 커널 수정 없이 실시간으로 분석할 수 있습니다.

기본 트레이싱

# 초당 Context Switch 수 (프로세스별)
bpftrace -e '
tracepoint:sched:sched_switch {
    @[args->prev_comm, args->next_comm] = count();
}
interval:s:5 {
    print(@); clear(@);
}'

# Context Switch 지연 분포 (히스토그램)
bpftrace -e '
tracepoint:sched:sched_switch {
    @start[args->next_pid] = nsecs;
}
tracepoint:sched:sched_wakeup {
    if (@start[args->pid]) {
        @latency_us = hist((nsecs - @start[args->pid]) / 1000);
        delete(@start[args->pid]);
    }
}'

# 특정 프로세스의 Context Switch 추적
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_comm == "myapp"/ {
    printf("OUT: %s(%d) -> %s(%d)\n",
        args->prev_comm, args->prev_pid,
        args->next_comm, args->next_pid);
}'

고급 분석

# Context Switch 원인 분석 (스택 트레이스)
bpftrace -e '
tracepoint:sched:sched_switch /args->prev_comm == "myapp"/ {
    printf("prev_state=%d\n", args->prev_state);
    print(kstack);  /* 커널 스택 역추적 */
}'
# prev_state: 0=RUNNING(비자발적), 1=INTERRUPTIBLE(자발적), 2=UNINTERRUPTIBLE

# CR3 쓰기 빈도 측정 (KPTI 영향)
bpftrace -e '
kprobe:write_cr3 {
    @[comm] = count();
}'

# FPU 저장 빈도 (#NM 예외)
bpftrace -e '
kprobe:do_device_not_available {
    @[comm] = count();
    printf("FPU restore: %s(%d)\n", comm, pid);
}'

Off-CPU 분석

# Off-CPU Time: 프로세스가 CPU를 떠나 있던 시간 분석
# (BCC 도구 사용)
/usr/share/bcc/tools/offcputime -p $(pgrep myapp) 10

# 결과: 어떤 커널 경로(잠금, I/O, 페이지 폴트)에서 오래 대기하는지 표시
# 예시 출력:
#     finish_task_switch.isra.0
#     schedule
#     futex_wait_queue_me    ← mutex 대기
#     myapp_worker_thread
#     Duration: 150000 µs (150ms)

성능 최적화

Context Switch 감소 전략

  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 */

__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;
}
ℹ️

pick_next_task 최적화: CFS 태스크만 존재하는 일반적인 경우, pick_next_task()는 DL/RT 클래스를 건너뛰고 곧바로 pick_next_task_fair()를 호출합니다. 이 fast path 덕분에 대부분의 스케줄링 결정은 수십 나노초 내에 완료됩니다.

호출 경로 SM 모드 prev->state 런큐 유지 발생 시점
preempt_schedule_irq() SM_PREEMPT TASK_RUNNING (변경 없음) IRQ/예외 복귀, preempt_enable()
schedule() SM_NONE INTERRUPTIBLE/UNINTERRUPTIBLE 아니오 (dequeue) sleep, wait_event, mutex_lock
cond_resched() SM_NONE TASK_RUNNING 긴 루프 중간, 소프트 잠금 방지
sched_yield() SM_NONE TASK_RUNNING 예 (vruntime 조정) 명시적 양보 (비권장)

FPU/XSTATE 전환 심화

x86-64 아키텍처의 확장 레지스터 상태(XSTATE)는 x87 FPU부터 AVX-512까지 세대별로 누적되어 최대 2.5KB에 달합니다. Context Switch 시 이 상태의 저장/복원 전략은 성능에 직접적인 영향을 미칩니다. 커널 5.x 이후로는 보안(Spectre) 이유로 Eager 모드가 기본값이지만, 내부 최적화로 실제 비용은 상당히 절감되었습니다.

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 동안 주파수 다운클럭
     * 같은 코어에서 다른 태스크의 성능에도 영향 */
}
⚠️

AVX-512 주파수 다운클럭: Intel CPU에서 AVX-512 명령을 실행하면 코어 주파수가 최대 20% 감소합니다. 이 다운클럭은 ~670us 동안 지속되며, 같은 물리 코어의 다른 하이퍼스레드에도 영향을 줍니다. perf stat -e cpu/event=0xc6,umask=0x01/로 AVX-512 사용 빈도를 확인하고, clearcpuid=avx512f 커널 파라미터로 비활성화할 수 있습니다.

XSTATE 컴포넌트 CPUID 비트 크기 XSAVEOPT 비용 비고
x87 FPU (ST0-ST7) bit 0 160B ~20ns 레거시, 항상 저장
SSE (XMM0-XMM15) bit 1 256B ~40ns MXCSR 포함
AVX (YMM hi128) bit 2 256B ~50ns 변경 시에만 저장
MPX (bnd0-bnd3) bit 3-4 128B ~15ns Linux 5.6에서 제거
AVX-512 (ZMM hi256 + Opmask) bit 5-7 ~1,664B ~200ns 주파수 다운클럭 유발
PKRU (보호키) bit 9 8B ~5ns MPK 사용 시
AMX TILE (tmm0-tmm7) bit 17-18 8,192B ~800ns Intel Sapphire Rapids+

KPTI 페이지 테이블 전환 심화

KPTI(Kernel Page-Table Isolation)는 Meltdown 취약점 완화를 위해 유저 공간과 커널 공간에 각각 별도의 페이지 테이블(PGD)을 사용합니다. 유저 PGD에는 커널 주소 공간 대부분이 매핑되지 않으므로, Meltdown을 통한 커널 메모리 읽기가 불가능합니다. 이 이중 PGD 구조가 Context Switch와 시스템 콜에 미치는 영향을 분석합니다.

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
💡

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;
TLB 플러시 트리거 플러시 범위 비용 IPI 필요
Context Switch (switch_mm) PCID: 없음 / 미지원: 전체 50ns / 500ns 아니오
munmap() / mprotect() 변경된 VA 범위 10ns/페이지 예 (mm 활성 CPU들)
페이지 마이그레이션 이동된 페이지 10ns/페이지
커널 모듈 로드/해제 전체 (글로벌) ~500ns 예 (모든 CPU)
KPTI 유저/커널 전환 PCID: 없음 / 미지원: 전체 30ns / 500ns 아니오
ℹ️

TLB shootdown 비용: IPI 기반 원격 TLB 무효화는 대상 CPU에 인터럽트를 발생시키므로, 코어 수가 많은 시스템에서 munmap()이 수십 µs까지 지연될 수 있습니다. perf stat -e tlb:tlb_flush로 빈도를 측정하고, 가능하면 madvise(MADV_FREE)로 즉시 해제 대신 지연 해제를 사용하세요.

ftrace/perf/bpftrace 문맥전환 분석 실전

문맥전환 병목을 진단할 때는 단순 횟수뿐 아니라, 어떤 프로세스가 어떤 이유로 전환되는지, 전환 지연(스케줄링 레이턴시)이 얼마인지, 캐시/TLB 영향은 얼마인지를 종합적으로 분석해야 합니다. 이 섹션은 ftrace sched_switch 이벤트, perf sched 레이턴시, bpftrace 고급 분석 기법을 실전 예제로 다룹니다.

ftrace sched_switch 이벤트

# ftrace로 sched_switch 이벤트 활성화
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable

# 출력 형식 확인
cat /sys/kernel/debug/tracing/trace_pipe | head -20
# 출력 예시:
#   nginx-1234  [002] d..2  12345.678901: sched_switch:
#     prev_comm=nginx prev_pid=1234 prev_prio=120 prev_state=S ==>
#     next_comm=kworker/2:1 next_pid=5678 next_prio=120
#
# 필드 해석:
#   prev_state: R=Running(비자발적), S=Sleeping(자발적),
#               D=Uninterruptible, T=Stopped, X=Dead

# 특정 프로세스만 필터링
echo 'prev_comm == "myapp" || next_comm == "myapp"' > \
    /sys/kernel/debug/tracing/events/sched/sched_switch/filter

# 함수 그래프 트레이서로 schedule() 내부 경로 추적
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo __schedule > /sys/kernel/debug/tracing/set_graph_function
cat /sys/kernel/debug/tracing/trace
# 출력: __schedule 내부 호출 트리 + 소요 시간
#  |  0.312 us  |  pick_next_task_fair();
#  |  0.156 us  |  switch_mm_irqs_off();
#  |  0.891 us  |  __switch_to_asm();
#  |  0.234 us  |  finish_task_switch();

# 비활성화
echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo nop > /sys/kernel/debug/tracing/current_tracer

perf sched 심층 레이턴시 분석

# 10초간 스케줄링 이벤트 기록
perf sched record -- sleep 10

# 프로세스별 스케줄링 레이턴시 분석 (최대 지연순 정렬)
perf sched latency --sort max
# 출력 예시:
# Task               |   Runtime   |  Switches  | Avg delay  | Max delay  |
# nginx:1234         |  2345.678ms |       5678 | 0.012ms    | 2.345ms    |
# postgres:5678      |  1234.567ms |       3456 | 0.045ms    | 15.678ms   |
#
# Max delay가 높은 프로세스: 스케줄링 지연 심각

# 시간순 전환 타임라인 (각 전환별 지연 표시)
perf sched timehist --summary
# 출력: CPU별 전환 타임라인
#   12345.001  [002]  nginx:1234     0.003ms  → kworker/2:1:5678
#   12345.002  [002]  kworker:5678   0.001ms  → nginx:1234

# wakeup 지연 분석 (깨어난 후 실제 실행까지)
perf sched timehist -w
# wakeup_time 컬럼: wake_up() → 실제 CPU 획득까지의 지연

# Context Switch와 캐시 미스 상관관계 분석
perf stat -e context-switches,cache-misses,dTLB-load-misses,\
dTLB-store-misses,L1-dcache-load-misses -p $(pgrep myapp) sleep 10
# context-switches가 높을수록 cache-misses도 비례해서 증가하면
# → Context Switch 감소가 최우선 최적화 목표

bpftrace 문맥전환 고급 분석

# 문맥전환 원인별 분류 (스택 트레이스 포함)
bpftrace -e '
tracepoint:sched:sched_switch {
    /* prev_state별 분류 */
    if (args->prev_state == 0) {
        @preempted[args->prev_comm] = count();
    } else if (args->prev_state == 1) {
        @sleeping[args->prev_comm] = count();
    } else if (args->prev_state == 2) {
        @disk_wait[args->prev_comm] = count();
    }
}
interval:s:5 {
    printf("--- 비자발적 (선점됨) ---\n");
    print(@preempted, 10);
    printf("--- 자발적 (sleep) ---\n");
    print(@sleeping, 10);
    printf("--- I/O 대기 ---\n");
    print(@disk_wait, 10);
    clear(@preempted); clear(@sleeping); clear(@disk_wait);
}'

# CPU별 문맥전환 빈도 히트맵
bpftrace -e '
tracepoint:sched:sched_switch {
    @[cpu] = count();
}
interval:s:3 { print(@); clear(@); }'

# 스케줄링 레이턴시 히스토그램 (깨어남 → 실행 시작)
bpftrace -e '
tracepoint:sched:sched_wakeup {
    @wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
    $ts = @wake[args->next_pid];
    if ($ts > 0) {
        $lat_us = (nsecs - $ts) / 1000;
        @latency_us = hist($lat_us);
        if ($lat_us > 1000) {
            printf("HIGH LAT: %s pid=%d lat=%d us\n",
                args->next_comm, args->next_pid, $lat_us);
        }
        delete(@wake[args->next_pid]);
    }
}
END { print(@latency_us); }'

# switch_mm 빈도 분석 (주소 공간 전환 비용)
bpftrace -e '
kprobe:switch_mm_irqs_off {
    @switch_mm[comm] = count();
}
kprobe:enter_lazy_tlb {
    @lazy_tlb[comm] = count();
}
interval:s:5 {
    printf("=== switch_mm (full TLB cost) ===\n");
    print(@switch_mm, 5);
    printf("=== lazy_tlb (no TLB cost) ===\n");
    print(@lazy_tlb, 5);
    clear(@switch_mm); clear(@lazy_tlb);
}'
💡

실전 진단 순서:vmstat 1로 cs 빈도 확인 → ② /proc/PID/status로 자발적/비자발적 비율 확인 → ③ perf sched latency로 최대 지연 프로세스 식별 → ④ bpftrace로 원인 분류(선점/sleep/I/O) → ⑤ perf stat로 캐시/TLB 영향 정량화. 이 순서로 병목을 좁혀가면 최적화 대상을 빠르게 특정할 수 있습니다.

문맥전환 비용 정량 분석

Context Switch의 실제 비용은 레지스터 저장/복원의 직접 비용보다 캐시/TLB 오염으로 인한 간접 비용이 훨씬 큽니다. 이 섹션은 두 비용을 정량적으로 분석하고, CPU 세대별/워크로드별 벤치마크 결과를 비교합니다.

Context Switch 비용 구조: 직접 비용 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()

Linux 커널의 선점 모델은 처리량(throughput)과 응답 레이턴시(latency) 사이의 트레이드오프를 결정합니다. 커널 6.x에서 도입된 CONFIG_PREEMPT_DYNAMIC은 런타임에 선점 모델을 전환할 수 있게 해, 워크로드에 따른 동적 최적화가 가능합니다.

선점 모델: 처리량 vs 레이턴시 트레이드오프 처리량 (Throughput) 높음 낮음 응답 레이턴시 (Latency) 높음 낮음 NONE PREEMPT_NONE 서버/클라우드 최대 레이턴시: ~100ms VOL PREEMPT_VOLUNTARY 데스크톱 기본 최대 레이턴시: ~10ms FULL PREEMPT (FULL) 게임/오디오 최대 레이턴시: ~1ms RT PREEMPT_RT 실시간 제어 cond_resched() 삽입 위치 긴 루프 (페이지 스캔, 파일시스템 순회, 네트워크 수신) PREEMPT_NONE: 유일한 커널 내 선점 기회 CONFIG_PREEMPT_DYNAMIC 런타임 전환: echo none|voluntary|full > preempt static_call 패칭: 오버헤드 0 (조건문 없음)
/* kernel/sched/core.c — cond_resched() 구현 상세 */

/* PREEMPT_NONE/VOLUNTARY에서 커널 내 선점 기회를 제공하는 핵심 함수 */
static __always_inline bool should_resched(int preempt_offset)
{
    /* preempt_count가 offset과 같고, TIF_NEED_RESCHED가 세트되면 true */
    return unlikely(preempt_count() == preempt_offset &&
                     tif_need_resched());
}

int __sched _cond_resched(void)
{
    if (should_resched(0)) {
        preempt_schedule_common();
        return 1;   /* 전환 발생 */
    }
    rcu_all_qs();  /* RCU quiescent state 보고 */
    return 0;      /* 전환 불필요 */
}

/* might_sleep() — cond_resched() + 디버그 검사 */
void __might_sleep(const char *file, int line)
{
    /* CONFIG_DEBUG_ATOMIC_SLEEP: 원자 컨텍스트에서 호출 시 경고 */
    if (preempt_count() || irqs_disabled()) {
        WARN_ONCE(1, "BUG: sleeping function called from invalid context");
    }
    _cond_resched();
}

/* ── CONFIG_PREEMPT_DYNAMIC: 런타임 선점 모델 전환 ── */

/* static_call로 구현: 조건문 오버헤드 0 */
DEFINE_STATIC_CALL(cond_resched, __cond_resched);
DEFINE_STATIC_CALL(might_resched, __cond_resched);
DEFINE_STATIC_CALL(preempt_schedule, __preempt_schedule);

/* none → cond_resched = nop (아무것도 안 함)
 * voluntary → cond_resched = __cond_resched (체크 + 양보)
 * full → preempt_schedule = __preempt_schedule (항상 선점) */

static int sched_dynamic_mode(const char *str)
{
    if (!strcmp(str, "none")) {
        static_call_update(cond_resched, __static_call_return0);
        static_call_update(preempt_schedule, NULL);
    } else if (!strcmp(str, "voluntary")) {
        static_call_update(cond_resched, __cond_resched);
        static_call_update(preempt_schedule, NULL);
    } else if (!strcmp(str, "full")) {
        static_call_update(cond_resched, __cond_resched);
        static_call_update(preempt_schedule, __preempt_schedule);
    }
    return 0;
}
선점 모델 CONFIG 이름 커널 내 선점 cond_resched() 최대 레이턴시 처리량 오버헤드 대표 용도
PREEMPT_NONE CONFIG_PREEMPT_NONE 불가 nop (무시) ~100ms 0% 서버, DB, HPC
PREEMPT_VOLUNTARY CONFIG_PREEMPT_VOLUNTARY 명시적 포인트만 활성 (체크+양보) ~10ms ~1% 데스크톱, 범용
PREEMPT (FULL) CONFIG_PREEMPT 스핀락/IRQ외 가능 활성 ~1ms ~3~5% 게임, 오디오, UI
PREEMPT_RT CONFIG_PREEMPT_RT 거의 모든 곳 활성 ~50us ~10~15% 산업제어, 로봇
# 현재 선점 모델 확인 (3가지 방법)
zcat /proc/config.gz | grep -E "^CONFIG_PREEMPT"
uname -v  # 예: #1 SMP PREEMPT_DYNAMIC ...
cat /sys/kernel/debug/sched/preempt  # 런타임 상태

# CONFIG_PREEMPT_DYNAMIC 런타임 전환
# 서버 모드 (처리량 우선)
echo none > /sys/kernel/debug/sched/preempt

# 데스크톱 모드 (균형)
echo voluntary > /sys/kernel/debug/sched/preempt

# 응답성 모드 (레이턴시 우선)
echo full > /sys/kernel/debug/sched/preempt

# 선점 모델별 Context Switch 빈도 비교 테스트
for mode in none voluntary full; do
    echo $mode > /sys/kernel/debug/sched/preempt
    echo "=== $mode ==="
    perf stat -e context-switches -- stress-ng --cpu 4 --timeout 5s 2>&1 | \
        grep context-switches
done
# 예상 결과:
# none:      ~2,000/s (최소 전환)
# voluntary: ~5,000/s (cond_resched 포인트에서 추가)
# full:      ~15,000/s (커널 내 선점으로 대폭 증가)

# cyclictest로 최악 레이턴시 측정
cyclictest -t 4 -p 80 -n -l 100000
# T:0 Min: 1 Act: 5 Avg: 3 Max: 15  (PREEMPT_FULL)
# T:0 Min: 1 Act: 5 Avg: 3 Max: 2345 (PREEMPT_NONE)
⚠️

PREEMPT_NONE 주의사항: PREEMPT_NONE에서는 커널 코드 실행 중 선점이 불가능하므로, 긴 커널 경로(대용량 파일 삭제, 대규모 메모리 해제 등)가 수십~수백 ms 동안 CPU를 독점할 수 있습니다. 서버에서도 cond_resched()가 삽입되지 않은 커널 경로에서 예상치 못한 레이턴시 스파이크가 발생할 수 있으므로, CONFIG_PREEMPT_VOLUNTARY이 안전한 기본값입니다.