컨텍스트 스위칭 (Context Switching)
Linux 커널의 Context Switching은 CPU 실행 주체를 전환하는 핵심 경로입니다. 이 문서는 schedule() 호출 시점부터 __switch_to 복귀까지 레지스터(Register)/커널 스택/메모리 컨텍스트 전환 단계를 해부하고, TLB/PCID 비용, FPU 상태 관리, KPTI 영향, ARM64 차이, perf·ftrace·eBPF 기반 병목(Bottleneck) 진단 절차까지 종합적으로 다룹니다.
기준 커널 버전: 본 문서의 소스 코드 인용과 동작 설명은 Linux 6.14부터 메인라인(master)까지를 기준으로 하며, 실제 소스 확인 시점은 2026년 10월입니다(확인 대상: kernel/sched/core.c, arch/x86/kernel/process_64.c, arch/arm64/mm/context.c 등). 작성 시점의 최신 안정(Stable) 버전과 릴리스 정보는 kernel.org Release 페이지를 참조하세요. 버전별 변경 이력은 최신 변경사항 (6.14+) 섹션을 확인하세요.
핵심 요약
- 레지스터·스택 전환 — CPU 범용 레지스터, RIP/RFLAGS, RSP/RBP를
task_struct의 thread에 저장·복원하며 각 태스크(Task)의 커널 스택을 교체합니다. - 주소 공간(Address Space)과 TLB — CR3 교체로 페이지 테이블을 바꾸고 PCID로 TLB를 보존해 전환 비용을 줄입니다. 커널 스레드(Kernel Thread)는 건너뜁니다.
- FPU·보안 비용 — Eager FPU로 전환 시 XSAVE로 즉시 저장하고, KPTI는 커널/유저 전환에서 CR3를 두 번 쓰는 추가 비용을 유발합니다.
- 직접/간접 비용 — 레지스터 저장 자체보다 캐시 오염·TLB 미스의 간접 비용이 대체로 더 큽니다.
- 선점(Preemption)과 관찰 — PREEMPT 모델이 전환 빈도를,
vmstat·bpftrace sched_switch가 실시간(Real-time) 분석 경로를 결정합니다.
단계별 이해
- 트리거 파악
타임 슬라이스(Time Slice) 만료, 블로킹 시스템 콜(System Call), 우선순위(Priority) 선점,sched_yield()를 기준으로 발생 시점을 이해하고vmstatcs 컬럼을 확인합니다. - schedule → context_switch 경로
schedule()→pick_next_task()→context_switch()흐름에서 주소 공간·레지스터 전환이 일어나는 지점을 추적합니다. - 주소 공간·레지스터 전환
switch_mm_irqs_off()의 CR3 교체,switch_to/__switch_to_asm의 callee-saved 레지스터 처리를 분석합니다. - FPU·KPTI 비용
FPU eager save, KPTI 이중 CR3 전환을/sys/devices/system/cpu/vulnerabilities/meltdown과 함께 확인합니다. - 관찰과 튜닝
/proc/[pid]/status의 voluntary/nonvoluntary 카운터,perf stat cpu-migrations,taskset/numactl로 빈도와 지연을 최적화합니다.
개요 (Overview)
Context Switch는 CPU가 현재 실행 중인 프로세스(또는 스레드(Thread))의 상태를 저장하고, 다른 프로세스의 이전 상태를 복원하여 실행을 재개하는 과정입니다. Linux 커널은 선점형 멀티태스킹을 구현하기 위해 이 메커니즘을 사용합니다.
Context Switch는 다음 시점에 발생합니다:
- 타임 슬라이스 만료 — 스케줄러 틱 인터럽트(Interrupt)(CONFIG_HZ, 기본 250Hz)
- 블로킹 시스템 콜 — I/O 대기, sleep(), wait(), mutex_lock()
- 우선순위 선점 — 높은 우선순위 프로세스가 깨어남(wake_up())
- 명시적 양보(Yield) — sched_yield(), cond_resched()
- 인터럽트 후 선점 — 인터럽트 핸들러(Handler) 종료 후 TIF_NEED_RESCHED 플래그 확인
빈도: 일반적인 리눅스 서버에서는 Context Switch 발생 빈도가 워크로드에 따라 크게 달라집니다. CPU-bound 워크로드는 낮고, I/O-bound 워크로드는 높습니다. vmstat 1의 cs 컬럼으로 확인 가능합니다. 부하가 높은 워크로드에서는 더욱 증가할 수 있습니다.
CPU 시간 분할과 컨텍스트(Context) 구성
단일 CPU는 물리적으로 한 번에 하나의 명령만 실행합니다. 여러 프로세스가 "동시에" 실행되는 것처럼 보이는 이유는 시간 분할(Time-Sharing) 때문입니다. 스케줄러는 각 프로세스에게 짧은 실행 시간(타임 슬라이스)을 순서대로 할당하며, 슬라이스 경계에서 Context Switch를 수행합니다. 이 전환이 매우 짧은 시간에 끝나면 사람의 눈에는 모든 프로세스가 동시에 진행되는 것처럼 보입니다.
sysctl_sched_base_slice 는 0.7ms 로 정의되며, 부팅 시 CPU 수에 따라 배율이 곱해집니다 — sysctl_sched_tunable_scaling 기본값 LOG, CPU 8개 이상이면 1+ilog2(8)=4배)컨텍스트(Context)의 세 구성 요소
Context Switch가 저장·복원하는 "컨텍스트"는 프로세스 실행 상태 전체를 의미합니다. Linux 커널에서 컨텍스트는 세 범주로 분류되며, 각각 다른 메커니즘으로 처리됩니다:
범용 레지스터(RAX, RCX, RDX, RSI, RDI, R8~R11)는 Context Switch에서 직접 저장하지 않습니다. x86-64 System V ABI에서 이 레지스터들은 caller-saved로, 함수 호출자 측에서 이미 처리했거나 보존이 불필요합니다. 시스템 콜·인터럽트 진입 경로에서는 pt_regs에 저장됩니다.
프로세스 상태 전이와 Context Switch
Context Switch는 프로세스가 특정 상태 전이를 거칠 때 발생합니다. Linux 커널은 프로세스의 현재 상태를 task_struct.__state 필드로 추적하며, 이 상태가 Context Switch의 발생 시점과 재개 조건을 결정합니다.
sched_fork() 가 TASK_NEW 로 표시한 태스크는 런큐에 없고, wake_up_new_task() 가 런큐에 등록(활성화)하며 재개 상태 TASK_RUNNING 을 씁니다. TASK_RUNNING 은 런큐 대기와 CPU 실행을 함께 포함하는 단일 값이므로, 그림 가운데 두 레인 사이에서 Context Switch(런큐 → CPU)와 선점·타임슬라이스 소진(CPU → 런큐)이 양방향으로 발생합니다. 실행 중 블로킹되거나 시그널을 기다리면 네 가지 비실행 상태(TASK_INTERRUPTIBLE · TASK_UNINTERRUPTIBLE · __TASK_STOPPED · __TASK_TRACED)로 빠지고(deactivate_task()), wake_up() 계열이 런큐 복귀(activate_task())를 일으켜 실행 시점을 잡습니다. 이미 잠든 태스크에 시그널이 오면 TASK_WAKEKILL 비트가 합쳐지므로, 각 비실행 상태는 하나의 상태 값이 아니라 전이를 일으킬 수 있는 __state 집합입니다. EXIT_ZOMBIE 는 __state 가 아니라 exit_state 필드에 기록됩니다.| 상태 | 의미 | Context Switch 관계 | 주요 진입 경로 |
|---|---|---|---|
| TASK_RUNNING | 실행 가능 (런큐(Runqueue) 대기 또는 CPU 실행 중) | 런큐→CPU: CS 발생. CPU→런큐: 선점/타임슬라이스(Time Slice)로 CS 발생 | fork, wake_up(), SIGCONT |
| TASK_INTERRUPTIBLE | 이벤트 또는 시그널(Signal) 대기 중 (슬립) | CS가 발생해 다른 태스크가 CPU를 차지함. 깨어나면 런큐에 추가 후 다시 CS로 CPU 획득 | sleep(), mutex_lock(), wait_event() |
| TASK_UNINTERRUPTIBLE | 특정 I/O 이벤트만 대기 (시그널 무시) | INTERRUPTIBLE과 동일. 시그널로 깨울 수 없어 D 상태로 표시됨 |
블로킹 디스크 I/O, NFS 대기 |
| __TASK_STOPPED | SIGSTOP/SIGTSTP 등 시그널로 일시 중지 | CS 후 다른 태스크 실행. SIGCONT 수신 시 TASK_RUNNING으로 복귀 | SIGSTOP/SIGTSTP 수신 |
| __TASK_TRACED | ptrace()로 일시 중지 (디버거 정지) | __TASK_STOPPED와 유사. PTRACE_CONT 또는 디버거 이벤트 시 TASK_RUNNING으로 복귀 | ptrace() PTRACE_ATTACH/PTRACE_STOP, gdb attach |
| EXIT_ZOMBIE | exit() 완료, 부모 wait() 대기 중 | 더 이상 스케줄되지 않음. 부모가 wait() 호출 시 완전 소멸 | exit(), exit_group() |
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 플래그 처리
TSS (Task State Segment) 기초
TSS(Task State Segment)는 x86 아키텍처가 하드웨어 태스크 스위칭을 지원하기 위해 설계한 특수 메모리 구조체입니다. Linux는 소프트웨어로 Context Switch를 구현하므로 하드웨어 태스크 스위칭(TR이 가리키는 TSS 자체를 교체하는 방식)을 사용하지 않습니다. 그러나 TSS의 sp0 필드는 여전히 유저 모드에서 인터럽트·예외가 발생할 때 Ring 0 진입의 1차 스택 전환에 사용됩니다. 한편 x86-64의 syscall 명령은 스택 전환을 수행하지 않으므로, 시스템 콜 진입 경로(entry_SYSCALL_64)는 TSS.sp0가 아니라 per-CPU 변수 pcpu_hot.top_of_stack에서 RSP를 로드합니다. 이 per-CPU 변수는 Context Switch 때마다 __switch_to()가 다음 태스크의 커널 스택 최상단으로 갱신하며, 이것이 커널이 태스크별 올바른 커널 스택을 유지하는 핵심 메커니즘입니다.
TSS의 역할과 Linux의 활용
x86-64 아키텍처에서 TSS는 CPU당 하나가 존재하며, GDT(Global Descriptor Table)의 TSS 디스크립터를 통해 참조됩니다. CPU는 TR(Task Register)에 이 디스크립터의 셀렉터(Selector)를 보관합니다.
| TSS 필드 | 역할 | Linux에서 관리 |
|---|---|---|
| sp0 | 인터럽트·예외로 Ring 0 진입 시 RSP로 로드할 스택 최상단 주소 | 부팅 시 CPU당 1회 설정 (엔트리 스택). 태스크별 커널 스택은 per-CPU top_of_stack 변수가 관리 (x86-32는 load_sp0()로 매 전환 갱신) |
| IST[1..7] | 특정 예외(#DF, NMI, #MC 등) 전용 스택 주소 | 부팅 시 고정. Context Switch 무관 |
| io_bitmap_base | I/O 포트 권한 비트맵(Bitmap) 오프셋 | ioperm() 사용 태스크만 비트맵 포인터 갱신 |
왜 TSS가 필요한가: 유저 모드(Ring 3)에서 인터럽트나 예외가 발생하면 CPU는 Ring 0으로 전환하면서 RSP를 커널 스택으로 바꿔야 합니다. 인터럽트 경로는 하드웨어가 TSS.sp0로 1차 스택 전환을 수행하고(그래서 TSS 자체는 반드시 필요합니다), 이후 진입 코드가 per-CPU top_of_stack 값으로 실제 태스크의 커널 스택으로 옮겨갑니다. 만약 __switch_to()가 이 per-CPU 값을 다음 태스크의 커널 스택 최상단으로 갱신하지 않으면, 시스템 콜·인터럽트 시 다른 태스크의 스택을 덮어쓰는 심각한 오류가 발생합니다.
__switch_to() 함수 심층 분석
어셈블리(Assembly) 래퍼: __switch_to_asm
switch_to 매크로(Macro)가 최종적으로 호출하는 어셈블리 함수입니다. 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매크로로 정의된 공개 커널 심볼(Kernel Symbol)입니다. - 5~10행callee-saved 레지스터 6개(RBP, RBX, R12~R15)를 현재 프로세스(prev)의 커널 스택에 순서대로
pushq합니다. x86-64 System V ABI에 따라 이 6개 레지스터는 함수 호출 전후로 값이 보존되어야 하므로__switch_to_asm이 직접 저장합니다. - 12행
movq %rsp, TASK_threadsp(%rdi):%rdi는 첫 번째 인자prev태스크 포인터입니다.TASK_threadsp는task_struct내thread.sp필드의 컴파일 타임 오프셋(Offset) 상수입니다. 현재 스택 포인터(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을 통해 스왑(Swap) 아웃될 때 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 상태 — prev의 컨텍스트를 저장하고 next 로드를 준비 */
switch_fpu(prev, cpu);
/* 2. FS/GS 세그먼트와 base 저장 — TLS 로드가 덮어쓰기 전에 수행 */
save_fsgs(prev);
/* 3. TLS 로드: GDT의 TLS 슬롯을 next 기준으로 갱신 */
load_TLS(next_thread, cpu);
arch_end_context_switch(next);
/* 4. DS/ES 세그먼트 전환 (CS/SS는 pt_regs에서 복원되므로 제외) */
savesegment(es, prev_thread->es);
if (unlikely(next_thread->es | prev_thread->es))
loadsegment(es, next_thread->es);
savesegment(ds, prev_thread->ds);
if (unlikely(next_thread->ds | prev_thread->ds))
loadsegment(ds, next_thread->ds);
/* 5. FS.base/GS.base 복원 — FSGSBASE 지원 시 wrgsbase/wrfsbase 사용 */
x86_fsgsbase_load(prev, next);
/* 6. PKRU(Protection Keys) 레지스터 전환 — thread_struct.pkru에 보관 */
x86_pkru_load(prev, next);
/* 7. per-CPU 현재 태스크와 커널 스택 상단 갱신 (시작/인터럽트 진입용) */
raw_cpu_write(current_task, next);
raw_cpu_write(pcpu_hot.top_of_stack, task_top_of_stack(next));
update_task_stack(next); /* TSS.sp0 재설정 */
/* 8. FPU 이외의 추가 컨텍스트(xfd, shadow stack 등) 전환 */
switch_to_extra(prev, next);
/* 9. AMD SYSRET SS 어트리뷰트 버그(X86_BUG_SYSRET_SS_ATTRS) 우회 */
if (static_cpu_has_bug(X86_BUG_SYSRET_SS_ATTRS)) {
unsigned short ss_sel;
savesegment(ss, ss_sel);
if (ss_sel != __KERNEL_DS)
loadsegment(ss, __KERNEL_DS);
}
/* 10. Intel RDT(PQR/Cache Allocation) MSR 적재 */
resctrl_arch_sched_in(next);
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, struct rq_flags *rf)
{
/* perf 이벤트/프리옵트 노티파이/kcov 준비 */
prepare_task_switch(rq, prev, next);
arch_start_context_switch(prev);
/* 주소 공간 전환 */
if (!next->mm) {
/* 커널 스레드: 이전 mm 차용 (페이지 테이블 전환 없음) */
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm)
mmgrab_lazy_tlb(prev->active_mm);
else
prev->active_mm = NULL;
} else {
/* 유저 프로세스: mm 전환 */
membarrier_switch_mm(rq, prev->active_mm, next->mm);
switch_mm_irqs_off(prev->active_mm, next->mm, next);
lru_gen_use_mm(next->mm);
if (!prev->mm) {
rq->prev_mm = prev->active_mm; /* finish_task_switch()에서 mmdrop_lazy_tlb() */
prev->active_mm = NULL;
}
}
mm_cid_switch_to(prev, next);
rseq_sched_switch_event(next);
/* rq->curr 갱신은 __schedule()의 RCU_INIT_POINTER(rq->curr, next)에서 수행 */
prepare_lock_switch(rq, next, rf);
/* 실제 레지스터/스택 전환 (위 __switch_to_asm) */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
코드 설명
- 3행
arch/x86/kernel/process_64.c의__switch_to()입니다. prev/next 태스크 포인터와 각각의thread_struct, 현재 CPU 번호를 확보한 뒤 순서대로 CPU 상태를 전환합니다. 반환값prev는switch_to()매크리가 caller에게 돌려주는 값입니다. - 10행FPU/SIMD 상태 전환의 진입점입니다. 현대 커널은 Eager 모드이므로
prev의 컨텍스트를 XSAVE로 저장하고(이미 저장된 상태라면 건너뜀) next의 로드를 준비합니다. - 13행FS/GS 세그먼트 셀렉터와 base를 저장합니다. 다음 단계의
load_TLS()가 GDT/TLS 엔트리를 덮어쓰므로 그 전에 저장해야 합니다. - 16행GDT의 TLS 슬롯을 next의
tls_array[]로 갱신하고, 이어서arch_end_context_switch()가 lazy 모드(패럴라벨)를 빠져나오며 남은 하이퍼콜을 플러시합니다. - 20행DS/ES 세그먼트를 전환합니다. 셀렉터 값만 읽으면 0이어도 실제 쓰기는 GDT/LDT 디스크립터를 로드하므로, 둘 중 하나라도 0이 아닐 때만 씁니다. CS/SS는
pt_regs복원 경로에서 처리되므로 여기서 다루지 않습니다. - 28행FS.base/GS.base를 복원합니다. FSGSBASE를 지원하는 CPU에서는 MSR 대신
rdfsbase/wrgsbase계열 명령을 사용합니다. 이전 커널의x86_fsgsbase_load()가 이 역할이었으나 현재 커널에서는 prev/next를 함께 받는x86_fsgsbase_load()로 바뀌었습니다. - 31행PKRU(Protection Keys for Userspace) 레지스터를 전환합니다. 값은
thread_struct.pkru에 저장되며, 태스크가 스케줄 아웃될 때 기록됩니다. - 34행per-CPU
current_task와pcpu_hot.top_of_stack을 갱신합니다. 후자는 시스템 콜/인터럽트 진입 시 RSP로 로드되는 커널 스택 상단이며,update_task_stack()이 TSS.sp0을 같은 값으로 맞춥니다. - 39행FPU 이외의 추가 컨텍스트를 전환합니다.
- 42행AMD CPU의 버그 우회 코드입니다. SYSRET이 SS 셀렉터를 쓰면서 캐시된 디스크립터를 갱신하지 않으므로, 커널 컨텍스트에서 SS가 NULL이 되지 않도록 강제로
__KERNEL_DS를 넣습니다. - 50행Intel RDT(PQR/Cache Allocation) MSR을 적재합니다. AMD의 경우 워크로드 클래스 레지스터 초기화도 이 뒤에 이어집니다.
- 61행
context_switch()의 첫 단계입니다. perf 이벤트 저장, 스킵 카운터, preempt 노티파이, kcov, kmap local 정리 등 전환 직전 준비를 수행합니다.arch_start_context_switch()는 패럴라벨 환경에서 페이지 테이블 재로드를 하이퍼콜로 합치기 위한 훅입니다. - 67행커널 스레드 분기(
!next->mm)입니다. 독립 주소 공간이 없으므로 이전 태스크의active_mm을 차용하고,mmgrab_lazy_tlb()로 참조 카운트(Reference Count)를 올립니다(이때 CR3 변경 없음 = Lazy TLB). - 75행유저 프로세스 분기입니다.
sys_membarrier()가 요구하는 메모리 배리어(Memory Barrier)를 mm 전환 경로에 넣은 뒤,switch_mm_irqs_off()가 CR3를 next의 페이지 테이블로 바꾸고lru_gen_use_mm()이 MGLRU에 활성화를 알립니다. 이전 태스크가 커널 스레드였다면rq->prev_mm에 차용한 mm를 남겨 두고finish_task_switch()에서mmdrop_lazy_tlb()로 해제합니다. - 84행mm CID(커널 스레드 간 공유 mm 식별자)와 rseq 스케줄 이벤트를 전환합니다. rseq 노티파이는
switch_mm_cid()이후에 호출되어 TIF 플래그가 설정된 상태를 보장합니다. - 88행런큐 락 소유권을 next로 넘기는 단계입니다.
rq->curr갱신은 여기서가 아니라__schedule()이RCU_INIT_POINTER(rq->curr, next)로 수행합니다. - 91행매크로로,
__switch_to_asm(prev, next)를 호출한 뒤 그 반환값을 돌려줍니다. 세 번째 인자는 어셈블리가 쓰지 않으며, prev를 돌려받아 다음 줄barrier()를 거쳐finish_task_switch(prev)로 전달합니다. - 94행전환이 완료된 뒤 next의 컨텍스트에서 실행됩니다. prev 태스크의 스택 해제,
rq->prev_mm의mmdrop_lazy_tlb(),TIF_NOTIFY_RESUME처리 등을 담당합니다.
호출 체인 소스 분석: schedule() → context_switch()
실제 커널 소스에서 schedule()부터 context_switch()까지 이어지는 전체 호출 체인을 추적합니다. 각 계층이 어떤 역할을 분담하는지 확인하세요.
context_switch() 함수 소스 분석
context_switch()는 주소 공간 전환과 레지스터/스택 전환을 오케스트레이션하는 핵심 함수입니다. 커널 스레드의 mm=NULL 처리, active_mm 차용(Borrowing), membarrier 동기화까지 모두 이 함수에서 수행됩니다.
/* kernel/sched/core.c — context_switch() 핵심 구현 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
/* ── Phase 1: 주소 공간 전환 준비 ── */
struct mm_struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
/*
* membarrier 시스템 콜 지원: 다른 CPU에서 실행 중인
* 스레드에 메모리 장벽을 강제하기 위해 컨텍스트 스위치
* 시점에 rq->membarrier_state를 갱신합니다.
*/
prepare_task_switch(rq, prev, next);
/* ── Phase 2: 주소 공간 전환 (mm 기반 분기) ── */
if (!mm) {
/*
* 커널 스레드(Kernel Thread): mm == NULL
* 자신만의 주소 공간이 없으므로 이전 프로세스의
* active_mm을 "차용(Borrow)"합니다.
* CR3를 변경하지 않아 TLB 플러시 없음 → 매우 저렴
*/
next->active_mm = oldmm;
mmgrab_lazy_tlb(oldmm);
enter_lazy_tlb(oldmm, next);
} else {
/*
* 유저 프로세스: mm != NULL
* CR3를 새 프로세스의 pgd로 교체해 주소 공간 전환
* PCID 지원 시 NOFLUSH 비트로 TLB 보존
*/
membarrier_switch_mm(rq, oldmm, mm);
switch_mm_irqs_off(oldmm, mm, next);
lru_gen_use_mm(mm);
if (!prev->mm) {
/* prev가 커널 스레드였으면 차용한 mm 반환 */
prev->active_mm = NULL;
rq->prev_mm = oldmm;
}
}
/* ── Phase 3: 다음 태스크를 현재 태스크로 설정 ── */
prepare_lock_switch(rq, next, rf);
/* ── Phase 4: 레지스터/스택 전환 — 여기서 CPU 제어권이 넘어감 ── */
switch_to(prev, next, prev);
/*
* ── Phase 5: 후처리 (새 프로세스의 관점에서 실행) ──
* switch_to() 이후의 코드는 "next" 프로세스가 실행합니다.
* prev 변수는 switch_to 매크로의 3번째 인자로 갱신되어
* "이전에 실행 중이던 진짜 프로세스"를 가리킵니다.
*/
return finish_task_switch(prev);
}
코드 설명
- mm vs active_mm
mm은 유저 공간 주소 공간을 나타냅니다. 커널 스레드는mm=NULL이지만 커널 공간(Kernel Space) 접근을 위해active_mm을 통해 이전 프로세스의 페이지 테이블을 빌려 사용합니다. 커널 주소 공간 매핑(Mapping)은 모든 mm에서 동일하기 때문에 이것이 안전합니다. - mmgrab_lazy_tlb()차용하는 mm의 참조 카운트를 증가시킵니다. 커널 스레드가 active_mm으로 보유하는 동안 mm이 해제되지 않도록 보장합니다. 이는
mm_users가 아닌mm_count를 증가시켜, 유저 프로세스가 종료되어도 mm 구조체 자체는 유지됩니다. - enter_lazy_tlb()Lazy TLB 모드에 진입합니다. 원격 TLB shootdown IPI를 수신해도 TLB를 즉시 무효화(Invalidation)하지 않고 플래그만 세트합니다. 다음에 유저 프로세스로 전환될 때 한꺼번에 처리합니다.
- 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()에서 처리합니다. 잠금을 들고 있는 동안mmdrop()을 호출하면 슬립(Sleep)할 수 있으므로 지연시킵니다.
finish_task_switch() 후처리 분석
switch_to()로 CPU 제어권이 넘어간 후, 새로 실행되는 프로세스가 이전 프로세스의 후처리를 담당합니다. 이 함수는 참조 카운트 해제, 좀비(Zombie) 프로세스 회수, mm 구조체(Struct) 반환 등 중요한 정리 작업을 수행합니다.
/* 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 잠금이 해제된 후 실행되는 부하 분산(Load Balancing) 콜백(Callback)입니다. 마이그레이션 요청, IPI 기반 태스크 이동 등이 이 시점에 처리됩니다. 잠금 내부에서 실행할 수 없는 작업들을 지연 실행합니다.
active_mm 차용(Borrowing) 메커니즘
커널 스레드는 유저 공간 매핑이 없으므로 task_struct.mm = NULL입니다. 하지만 커널 공간 접근을 위한 페이지 테이블이 필요하므로, 이전에 실행 중이던 유저 프로세스의 mm_struct를 active_mm으로 차용합니다. 커널 주소 공간 매핑은 모든 프로세스에서 동일하므로(상위 256TB, PGD 상반부(Top Half)) 이것이 안전합니다.
성능 함의: 커널 스레드(kworker, ksoftirqd 등) 간 Context Switch는 주소 공간 전환이 없어 매우 빠릅니다. 반면 유저 프로세스 간 전환은 CR3 교체, TLB 관리, PCID 할당 등의 비용이 추가됩니다. kthread_create()로 생성한 워커 스레드가 io_uring이나 workqueue를 통해 커널 내부에서 작업을 처리하면, 유저/커널 경계를 넘지 않으므로 Context Switch 비용이 절감됩니다.
thread_struct 구조체 필드 분석
thread_struct는 task_struct 내부에 임베드된 아키텍처 의존 구조체로, Context Switch 시 저장·복원이 필요한 CPU 상태를 보관합니다. x86-64에서는 arch/x86/include/asm/processor.h에 정의됩니다.
/* arch/x86/include/asm/processor.h — thread_struct (x86-64 핵심 필드) */
struct thread_struct {
/* ─── 레지스터/스택 전환 필드 ─── */
unsigned long sp; /* 커널 스택 포인터 — __switch_to_asm이 저장·복원 */
unsigned short fsindex; /* FS 세그먼트 셀렉터 — save_fsgs()가 저장 */
unsigned short gsindex; /* GS 세그먼트 셀렉터 — save_fsgs()가 저장 */
/* ─── 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 — swapgs 시 MSR_KERNEL_GS_BASE에 보관 */
/* ─── FPU / XSTATE ─── */
struct fpu fpu; /* FPU 상태 컨테이너 — xsave_area, 초기화 플래그 포함 */
/* ─── 하드웨어 브레이크포인트 상태 (실제 DR 값은 per-CPU cpu_debugreg[]에 있음) ─── */
struct perf_event *ptrace_bps[HBP_NUM]; /* DR0~DR3 자리에 걸린 ptrace/perf 브레이크포인트 */
unsigned long virtual_dr6; /* #DB 트래핑·싱글스텝용 DR6 소프트웨어 복사본 */
unsigned long ptrace_dr7; /* 사용자가 설정한 DR7 원본값 */
u32 pkru; /* Protection Keys — __switch_to()가 즉시 로드 */
/* ─── I/O 권한 비트맵 ─── */
struct io_bitmap *io_bitmap; /* ioperm() 사용 시 할당 — NULL이면 비용 없음 */
/* ─── 오류 주소 (시그널/폴트 처리) ─── */
unsigned long cr2; /* 최근 페이지 폴트(Page Fault) 주소 — #PF 핸들러가 기록 */
unsigned long trap_nr; /* 최근 예외 번호 — die() 경로에서 사용 */
unsigned long error_code; /* 최근 예외 에러 코드 */
};
코드 설명
- sp 필드커널 스택 포인터 저장 위치입니다.
__switch_to_asm의movq %rsp, TASK_threadsp(%rdi)가 이 필드에 현재 RSP를 저장하고,movq TASK_threadsp(%rsi), %rsp로 복원합니다. Context Switch의 핵심 필드입니다. - fsindex / gsindex 필드FS/GS 세그먼트 셀렉터입니다.
save_fsgs()가savesegment로 저장하고__switch_to()의x86_fsgsbase_load()가 복원합니다. 참고로 x86-64thread_struct에는sp0필드가 없습니다(x86-32 전용). Ring 0 진입 스택은 CPU당 TSS.sp0(엔트리 트램폴린 스택, 부팅 시 고정)과 per-CPUpcpu_hot.top_of_stack으로 관리되며,__switch_to()가this_cpu_write(pcpu_hot.top_of_stack, task_top_of_stack(next))로 갱신합니다. - fsbase / gsbase 필드x86-64 TLS(Thread-Local Storage) 구현에 사용됩니다.
fsbase는 glibc의__thread변수 포인터(pthread 구조체 주소)이고,gsbase는 유저 공간 GS.base입니다. 커널 모드에서는swapgs로 GS.base가 커널 per-CPU 영역(MSR_GS_BASE)을 가리키므로, 유저 GS.base는 MSR_KERNEL_GS_BASE에 보관됩니다.save_fsgs()가 이 값을thread.gsbase에 저장하고x86_fsgsbase_load()가 WRMSR로 복원합니다. - fpu 필드FPU/SIMD 상태 컨테이너(Container)입니다. Eager 기본값에서
switch_fpu()가save_fpregs_to_fpstate()로 즉시 저장하고, 복원은TIF_NEED_FPU_LOAD플래그로 유저 복귀 시점에 지연됩니다(과거 Lazy 방식의 CR0.TS/#NM 메커니즘은 폐기). XSAVE 영역, 초기화 플래그, 퍼미션 비트를 포함합니다. - ptrace_bps / ptrace_dr7 필드하드웨어 브레이크포인트의 소프트웨어 상태입니다. 실제 DR0~DR7 값은
thread_struct가 아니라arch/x86/kernel/hw_breakpoint.c의 per-CPU 변수(cpu_debugreg[],cpu_dr7)에 유지되고, perf 이벤트가 스케줄 인/아웃될 때arch_install_hw_breakpoint()가 갱신합니다. 브레이크포인트를 쓰지 않는 태스크에서는 이 경로가 전혀 실행되지 않습니다. - io_bitmap 필드
ioperm()시스템 콜로 I/O 포트 직접 접근 권한을 부여받은 프로세스만 이 포인터가 non-NULL입니다.tss_update_io_bitmap()은 TSS의 I/O 비트맵 오프셋을 업데이트합니다. 일반 프로세스는 NULL이므로unlikely()경로입니다.
inactive_task_frame 구조체: 커널 스택의 스냅샷
inactive_task_frame은 프로세스가 CPU에서 내려올 때(스케줄 아웃) 커널 스택 최상단에 저장되는 레지스터 프레임입니다. __switch_to_asm의 push 명령들이 이 구조체와 정확히 대응됩니다.
/* arch/x86/include/asm/switch_to.h */
struct inactive_task_frame {
#ifdef CONFIG_X86_64
unsigned long r15; /* callee-saved: pushq %r15 순서 */
unsigned long r14; /* callee-saved */
unsigned long r13; /* callee-saved */
unsigned long r12; /* callee-saved (커널 스레드: 함수 인자) */
#else
unsigned long flags; /* x86-32 전용: EFLAGS */
unsigned long si; /* x86-32 전용: ESI */
unsigned long di; /* x86-32 전용: EDI (커널 스레드: 함수 인자) */
#endif
unsigned long bx; /* callee-saved: RBX (커널 스레드: 실행 함수) */
/* 아래 두 필드는 반드시 연속 — get_frame_pointer()가 스택 프레임 헤더로 사용 */
unsigned long bp; /* callee-saved: RBP (frame pointer) */
unsigned long ret_addr; /* 재개 주소 (RIP 역할) — 6회 pop 후 ret가 점프 */
};
코드 설명
- 구조체 배치 원리스택은 높은 주소에서 낮은 주소 방향으로 자랍니다.
__switch_to_asm이pushq %rbp,%rbx,%r12~%r15순서로 push하면 마지막에 push된%r15가 가장 낮은 주소(thread.sp)에 위치합니다. 구조체 필드 선언 순서(r15→ret_addr)는 이 스택 레이아웃과 정확히 일치하며,__switch_to_asm이 popq %r15..%rbp 6회 후ret로 ret_addr에 도달합니다. - r15~bx 필드x86-64 System V ABI의 callee-saved 레지스터 6개입니다. 함수 호출 규약(Calling Convention)상 이 레지스터들은 호출된 함수(callee)가 보존해야 합니다.
__switch_to_asm은call이 아닌 어셈블리 경계에서 스택을 교체하므로 반드시 직접 저장·복원해야 합니다. - ret_addr 필드가장 중요한 필드입니다.
__switch_to()가ret명령을 실행하면 이 주소로 점프합니다. 일반 프로세스는 이전에__switch_to_asm을 호출한context_switch()내부 주소이고,fork()로 갓 생성된 프로세스는copy_thread()가ret_from_fork_asm주소를 미리 채워둡니다. - bp 필드RBP(frame pointer)는 스택 프레임(Stack Frame) 체인의 연결 포인터입니다.
CONFIG_FRAME_POINTER가 활성화된 커널에서bp필드를 통해 스택 언와인딩(unwinding)이 가능합니다. perf/ftrace의 콜 스택 추적(Stack Trace)이 이 체인에 의존합니다. - x86-32 추가 필드x86-32에서는
flags(EFLAGS),si(ESI),di(EDI)를 추가로 저장합니다. x86-32 ABI는 ESI/EDI가 callee-saved이지만 R12~R15가 없어 6개의 callee-saved 레지스터를 채우기 위해 이들을 저장합니다. 반대로 x86-64는 RSI/RDI가 caller-saved이고 R12~R15가 callee-saved라 R12~R15를 저장합니다.
switch_mm_irqs_off() 소스 분석
주소 공간 전환의 핵심 함수입니다. PCID(Process Context Identifier) 지원 여부에 따라 TLB 플러시(Flush) 비용이 크게 달라집니다.
/* 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 mm_struct *real_prev = this_cpu_read(cpu_tlbstate.loaded_mm);
struct tlb_context *next_tlb;
unsigned long new_cr3;
u16 new_asid;
bool need_flush;
/* 이 CPU에 실제 로드된 mm(real_prev)이 next와 같으면 CR3 재기록 불필요
* (커널 스레드 → 유저 복귀, 같은 프로세스 스레드 간 전환 등).
* 커널/유저 CR3 쌍 전환은 시스템 콜 진입·복귀 시 SWITCH_TO_*_CR3가 담당 */
if (real_prev == next)
return;
/* PCID(ASID) 할당: CPU 별 TLB 슬롯 번호 결정 */
new_asid = choose_new_asid(next, atomic64_read(&next->context.tlb_gen),
&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()이후에 호출되므로 이 불변 조건이 항상 보장됩니다. - real_prev == next 분기커널 스레드가 유저 프로세스로 전환되는 경우처럼
active_mm이 같을 때 발생합니다. 비교 대상은 인자prev가 아니라 이 CPU에 실제 로드된 mm(cpu_tlbstate.loaded_mm, real_prev)이며, 같으면 커널 CR3가 이미 해당 mm의 커널 PGD를 가리키므로 CR3를 재기록하지 않고 상태만 갱신한 뒤 즉시 반환합니다. KPTI 활성화 시 커널/유저 CR3 쌍 전환은 시스템 콜 진입·복귀 시점의SWITCH_TO_KERNEL_CR3/SWITCH_TO_USER_CR3가 담당하므로 이 경로에서는 추가 작업이 필요 없습니다. - choose_new_asid()이 CPU 의 동적 ASID 슬롯(
TLB_NR_DYN_ASIDS= 6개, 0~5) 중 하나를 next mm에 배정합니다. 이 CPU 에서 그 mm 이 쓰던 슬롯의ctx_id가 일치하면 같은 ASID 를 재사용하고, 슬롯의tlb_gen이next_tlb_gen보다 작을 때만need_flush = true가 됩니다. 빈 슬롯이 없으면next_asid를 한 칸 전진시켜 새 슬롯을 잡습니다. 하드웨어INVLPGB를 지원하면 전역 ASID 공간에서 슬롯을 받아 원격 TLB 플러시를 하드웨어에 맡깁니다. - build_cr3()CR3 레지스터에 기록할 64비트 값을 생성합니다. 상위 비트는 페이지(Page) 글로벌 디렉터리(PGD)의 물리 주소(Physical Address)이고, LAM 마스크와 PCID 값을 함께 OR 합니다. PCID 자리에는 ASID 가 아니라
kern_pcid()가 돌려주는asid + 1이 들어갑니다(PCID 0 은 PCID 를 모르는 코드용으로 예약). x86-64에서 CR3[63]이 NOFLUSH 비트입니다. - X86_CR3_PCID_NOFLUSH 분기PCID 지원 CPU에서 TLB 플러시가 불필요한 경우, CR3에 이 비트를 세트하고
write_cr3()를 호출합니다. CPU는 이 비트를 보고 CR3 교체 시 TLB를 비우지 않습니다. 이 경로가 Context Switch 비용을 대폭 줄이는 핵심 최적화입니다. - next_tlb 갱신
ctx_id와tlb_gen은 원격 TLB 플러시(IPI 기반) 추적에 사용됩니다. 다른 CPU가 이 mm의 페이지 테이블을 수정해tlb_gen을 증가시키면, 다음 번 이 CPU에서 해당 mm으로 전환될 때choose_new_asid()가need_flush = true를 반환합니다.
커널 스택 구조와 전환
각 프로세스(스레드)는 독립적인 커널 스택을 가집니다. x86-64에서 기본 크기는 16KB(4페이지)이며, CONFIG_THREAD_SIZE_ORDER로 설정됩니다.
/* 커널 스택 구조 (높은 주소 → 낮은 주소) */
struct thread_info { /* v4.9+ (CONFIG_THREAD_INFO_IN_TASK): task_struct 내부에 위치 */
unsigned long flags; /* TIF_NEED_RESCHED 등 */
unsigned long syscall_work;
u32 status;
/* ... */
};
/*
* task_top_of_stack(): 커널 스택의 최상단 주소
* = (unsigned long)(task_pt_regs(task) + 1)
* = task->stack + THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING
* 이 값이 per-CPU pcpu_hot.top_of_stack에 기록되어
* 시스템 콜 진입(entry_SYSCALL_64) 시 RSP로 사용됨
*/
static __always_inline unsigned long task_top_of_stack(struct task_struct *task)
{
return (unsigned long)(task_pt_regs(task) + 1);
}
/* thread_struct: task_struct 내 아키텍처별 레지스터 상태 */
struct thread_struct {
unsigned long sp; /* 커널 스택 포인터 (switch_to로 교환) */
unsigned short fsindex; /* FS 세그먼트 셀렉터 */
unsigned short gsindex; /* GS 세그먼트 셀렉터 */
unsigned long fsbase; /* FS.base (유저 TLS 포인터) */
unsigned long gsbase; /* 유저 GS.base (swapgs로 커널 percpu와 교환) */
struct fpu fpu; /* FPU/SIMD 상태 */
/* ... */
};
코드 설명
커널 스택의 메모리 레이아웃과 Context Switch에서 사용되는 핵심 자료구조입니다.
- struct thread_info스택 최하단(또는 v4.9+에서는
task_struct내부)에 위치하며,TIF_NEED_RESCHED등 스케줄링 플래그를 담고 있습니다.flags필드는 인터럽트 복귀 경로에서 매번 검사되어 선점 여부를 결정합니다. - task_top_of_stack()현대 커널에서는
task_pt_regs(task) + 1=task->stack + THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING(기본 16KB)로 계산합니다. 이 값은 per-CPU 변수pcpu_hot.top_of_stack에 기록되어, 유저 모드에서 시스템 콜 진입 시entry_SYSCALL_64가 RSP로 로드합니다. 인터럽트 경로는 TSS.sp0(엔트리 트램폴린 스택, 부팅 시 고정)으로 1차 진입한 뒤 이 값으로 태스크 스택에 도달합니다. - thread_struct.sp
switch_to()에서 교환되는 커널 스택 포인터입니다.__switch_to_asm이prev->thread.sp에 현재 RSP를 저장하고,next->thread.sp에서 RSP를 복원하여 스택 전환을 완료합니다. - thread_struct.fsindex/gsindex·fsbase/gsbasex86-64에서 FS.base는 유저 공간 TLS(Thread Local Storage) 포인터, GS.base는 유저 공간 GS 포인터로 사용됩니다. 커널 모드에서는
swapgs로 GS.base가 커널 per-CPU 영역을 가리키므로, 태스크의gsbase는 커널 진입 시save_fsgs()가 MSR_KERNEL_GS_BASE에서 읽어 저장하고__switch_to()가x86_fsgsbase_load()로 복원합니다. - thread_struct.fpuFPU/SIMD 상태(XSAVE 영역)를 가리킵니다. Context Switch 시
switch_fpu()가save_fpregs_to_fpstate()로 저장하고, 복원은TIF_NEED_FPU_LOAD기반으로 유저 복귀 시 수행됩니다.
커널 스택 가드 페이지와 오버플로 탐지
CONFIG_VMAP_STACK=y(x86_64 기본)가 활성화되면 커널 스택은 vmalloc 영역에 할당되며, 스택 양쪽 끝에 매핑되지 않은 가드 페이지가 자연스럽게 배치됩니다. 스택 오버플로(Stack Overflow)가 가드 페이지를 침범하면 즉시 페이지 폴트(Page Fault)가 발생하여 인접 메모리 훼손을 방지합니다.
/* 스택 경계 계산 함수 */
/* 스택 최하단 (낮은 주소) — include/linux/sched/task_stack.h */
static inline unsigned long *end_of_stack(const struct task_struct *task)
{
return task->stack;
}
/* 스택 최상단 (높은 주소) */
static inline unsigned long task_top_of_stack(struct task_struct *task)
{
return (unsigned long)(task->stack) + THREAD_SIZE;
}
/* Guard page 위치 (VMAP_STACK 활성 시, vmalloc 갭)
*
* [Guard 4KB] [task->stack ··· task->stack + THREAD_SIZE] [Guard 4KB]
* S - 4KB S (end_of_stack) S + 16KB S + 20KB
* (unmapped) (스택 최하단) (스택 최상단) (unmapped)
*/
end_of_stack())에 STACK_END_MAGIC(0x57AC6E9D)을 배치하여 schedule() 호출 시점에 훼손 여부를 검사합니다. 이는 즉시 감지가 아닌 사후 감지이므로, VMAP_STACK 활성화를 권장합니다. 보안 관점의 상세 분석은 커널 하드닝 — VMAP_STACK을 참조하십시오.
커널 스택 훼손의 원인 분류
커널 스택 훼손(Stack Corruption)은 다양한 경로로 발생합니다. 원인을 체계적으로 이해해야 적절한 방어 메커니즘을 선택할 수 있습니다.
| 원인 | 설명 | 방어 메커니즘 |
|---|---|---|
| 스택 버퍼 오버플로(Buffer Overflow) | memcpy(), strcpy() 등으로 로컬 버퍼(Buffer) 경계 초과 쓰기 | Stack Protector, FORTIFY_SOURCE |
| 스택 깊이 고갈 | 깊은 재귀 호출 (파일시스템(Filesystem) 경로 해석, symlink 중첩, 네트워크 프로토콜 체인) | VMAP_STACK, ftrace stack tracer |
| DMA→스택 메모리 | 디바이스가 스택 영역의 물리 페이지에 DMA 쓰기 수행 | IOMMU, bounce buffer, dma_alloc_coherent() |
| 스택 변수 use-after-free | 지역 변수 포인터를 함수 반환 후 참조 (댕글링 포인터(Dangling Pointer)) | -Wreturn-local-addr, KASAN |
| thread_info 덮어쓰기 | pre-4.9 커널에서 스택 바닥의 thread_info 변조 → 권한 상승 | CONFIG_THREAD_INFO_IN_TASK |
| VLA/alloca 과다 사용 | 런타임에 결정되는 가변 길이 배열(Variable-Length Array)로 스택 프레임 크기 폭증 | -Wvla, 커널 VLA 제거 (v4.20+) |
| 중첩 인터럽트 | Per-CPU IRQ 스택 미사용 시 인터럽트가 프로세스 스택을 소비 | Per-CPU IRQ 스택, IST |
-Wvla 빌드 플래그 추가). VLA는 컴파일 타임에 스택 사용량을 예측할 수 없게 하여 CONFIG_FRAME_WARN 검사를 무력화하며, 공격자가 크기 매개변수를 제어하면 스택 고갈을 유발할 수 있었습니다.
스택 오버플로 전파와 훼손 영역
커널 스택은 높은 주소에서 낮은 주소로 성장합니다. 함수 내부에서 로컬 버퍼 오버플로가 발생하면, 데이터는 높은 주소 방향(호출자의 프레임)으로 전파되면서 단계적으로 더 심각한 훼손을 유발합니다.
/* v4.9+ : thread_info는 task_struct 내부에 임베드 */
/* include/linux/sched.h */
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info; /* 스택이 아닌 task_struct 내부 */
#endif
unsigned int __state;
void *stack; /* 커널 스택 포인터 */
/* ... */
};
/* pre-4.9 : thread_info가 스택 바닥에 위치 (취약) */
/*
* [스택 최상단 (높은 주소)]
* │ pt_regs, 함수 프레임 ... │ ← RSP가 여기서 아래로 성장
* │ ... (미사용 공간) ... │
* │ thread_info │ ← 스택 오버플로 시 덮어쓰기 가능!
* [스택 최하단 (낮은 주소)]
*
* 공격 시나리오: 스택 오버플로 → thread_info.addr_limit 변조
* → set_fs(KERNEL_DS) 효과 → 커널 메모리 임의 접근
*/
스택 훼손 디버깅(Debugging)
커널 스택 훼손은 발생 시점과 증상 발현 시점이 다를 수 있어 디버깅이 까다롭습니다. 다음 도구들을 활용하면 스택 문제를 사전에 탐지하거나 사후에 분석할 수 있습니다.
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의 무결성(Integrity)을 검사합니다. VMAP_STACK이 비활성인 환경에서 스택 오버플로를 가능한 빨리(다음 스케줄링 시점에) 감지합니다.
/* kernel/sched/core.c — schedule_debug() 내부 */
static inline void schedule_debug(struct task_struct *prev, bool preempt)
{
#ifdef CONFIG_SCHED_STACK_END_CHECK
if (task_stack_end_corrupted(prev))
panic("corrupted stack end detected inside scheduler\n");
#endif
}
/* include/linux/sched/task_stack.h */
static inline bool task_stack_end_corrupted(const struct task_struct *tsk)
{
return *end_of_stack(tsk) != STACK_END_MAGIC;
}
KASAN 스택 계측 (CONFIG_KASAN_STACK)
CONFIG_KASAN_STACK=y를 활성화하면 KASAN이 스택 변수에 대해서도 레드존(Redzone)을 배치합니다. 로컬 배열의 경계를 초과하는 읽기/쓰기가 발생하면 즉시 리포트를 생성합니다.
# KASAN 스택 OOB 탐지 예시 리포트:
BUG: KASAN: stack-out-of-bounds in vulnerable_function+0x48/0x80
Write of size 128 at addr ffff88800d4a7c40 by task exploit/1234
addr ffff88800d4a7c40 is located in stack of task exploit/1234
at offset 64 in frame:
vulnerable_function+0x0/0x80
this frame has 1 object:
[32, 96) 'buf' <== Memory access at offset 64 overflows this variable
/proc/<pid>/stack
실행 중인 프로세스의 현재 커널 콜 스택을 확인합니다. CONFIG_STACKTRACE 활성 시 사용 가능합니다.
# PID 1234의 현재 커널 스택 확인
cat /proc/1234/stack
# [<0>] __schedule+0x2f0/0x870
# [<0>] schedule+0x5e/0xd0
# [<0>] schedule_timeout+0x1d5/0x2b0
# [<0>] wait_for_completion+0x8e/0xf0
# [<0>] __do_sys_wait4+0xb0/0x100
손상된 스택 트레이스 해석
스택이 훼손된 상태에서 크래시가 발생하면, 스택 트레이스에 특징적인 패턴이 나타납니다.
# 정상 스택 트레이스:
Call Trace:
schedule+0x5e/0xd0
schedule_timeout+0x1d5/0x2b0
wait_for_completion+0x8e/0xf0
# 훼손된 스택 트레이스 (Phase 2 — RBP 체인 파괴):
Call Trace:
<TASK>
? vulnerable_function+0x48/0x80 ← 마지막 유효 프레임
? 0xdeadbeefcafebabe ← 가비지 주소 (RBP 손상)
? 0x4141414141414141 ← 오버플로 데이터 패턴
</TASK>
# crash 유틸리티에서 bt 명령:
crash> bt
PID: 1234 TASK: ffff88800d4a0000 CPU: 0 COMMAND: "exploit"
#0 [ffff88800d4a7c00] die at ffffffff81023456
#1 [ffff88800d4a7c30] page_fault at ffffffff81a00ace
[exception RIP: 0x4141414141414141] ← RIP가 오버플로 패턴
RBP: 4141414141414141 RSP: ffff88800d4a7c98
--- <유효하지 않은 프레임 포인터 — 추적 중단> ---
0x41414141, 0xCC 등)이 보이면 버퍼 오버플로를 의심합니다. KASAN이 활성화되어 있다면 0xCC 패턴은 KASAN 레드존 마커일 수 있습니다. 0x57AC6E9D(STACK_END_MAGIC)가 훼손되었다면 스택 깊이 고갈이 원인입니다.
새 태스크의 첫 번째 실행
fork()나 clone()으로 생성된 새 프로세스는 아직 단 한 번도 CPU를 얻은 적이 없습니다. switch_to()는 이전에 저장해 둔 레지스터를 복원하는 방식으로 동작하는데, 새 프로세스에는 "저장해 둔" 상태가 없습니다. 이 문제를 해결하기 위해 copy_thread()가 커널 스택을 미리 셋업하고, ret_from_fork_asm 주소를 복귀 주소로 배치합니다.
/* arch/x86/kernel/process.c — fork() 시 커널 스택 초기화 */
/* fork_frame: inactive_task_frame + pt_regs를 연속 배치한 공용 구조체 */
struct fork_frame {
struct inactive_task_frame frame;
struct pt_regs regs;
};
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
unsigned long sp = args->stack;
struct inactive_task_frame *frame;
struct fork_frame *fork_frame;
struct pt_regs *childregs;
/* 새 태스크 커널 스택 최상단에 pt_regs 공간 예약 */
childregs = task_pt_regs(p);
fork_frame = container_of(childregs, struct fork_frame, regs);
frame = &fork_frame->frame;
/* 첫 실행 시 RBP(프레임 포인터)와 복귀 주소를 미리 배치 */
frame->bp = encode_frame_pointer(childregs);
frame->ret_addr = (unsigned long)ret_from_fork_asm;
p->thread.sp = (unsigned long)fork_frame;
/* ── 커널 스레드 경로 (PF_KTHREAD) ── */
if (unlikely(p->flags & PF_KTHREAD)) {
/* 커널 스레드: 유저 레지스터 상태 필요 없음 */
memset(childregs, 0, sizeof(*childregs));
/* kthread_frame_init(): RBX에 실행 함수, R12에 인자 저장 */
kthread_frame_init(frame, args->fn, args->fn_arg);
return 0;
}
/* ── 유저 프로세스 경로 ── */
/* callee-saved 중 bx만 0: ret_from_fork가 fn == NULL임을 보고 유저 복귀 경로를 탐 */
frame->bx = 0;
/* 부모의 유저 레지스터 상태 복사 */
*childregs = *current_pt_regs();
childregs->ax = 0; /* fork() 자식 반환값 = 0 */
if (sp)
childregs->sp = sp;
/* 커널이 만드는 유저 스레드(args->fn 설정): sp/ip를 0으로 리셋하고
* 커널 스레드와 같은 frame 초기화를 수행한다 */
if (unlikely(args->fn)) {
childregs->sp = 0;
childregs->ip = 0;
kthread_frame_init(frame, args->fn, args->fn_arg);
return 0;
}
return 0;
}
/*
* switch_to() 내부 동작 (새 태스크 첫 실행):
*
* 1. __switch_to_asm: RSP ← p->thread.sp (fork_frame 시작 위치)
* 2. pop R15, R14, R13, R12, RBX, RBP (유저 프로세스는 bp/bx만 유효)
* 3. jmp __switch_to → return prev (RAX에 prev 저장)
* 4. ret → 스택의 ret_addr(ret_from_fork_asm)로 점프
*/
/* arch/x86/entry/entry_64.S — 새 태스크 첫 진입점 (frame.ret_addr이 가리킴) */
SYM_CODE_START(ret_from_fork_asm)
/*
* __switch_to_asm의 pop 6회 → jmp __switch_to의 ret 직후 이 지점에 도달:
* - RBX: 커널 스레드 함수 (frame.bx, 유저 프로세스는 0)
* - R12: 커널 스레드 인자 (frame.r12, 유저 프로세스는 무시됨)
* - RAX: __switch_to()의 반환값 = prev
*/
/* C 함수 ret_from_fork(prev, regs, fn, fn_arg) 호출 준비 */
movq %rax, %rdi /* 1번째 인자: prev */
movq %rsp, %rsi /* 2번째 인자: regs (pt_regs) */
movq %rbx, %rdx /* 3번째 인자: fn */
movq %r12, %rcx /* 4번째 인자: fn_arg */
call ret_from_fork
/* 유저 공간으로 복귀 (FRED에서는 asm_fred_exit_user 경로) */
jmp swapgs_restore_regs_and_return_to_usermode
SYM_CODE_END(ret_from_fork_asm)
/* arch/x86/kernel/process.c — 새 태스크 첫 실행 본체 */
void ret_from_fork(struct task_struct *prev, struct pt_regs *regs,
int (*fn)(void *), void *fn_arg)
{
/* 첫 실행 시에만 이 경로가 사용됨 — prev 태스크 정리 */
schedule_tail(prev); /* 내부에서 finish_task_switch(prev) 호출 */
if (fn) {
/* 커널 스레드: 등록된 함수 실행 */
fn(fn_arg);
/* kernel_execve() 성공 시 유저 복귀를 위해 ax를 0으로 */
regs->ax = 0;
}
/* fn이 NULL이든 반환했든 항상 유저 공간 복귀 경로로 */
syscall_exit_to_user_mode(regs);
}
코드 설명
arch/x86/kernel/process.c의 copy_thread()와 ret_from_fork(), arch/x86/entry/entry_64.S의 ret_from_fork_asm이 새 태스크의 첫 번째 Context Switch를 준비합니다.
- copy_thread() 커널 스레드 경로
p->flags & PF_KTHREAD가 설정된 경우가 커널 스레드입니다.pt_regs를 0으로 초기화하고,kthread_frame_init()이frame.bx에 실행 함수,frame.r12에 인자를 저장합니다.frame.ret_addr은 앞단 공통 구간에서ret_from_fork_asm으로 이미 설정되어 있습니다. 커널이 만드는 유저 스레드(args->fn설정, PF_KTHREAD는 아님)는childregs->sp/ip를 0으로 리셋한 뒤 같은kthread_frame_init()을 호출합니다. - copy_thread() 유저 프로세스 경로callee-saved 레지스터 중
frame.bx만 0으로 설정합니다(=fn없음).frame.ret_addr에는 앞단에서ret_from_fork_asm이 들어 있습니다. 이어 부모의pt_regs를 복사하고childregs->ax = 0으로 설정하여 자식에서fork()반환값이 0이 되게 하며,clone()이 유저 스택을 지정한 경우childregs->sp를 그 주소로 바꿉니다. r12~r15는 copy_thread가 초기화하지 않는데, 유저 복귀 시 사용되지 않으므로 값이 무의미합니다. - __switch_to_asm 시뮬레이션
thread.sp를fork_frame시작 주소로 설정합니다.__switch_to_asm이 pop 6회(R15~RBP) 후jmp __switch_to하고,__switch_to의ret가 스택의ret_addr을 pop하면서ret_from_fork_asm에 도달합니다. 이 트릭 덕분에 새 태스크도 기존 Context Switch 코드 경로를 그대로 사용합니다. - ret_from_fork 진입어셈블리
ret_from_fork_asm이 레지스터 인자(prev, regs, fn, fn_arg)를 구성해 C 함수ret_from_fork()를 호출합니다. C 함수가schedule_tail(prev)로 이전 태스크를 정리한 뒤,fn이 NULL이 아니면(커널 스레드)fn(fn_arg)를 실행하고, NULL이면syscall_exit_to_user_mode(regs)를 지나 어셈블리가swapgs_restore_regs_and_return_to_usermode로 점프하여 유저 공간으로 복귀합니다. - 커널 스레드 종료커널 스레드의
fn()은 보통kernel/kthread.c의kthread()래퍼로 실행되며,fn()이 반환되면kthread_exit()(=do_exit())으로 태스크가 종료됩니다.ret_from_fork()자체는 종료를 호출하지 않고,kernel_execve()로 성공하면 커널 스레드도 유저 공간으로 복귀할 수 있습니다.
호출 체인: fork()/clone() → copy_thread()(스택 셋업) → 첫 switch_to() → ret_from_fork_asm → ret_from_fork() → 유저 복귀 또는 fn() 실행
exec() 후 첫 실행: exec()는 기존 프로세스의 주소 공간을 교체합니다. execve 시스템 콜이 완료되면 start_thread()로 pt_regs의 RIP과 RSP를 새 바이너리의 진입점(Entry Point)과 유저 스택으로 설정합니다. 이후 sysret으로 복귀할 때 새 바이너리의 첫 명령이 실행됩니다.
pt_regs 구조체 심층 분석
Context Switch 흐름도와 커널 스택 레이아웃에서 자주 등장하는 pt_regs는 유저 모드에서 커널 모드로 진입할 때(시스템 콜·인터럽트·예외) 유저 공간의 레지스터 상태를 커널 스택에 저장하는 구조체입니다. Context Switch에서 사용하는 inactive_task_frame과는 목적과 생성 시점이 다릅니다.
/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
/* 시스템 콜/인터럽트 진입 시 커널이 저장 */
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* 인터럽트 진입 경로에서 추가 저장 */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax; /* 시스템 콜 번호 or 반환값 */
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/* 아래 5개는 CPU가 자동 저장 (ring 전환 시) */
unsigned long orig_rax; /* 원본 syscall 번호 (재시작 판단용) */
unsigned long rip; /* 다음 실행할 유저 공간 명령 주소 */
unsigned long cs; /* 코드 세그먼트 (유저: 0x33) */
unsigned long eflags; /* 조건 플래그 */
unsigned long rsp; /* 유저 스택 포인터 */
unsigned long ss; /* 스택 세그먼트 (유저: 0x2b) */
};
코드 설명
- r15 ~ rbxcallee-saved 레지스터 6개입니다. 시스템 콜 경로(
entry_SYSCALL_64)에서는 ABI 규약상 이미 보존된 상태이므로 생략되기도 합니다. 인터럽트·예외 경로(common_interrupt)에서는 모든 레지스터를 저장합니다. - r11 ~ rdicaller-saved 레지스터입니다. 시스템 콜 인자(rdi, rsi, rdx, r10, r8, r9)와 임시 계산 결과를 포함합니다. 커널이 이 레지스터를 수정하므로 반드시 저장해야 합니다.
- orig_rax시스템 콜 번호의 원본 복사본입니다. 시스템 콜이 중단 후 재시작(Reboot)이 아닌 재개(
ERESTARTSYS)되어야 할 때 원본 번호 판단에 사용됩니다. rax가 반환값으로 덮어쓰여도 이 필드에서 원본 번호를 복원합니다. - rip, cs, eflags, rsp, ssCPU가 ring 전환 시 자동으로 스택에 push하는 5개 필드입니다. 커널이 별도로 저장할 필요가 없습니다.
sysret/iret명령으로 유저 공간 복귀 시 이 값들이 CPU 레지스터로 자동 복원됩니다.
pt_regs vs inactive_task_frame
pt_regs와 inactive_task_frame은 둘 다 커널 스택에 저장되지만 서로 다른 시점과 목적을 가집니다:
| 항목 | pt_regs |
inactive_task_frame |
|---|---|---|
| 저장 시점 | 유저→커널 진입 시 (syscall, interrupt) | Context Switch 시 (__switch_to_asm) |
| 저장 주체 | CPU 일부 자동 + 커널 어셈블리 | 커널 어셈블리 (__switch_to_asm) |
| 저장 레지스터 | 모든 범용 레지스터 + RIP/RSP/RFLAGS | callee-saved 6개 (RBX, RBP, R12~R15) + RIP |
| 복원 시점 | 커널→유저 복귀 시 (sysret/iret) |
다음 Context Switch로 이 태스크가 선택될 때 |
| 위치 (커널 스택) | 스택 최상단 (THREAD_SIZE - sizeof(pt_regs)) | 스택 중간 (switch_to 호출 시점의 RSP) |
| 관련 함수 | task_pt_regs(task)로 접근 |
task->thread.sp로 접근 |
유저 프로세스의 커널 스택 레이아웃: 유저 태스크가 시스템 콜을 호출하면 커널 스택 최상단에 pt_regs가 push됩니다. 이후 해당 태스크가 슬립 상태로 들어가 Context Switch가 발생하면, 스택 아래쪽에 inactive_task_frame이 추가됩니다. 다시 깨어나면 역순으로 복원됩니다.
저장·복원되는 레지스터
task_struct ↔ thread_struct ↔ 커널 스택 메모리 관계
Context Switch에 관련된 세 가지 핵심 자료구조 — task_struct, thread_struct, 커널 스택 — 의 메모리 배치와 상호 참조 관계를 이해하면 switch_to 과정이 명확해집니다.
| 레지스터 종류 | 예시 (x86-64) | 저장 방식 | 비고 |
|---|---|---|---|
| callee-saved | RBX, RBP, R12~R15 | __switch_to_asm (push/pop) | ABI 상 함수가 직접 보존 |
| 명령 포인터 | RIP | 스택의 반환 주소 | call/ret으로 자동 관리 |
| 스택 포인터 | RSP | thread.sp 필드 | switch_to_asm 핵심 교환 |
| 플래그 | RFLAGS | 스택 (인터럽트 프레임) | 인터럽트 재진입 시 복원 |
| TLS 세그먼트 | FS, GS base MSR | __switch_to() / load_TLS() | 스레드 로컬 변수용 |
| 페이지 테이블 | CR3 | switch_mm (write_cr3) | PCID 포함 |
| FPU/SIMD | x87, SSE, AVX, AVX-512 | Eager XSAVE (switch_fpu) | 576B~2.4KB, 유저 복귀 시 복원 |
| 디버그 | DR0~DR7 | perf 경로: arch_install_hw_breakpoint() | 태스크에 브레이크포인트가 있을 때만 |
FPU/SIMD 상태 관리와 전환 비용
FPU(x87)/SSE/AVX/AVX-512 레지스터 집합은 크기가 커서(기본 XSAVE 576B, AVX-512 사용 시 약 2.4KB, AMX 사용 시 약 10.4KB) 매 Context Switch마다 저장하면 성능에 영향을 줍니다. Linux 커널은 v4.6(commit 58122bf1d856, "x86/fpu: Default eagerfpu=on on all CPUs")부터 Eager FPU(전환 시 즉시 저장)를 모든 CPU 의 기본값으로 사용합니다. Eager/Lazy 두 모드 자체는 v3.7(commit 304bceda6a18, "x86, fpu: use non-lazy fpu restore for processors supporting xsave")에서 도입되어 XSAVEOPT CPU 에만 Eager 가 자동 적용되었고, Lazy 방식(CR0.TS 비트로 저장 지연)은 보안 문제와 예측 불가능한 #NM 예외 지연 때문에 v4.10(commit 5a83d60c074d, "x86/fpu: Remove irq_ts_save() and irq_ts_restore()", commit c592b5734706, "x86/fpu: Remove use_eager_fpu()")에서 태스크 전환 경로가 제거되었습니다.
Eager FPU 기본값과 과거 Lazy 방식
/* arch/x86/include/asm/fpu/sched.h — 전환 시 FPU 상태 저장 (Eager 기본값) */
static inline void switch_fpu(struct task_struct *old, int cpu)
{
if (!test_tsk_thread_flag(old, TIF_NEED_FPU_LOAD) &&
cpu_feature_enabled(X86_FEATURE_FPU) &&
!(old->flags & (PF_KTHREAD | PF_USER_WORKER))) {
struct fpu *old_fpu = x86_task_fpu(old);
/* Eager: 미저장 상태면 즉시 XSAVE, 복원은 유저 복귀 시점으로 지연 */
set_tsk_thread_flag(old, TIF_NEED_FPU_LOAD);
save_fpregs_to_fpstate(old_fpu);
old_fpu->last_cpu = cpu; /* 같은 CPU 복귀 + 컨텍스트 소유 시 XRSTOR 생략 */
}
}
/* 유저 공간 복귀 시 (fpregs_restore_userregs): XRSTOR로 FPU 레지스터 복원 */
/* TIF_NEED_FPU_LOAD가 세트되어 있어야 하며, 복원 후 플래그를 클리어합니다 */
/* #NM 예외 (Device Not Available) — 현대 커널은 커널 FPU 보호 전용 */
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_device_not_available)
{
/* 커널 스레드/인터럽트가 FPU를 사용하는 동안 상태 오염을 방지 */
/* (fpu_swap_kernel_fpstate()로 커널 FPU 컨텍스트 전환) */
}
/* XSAVE 컴포넌트 마스크 (xstate_bv) — XCR0 비트 번호와 1:1 대응 */
#define XFEATURE_MASK_FP (1 << 0) /* x87 FPU (160B) */
#define XFEATURE_MASK_SSE (1 << 1) /* SSE/XMM (256B) */
#define XFEATURE_MASK_YMM (1 << 2) /* AVX/YMM (256B) */
#define XFEATURE_MASK_AVX512 (7 << 5) /* OPMASK+ZMM_Hi256+Hi16_ZMM (~1.6KB) */
#define XFEATURE_MASK_PKRU (1 << 9) /* PKRU (8B) */
#define XFEATURE_MASK_XTILE_DATA (1 << 18) /* AMX TILEDATA (8KB) */
코드 설명
현대 커널의 Eager FPU 전환 방식(arch/x86/include/asm/fpu/sched.h)과 XSAVE 컴포넌트 마스크 정의입니다. 과거 Lazy 방식의 이해는 왜 Eager로 바뀌었는지(보안·지연 예측성)를 설명하는 배경이 됩니다.
- switch_fpu() — Eager
TIF_NEED_FPU_LOAD가 없으면save_fpregs_to_fpstate()로 FPU 상태를 즉시 저장합니다(전환 시점). 커널 스레드(PF_KTHREAD)는 FPU 상태를 가지지 않으므로 제외됩니다. 저장 후TIF_NEED_FPU_LOAD를 세트해 복원을 유저 복귀 시점으로 지연하며,last_cpu가 같은 CPU를 가리키면 복원(XRSTOR)을 생략할 수 있습니다. - exc_device_not_available() — #NM과거 Lazy 방식에서는
fpu__save()/fpu__restore()로 태스크 FPU 상태를 이곳에서 전환했습니다. 현대 커널에서 #NM은 커널 모드 FPU 보호 전용입니다. 커널 스레드가 인터럽트로 중단된 상태에서 FPU를 사용하면 상태가 오염될 수 있으므로, 커널 FPU 사용 중에는fpu_swap_kernel_fpstate()로 커널 FPU 컨텍스트를 잠시 교체합니다. - 유저 복귀 시 XRSTOR
fpregs_restore_userregs()가TIF_NEED_FPU_LOAD를 확인하고 XRSTOR 계열 명령으로fpstate에서 FPU 레지스터를 복원합니다.xstate_bv비트맵에 따라 사용되는 컴포넌트만 선택적으로 복원하여 불필요한 메모리 읽기를 생략합니다. 같은 CPU에 머물며 FPU 컨텍스트를 소유한 경우(last_cpu일치)에는 XRSTOR를 생략합니다. - XFEATURE_MASK_*XSAVE 컴포넌트별 비트 마스크로 XCR0 비트 번호와 1:1 대응합니다. x87 FPU(160B), SSE/XMM(256B), AVX/YMM(256B), AVX-512(OPMASK 64B + ZMM_Hi256 512B + Hi16_ZMM 1KB ≈ 1.6KB), PKRU(8B), AMX TILECFG(64B) + TILEDATA(8KB)입니다. XSAVE 영역은 헤더 64B를 포함해 AVX-512까지 약 2.4KB, AMX까지 약 10.5KB에 달해 전환 비용이 크게 증가합니다.
참고: Lazy 방식은 v4.6(commit 58122bf1d856 "x86/fpu: Default eagerfpu=on on all CPUs")부터 모든 CPU 에서 Eager 모드가 기본이 되었습니다. CR0.TS 타이밍으로 다른 태스크의 FPU 사용 여부가 노출되는 사이드 채널 문제와, #NM 예외에 의한 예측 불가능한 지연이 이유입니다. 이후 5.x FPU 재작업에서 전환 시점 저장(Eager) + 유저 복귀 시점 복원(TIF_NEED_FPU_LOAD) 구조로 단순화되었습니다.
| FPU 상태 | 크기 | 명령 | 저장 조건 |
|---|---|---|---|
| x87 FPU + XMM (SSE) | 512 B | FXSAVE/FXRSTOR | 구형 (XSAVE 미지원 시) |
| XSAVE (컴포넌트 선택) | 576B ~ 2.4KB (AVX-512), 10.4KB (AMX) | XSAVEOPT / XRSTOR | 실제 사용 컴포넌트만 |
| AVX-512 추가 | +1,600 B | ZMM/Opmask 레지스터 포함 | AVX-512 사용 프로세스만 |
성능 팁: AVX-512를 사용하는 프로세스와 사용하지 않는 프로세스가 같은 CPU에서 Context Switch되면, FPU 상태 크기 차이(576B vs 2.4KB)로 인해 불균등한 비용이 발생합니다. CPU 어피니티로 AVX-512 워크로드를 격리(Isolation)하면 일관된 성능을 얻을 수 있습니다.
TLB 관리와 PCID
PCID (Process-Context Identifier)
x86에서 CR3(페이지 테이블 포인터)를 변경하면 기본적으로 TLB 전체가 무효화됩니다. PCID는 각 주소 공간에 12비트 태그를 부여해 TLB 항목을 구분하므로, 주소 공간 전환 후에도 이전 TLB 항목을 재사용할 수 있습니다:
하드웨어 PCID 필드는 12비트이지만, 커널은 그중 일부만 mm 식별자로 씁니다. CPU 마다 동적 할당되는 ASID 는 TLB_NR_DYN_ASIDS(= 6)개이고, 나머지 구간은 여러 CPU 에서 동일한 값이 유지되는 전역 ASID 로 예약됩니다. CR3 에 기록되는 값은 ASID 가 아니라 asid + 1(kPCID)이며, KPTI 가 켜지면 유저 주소 공간용 uPCID 가 비트 11 로 구분됩니다:
/* arch/x86/mm/tlb.c — ASID / kPCID / uPCID 공간 구분 */
/*
* ASID - [0, TLB_NR_DYN_ASIDS-1] CPU 마다 동적 할당되는 mm 식별자
* [TLB_NR_DYN_ASIDS, MAX_ASID_AVAILABLE-1] 모든 CPU 에서 동일한 전역 식별자
* kPCID - [1, MAX_ASID_AVAILABLE] CR3 에 기록하는 값 (ASID + 1)
* uPCID - [2048 + 1, 2048 + MAX_ASID_AVAILABLE] KPTI 용 (kPCID + 2048)
*/
/* 이 CPU 에 이 mm 의 ASID 슬롯이 있으면 재사용, 없으면 새로 배정 */
static struct new_asid choose_new_asid(struct mm_struct *next, u64 next_tlb_gen)
{
struct new_asid ns;
for (asid = 0; asid < TLB_NR_DYN_ASIDS; asid++) {
if (this_cpu_read(cpu_tlbstate.ctxs[asid].ctx_id) !=
next->context.ctx_id)
continue;
ns.asid = asid;
ns.need_flush = (this_cpu_read(cpu_tlbstate.ctxs[asid].tlb_gen) < next_tlb_gen);
return ns;
}
/* 이 CPU 에 슬롯이 없다 — 라운드로빈으로 새 슬롯을 잡고 플러시가 필요하다고 표시 */
ns.asid = this_cpu_add_return(cpu_tlbstate.next_asid, 1) - 1;
if (ns.asid >= TLB_NR_DYN_ASIDS) {
ns.asid = 0;
this_cpu_write(cpu_tlbstate.next_asid, 1);
}
ns.need_flush = true;
return ns;
}
/* arch/x86/mm/tlb.c — CR3 값 생성과 기록 */
static inline unsigned long build_cr3(pgd_t *pgd, u16 asid, unsigned long lam)
{
unsigned long cr3 = __sme_pa(pgd) | lam;
if (cpu_feature_enabled(X86_FEATURE_PCID))
cr3 |= kern_pcid(asid); /* asid + 1 — PCID 0 은 예약 */
return cr3;
}
static inline unsigned long build_cr3_noflush(pgd_t *pgd, u16 asid,
unsigned long lam)
{
return build_cr3(pgd, asid, lam) | CR3_NOFLUSH; /* CR3[63] */
}
static void load_new_mm_cr3(pgd_t *pgdir, u16 new_asid, unsigned long lam,
bool need_flush)
{
unsigned long new_mm_cr3;
if (need_flush) {
invalidate_user_asid(new_asid);
new_mm_cr3 = build_cr3(pgdir, new_asid, lam);
} else {
new_mm_cr3 = build_cr3_noflush(pgdir, new_asid, lam);
}
write_cr3(new_mm_cr3);
}
/* arch/x86/mm/tlb.c — INVPCID 로 유저 PCID 의 한 주소만 무효화 */
STATIC_NOPV void native_flush_tlb_one_user(unsigned long addr)
{
u32 loaded_mm_asid;
bool cpu_pcide;
/* 커널 PCID 에서 addr 를 무효화한다 */
invlpg(addr);
/* PTI 가 꺼져 있으면 유저 PCID 가 없으므로 끝 */
if (!cpu_feature_enabled(X86_FEATURE_PTI))
return;
loaded_mm_asid = this_cpu_read(cpu_tlbstate.loaded_mm_asid);
cpu_pcide = this_cpu_read(cpu_tlbstate.cr4) & X86_CR4_PCIDE;
/* CR4.PCIDE 가 0 이면 invpcid_flush_one() 이 #GP 를 일으킨다 */
if (boot_cpu_has(X86_FEATURE_INVPCID) && cpu_pcide)
invpcid_flush_one(user_pcid(loaded_mm_asid), addr);
else
invalidate_user_asid(loaded_mm_asid);
}
코드 설명
arch/x86/mm/tlb.c의 PCID 기반 주소 공간 전환과 TLB 관리입니다. Context Switch 에서 switch_mm_irqs_off() 가 choose_new_asid() 로 ASID 를 고른 뒤 load_new_mm_cr3() 로 CR3 를 기록합니다.
- switch_mm_irqs_off()
context_switch()에서 호출되어 페이지 테이블 베이스(CR3)를 교체합니다. PCID 지원 CPU에서는CR3_PCID_NOFLUSH비트(bit 63)를 세트하여 TLB 플러시 없이 주소 공간을 전환합니다. - PCID 할당각
mm_struct에 이 CPU 의 동적 ASID 슬롯(0~TLB_NR_DYN_ASIDS-1)을 배정하고, CR3 에는asid + 1(kPCID)을 기록합니다. TLB 항목에 PCID 가 태그되므로 다른 프로세스의 항목과 공존할 수 있어 전환 후 TLB miss 가 줄어듭니다. - KPTI 연동KPTI 활성화 시 유저 PGD 에는 별도 PCID(uPCID)를 부여합니다(
user_pcid()= kPCID |1 << X86_CR3_PTI_PCID_USER_BIT, 비트 11). 시스템 콜 진입·복귀의 유저/커널 PGD 전환도 NOFLUSH 로 처리되어 TLB 가 유지됩니다. - native_flush_tlb_one_user()유저 주소 한 개의 TLB 항목을 무효화합니다. 커널 PCID 는
invlpg로, KPTI 가 켜진 경우 유저 PCID 는INVPCID(invpcid_flush_one())로 지웁니다.CR4.PCIDE가 0 이면 INVPCID 가 #GP 를 일으키므로cpu_tlbstate.cr4를 먼저 검사합니다.
호출 체인: context_switch() → switch_mm_irqs_off(prev_mm, next_mm) → write_cr3(new_pgd | asid | NOFLUSH)
PCID 성능 향상
| 시나리오 | PCID 미사용 | PCID 사용 | 개선 |
|---|---|---|---|
| Context Switch 지연 (직접) | 측정 기준 | 크게 감소 | 크게 감소(측정 환경에 따라 다름) |
| TLB Miss (전환 직후) | 높음 | 크게 낮음 | 대폭 감소 |
| 처리량(Throughput) (I/O 집약적) | baseline | 향상 | 캐시 재사용 효과 |
| 메모리 (TLB 엔트리 수) | 하나의 컨텍스트 | CPU 당 동적 ASID 6개 + 전역 ASID | 다중 프로세스 TLB 공존 |
mm_cpumask 지연 갱신 최적화 (커널 6.14)
커널 6.14부터: x86 TLB 플러시 확장성(Scalability) 최적화로 mm_cpumask를 지연(lazy) 갱신하는 방식이 도입되었습니다 (Rik van Riel, Meta).
멀티스레드 워크로드에서 여러 CPU가 동일한 mm_struct를 공유하는 프로세스를 실행·종료할 때, 컨텍스트 스위치 시점마다 mm_cpumask에 대한 잠금(Lock) 경합(Contention)이 심각해지는 문제가 있었습니다. Meta의 웹서버에서 CPU 시간의 상당 부분이 switch_mm_irqs_off() 내부에서 소모되는 현상이 관측된 것이 이 최적화의 발단입니다.
기존 방식에서는 컨텍스트 스위치 때마다 mm_cpumask의 CPU 비트를 무조건 설정·해제했습니다. 새로운 지연 갱신 방식은:
- 컨텍스트 스위치 시점에 해당 CPU 비트가 아직 설정되지 않은 경우에만 세트
- CPU 비트 해제는 "해당 CPU에서 더 이상 실행되지 않는 프로세스에게 첫 번째 TLB 플러시가 전송될 때" 지연 처리
- 연속적인 TLB 플러시 폭주가 발생할 때, 현재 그 프로세스를 실행하지 않는 CPU에는 첫 번째 플러시만 전송되고 이후 플러시는 실제 실행 중인 CPU에만 전송
성능 측정: 커널 스케줄러 벤치마크인 Hackbench 기준으로 실행 시간이 다소 단축되는 효과가 보고됩니다(측정 환경에 따라 다름). 컨텍스트 스위치가 빈번한 멀티스레드 워크로드(웹 서버, 데이터베이스 등)에서 효과가 두드러집니다.
/* arch/x86/mm/tlb.c (6.14+) — mm_cpumask 지연 갱신 핵심 로직 */
void switch_mm_irqs_off(struct mm_struct *prev,
struct mm_struct *next,
struct task_struct *tsk)
{
int cpu = smp_processor_id();
/* 이미 비트가 세트되어 있으면 경합 없이 그냥 통과 */
if (!cpumask_test_cpu(cpu, mm_cpumask(next))) {
cpumask_set_cpu(cpu, mm_cpumask(next));
/* 비트 해제는 TLB 플러시 시점까지 지연 */
}
/* PCID/NOFLUSH: 기존과 동일한 CR3 쓰기 */
load_new_mm_cr3(next->pgd, new_asid, noflush);
}
효과 범위: 이 최적화는 특히 다수의 스레드가 자주 생성·소멸되는 워크로드(웹 서버, 마이크로서비스, 대규모 멀티스레드 애플리케이션(Application))에서 효과적입니다. CPU bound 단일 스레드 워크로드에서는 효과가 미미합니다. 개선된 switch_mm_irqs_off() 구현은 Intel과 AMD x86_64 모두에 적용됩니다.
Context Switch 비용 분석
직접 비용 — 전환이 수행하는 작업
직접 비용은 커널이 전환 과정에서 실제로 수행하는 작업의 비용입니다. 항목 수가 고정되어 있어 전환 1회당 비용을 예측하기 쉽고, 전환 횟수에 선형으로 비례합니다. 정확한 소요 시간은 CPU 세대·캐시 상태·XCR0 구성에 따라 크게 달라지므로, 여기서는 항목과 비용을 키우는 조건만 정리합니다.
| 항목 | 수행 작업 | 비용이 커지는 조건 |
|---|---|---|
| callee-saved 레지스터 | rbx·rbp·r12~r15 push/pop |
없음 (항목 수 고정) |
thread.sp 교환 |
inactive_task_frame의 sp·복귀 주소 교체 |
없음 (항목 수 고정) |
per-CPU top_of_stack |
현재 CPU 의 커널 스택 최상단 갱신 | 없음 (항목 수 고정) |
| TLS MSR | fs_base·gs_base WRMSR 2회 |
WRMSR 는 일반 레지스터 연산보다 느리다 |
| FPU/XSTATE | 이전 태스크 저장 + 다음 태스크 복원 | XCR0 에 포함된 컴포넌트 수 (AVX-512 는 zmm 32개) |
| CR3 쓰기 | PCID 태그를 바꿔 주소 공간 전환 | PCID 미지원이거나 ASID 슬롯이 고갈되면 전체 TLB 무효화를 동반 |
간접 비용 — 전환 이후에 나타나는 비용
간접 비용은 전환 자체가 아니라 전환 이후 실행 구간에서 발생합니다. 워크로드·캐시 크기·CPU 구조에 따라 달라져 직접 비용보다 예측이 어렵고, 대체로 규모가 더 큽니다.
- L1/L2 캐시 오염 — 새 프로세스의 데이터가 캐시를 채워 이전 프로세스의 데이터를 축출(Eviction)합니다. 캐시가 작을수록 교체되는 영역의 비중이 커집니다.
- LLC (L3) 캐시 미스 — 공유되지만 접근 패턴이 완전히 바뀌면 재사용률이 떨어집니다.
- TLB Miss 폭풍 — PCID 가 없으면 전환마다 TLB 가 비워져, 이후 모든 메모리 접근이 페이지 테이블 워크(Page Table Walk)를 동반합니다.
- 분기 예측(Branch Prediction)기 오염 — BTB(Branch Target Buffer) 항목이 다른 코드 주소로 채워져 전환 직후 예측 실패가 늘어납니다.
- 인터럽트 비활성화 구간 —
context_switch()동안 인터럽트가 꺼져 있어 그 구간만큼 지연이 누적됩니다. - 간접 비용의 총합 — 워크로드·캐시 크기·CPU 구조에 따라 직접 비용을 크게 웃돌 수 있습니다.
간접 비용 함정: perf stat -e context-switches는 직접 비용만 측정합니다. 실제 성능 영향은 캐시 미스(cache-misses), TLB 미스(dTLB-load-misses)를 함께 측정해야 전체 그림을 볼 수 있습니다.
KPTI (Kernel Page-Table Isolation)와 Context Switch
Meltdown 취약점(Vulnerability, CVE-2017-5754) 완화를 위해 도입된 KPTI는 커널 주소 공간과 유저 주소 공간을 분리된 페이지 테이블로 관리합니다. 이로 인해 커널↔유저 전환마다 CR3를 두 번 교체하는 추가 비용이 발생합니다.
KPTI CR3 이중 전환
/*
* 유저 공간 → 커널 공간 (시스템 콜/인터럽트):
* CR3 = kernel_pgd | kPCID (커널 페이지 테이블로 전환)
*
* 커널 공간 → 유저 공간 (sysret/iret):
* CR3 = user_pgd | uPCID (유저 페이지 테이블로 전환)
*
* Context Switch 시 (process A → process B):
* CR3 = B.pgd | ASID_B ← switch_mm() 에서 1회
* CR3 = B.user_pgd | uPCID_B ← sysret 직전 1회
*
* KPTI 비활성화 시: CR3 교체 1회
* KPTI 활성화 시: CR3 교체 2회 (진입과 복귀에 각 1회씩 추가)
*/
/* arch/x86/entry/calling.h — sysret 직전 (발췌) */
.macro SWITCH_TO_USER_CR3 scratch_reg:req scratch_reg2:req
mov %cr3, \scratch_reg
ALTERNATIVE "jmp .Lwrcr3_\@", "", X86_FEATURE_PCID
/* 이 ASID 의 유저 TLB 를 지워야 하는지 검사한다 */
movq \scratch_reg, \scratch_reg2
andq $(0x7FF), \scratch_reg /* mask ASID — KPTI 가 비트 11 을 쓰므로 11비트 */
bt \scratch_reg, THIS_CPU_user_pcid_flush_mask
jnc .Lnoflush_\@
/* 플러시가 필요하면 per-CPU 마스크에서 비트를 지운다 */
btr \scratch_reg, THIS_CPU_user_pcid_flush_mask
movq \scratch_reg2, \scratch_reg
jmp .Lwrcr3_pcid_\@
.Lnoflush_\@:
movq \scratch_reg2, \scratch_reg
SET_NOFLUSH_BIT \scratch_reg
.Lwrcr3_pcid_\@:
orq $(PTI_USER_PCID_MASK), \scratch_reg /* kPCID → uPCID (비트 11) */
.Lwrcr3_\@:
orq $(PTI_USER_PGTABLE_MASK), \scratch_reg /* 커널 PGD → 유저 PGD (비트 12) */
mov \scratch_reg, %cr3
.endm
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 이전) | 예 | 예 (기본) | 높음(CPU·워크로드 의존) |
| Intel Core (Coffee Lake 이후) | 일부 | CPU 의존 | 낮음~중간(CPU·워크로드 의존) |
| AMD Zen 2 이상 | 아니오 | 비활성화 | 거의 없음 |
| ARM Cortex-A (ARMv8.5+) | 일부 모델 | KPTI (UNMAP_KERNEL_AT_EL0) | 낮음(CPU·워크로드 의존) |
INVPCID + PCID 조합: KPTI 활성화 시에도 PCID를 사용하면 유저/커널 페이지 테이블 각각 별도 PCID를 부여해 TLB 플러시를 최소화합니다. Intel Haswell 이상에서 INVPCID 명령으로 선택적 무효화가 가능합니다.
Spectre 완화와 Context Switch 비용
Meltdown(KPTI) 외에도 Spectre V2(Branch Target Injection, CVE-2017-5715)와 관련 변종 완화가 Context Switch에 직접적인 비용을 추가합니다. 특히 IBPB(Indirect Branch Prediction Barrier), STIBP(Single Thread Indirect Branch Predictors), IBRS(Indirect Branch Restricted Speculation)는 프로세스 간 전환 시 분기 예측기(Branch Predictor)를 격리하여 추측 실행(Speculative Execution) 공격을 방지합니다.
/* arch/x86/mm/tlb.c — Context Switch 시 IBPB 발행 */
static inline void cond_ibpb(struct task_struct *next)
{
/*
* IBPB가 필요한 경우:
* 1. spectre_v2_user_ibpb == SPECTRE_V2_USER_STRICT
* → 모든 프로세스 전환 시 IBPB (가장 안전, 가장 느림)
* 2. spectre_v2_user_ibpb == SPECTRE_V2_USER_PRCTL
* → PR_SPEC_INDIRECT_BRANCH 활성화된 태스크만
* 3. spectre_v2_user_ibpb == SPECTRE_V2_USER_SECCOMP
* → SECCOMP 적용 + non-dumpable 프로세스
*/
if (!static_branch_likely(&switch_mm_cond_ibpb))
return;
/* 같은 프로세스 내 스레드 간 전환이면 IBPB 불필요 */
if (next == current || (next->flags & PF_KTHREAD))
return;
/* TIF_SPEC_IB 플래그 설정 확인 */
if (test_tsk_thread_flag(next, TIF_SPEC_IB) ||
test_tsk_thread_flag(current, TIF_SPEC_IB)) {
/* IBPB 발행: 간접 분기 예측 이력 전체 클리어 */
indirect_branch_prediction_barrier();
}
}
/* 실제 IBPB 실행 (MSR 쓰기) */
static inline void indirect_branch_prediction_barrier(void)
{
/* IA32_PRED_CMD MSR에 IBPB 비트 쓰기 */
wrmsrl(MSR_IA32_PRED_CMD, PRED_CMD_IBPB);
}
/*
* Spectre V2 완화 전략 (커널 파라미터):
* spectre_v2=auto — CPU에 맞는 최적 전략 자동 선택
* spectre_v2=retpoline — 간접 분기를 retpoline으로 대체
* spectre_v2=ibrs — IBRS 모드 (MSR 기반)
* spectre_v2=eibrs — Enhanced IBRS (하드웨어 격리)
* spectre_v2=eibrs,retpoline — eIBRS + retpoline 병용
*
* IBPB 정책 (커널 파라미터):
* spectre_v2_user=auto — SECCOMP/PRCTL 기반 선택적 IBPB
* spectre_v2_user=on — 모든 전환 시 IBPB (strict)
* spectre_v2_user=off — IBPB 비활성화 (보안 위험)
*/
코드 설명
arch/x86/mm/tlb.c의 Spectre V2 완화를 위한 IBPB(Indirect Branch Prediction Barrier) 구현입니다. Context Switch 시 분기 예측 이력을 클리어하여 프로세스 간 간접 분기 예측 공격을 방지합니다.
- cond_ibpb()
switch_mm_irqs_off()에서 주소 공간 전환 직후 호출됩니다.static_branch_likely()로 IBPB 정책이 활성화되었는지 먼저 확인하며, 비활성화 시 분기 예측으로 즉시 반환됩니다(오버헤드가 사실상 없습니다). - 같은 프로세스 필터링같은
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)를 전체 클리어하며, 적지 않은 비용이 발생합니다. - Spectre V2 완화 전략커널 파라미터로 선택합니다.
retpoline은 간접 분기를 ret 기반 루프로 대체하고,eibrs는 하드웨어가 권한 레벨별 예측을 자동 격리합니다. eIBRS/AutoIBRS 지원 CPU에서는 IBPB 없이도 안전하여 추가 비용이 사실상 없습니다.
호출 체인: 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) | 상당한 추가 비용 (WRMSR, 하드웨어·설정 의존) | Intel Skylake+, AMD Zen+ |
| STIBP | 상시 (SMT 활성 시) | 상시 IPC 감소(정도는 CPU·워크로드 의존) | Intel Skylake+, AMD Zen+ |
| Retpoline | 간접 호출마다 | 간접 호출당 소폭 오버헤드(하드웨어 의존) | 소프트웨어 (모든 CPU) |
| eIBRS/AutoIBRS | Ring 전환 시 (하드웨어) | 미미함 (마이크로코드 처리) | Intel Ice Lake+, AMD Zen 3+ |
| RSB 채우기 | Context Switch 시 | RSB 채우기 비용(하드웨어 의존) | Spectre V2 RSB 변종 |
보안 vs 성능 트레이드오프: mitigations=off 커널 파라미터는 모든 하드웨어 취약점 완화를 비활성화하여 최대 성능을 얻지만, 심각한 보안 위험이 따릅니다. 프로덕션(Production) 환경에서는 절대 사용하지 마세요. 대신 최신 CPU(eIBRS 지원)로 업그레이드하면 완화 비용 없이 보안을 유지할 수 있습니다. spectre_v2_user=prctl로 선택적 IBPB를 적용하면 신뢰할 수 있는 워크로드에서 불필요한 비용을 줄일 수 있습니다.
Intel FRED — Flexible Return and Event Delivery
FRED(Flexible Return and Event Delivery)는 Intel이 설계한 x86-64 이벤트(인터럽트/예외/시스템 콜) 전달 아키텍처로, 기존의 IDT(Interrupt Descriptor Table)와 IRET/SYSRET를 대체합니다. Linux 6.9에 병합되었으며 FRED 지원 CPU(일부 Xeon, Panther Lake 이후 클라이언트 CPU)에서 자동 활성화됩니다. 컨텍스트 스위칭의 앞단—유저/커널 권한 전환(ring transition)—에 해당하는 경로를 단순화하여, 기존 IDT 경로가 감수해 온 "반쯤 구워진 진입 상태(half-baked entry state)" 문제와 SYSRET의 악명 높은 CPL3 복귀 코너 케이스를 근본적으로 제거합니다.
왜 FRED인가
기존 IDT 기반 이벤트 전달의 문제점은 수십 년간 축적된 어셈블리 셀프-서지(self-surgery) 코드로 드러납니다.
- 일관성 없는 상태 저장:
#PF(페이지 폴트)는CR2를,#DB(디버그 예외)는DR6를 별도 저장해야 하며, 이들은 이벤트 진입 후 다른 예외가 발생하기 전에 읽어야 합니다. FRED는 이벤트 스택 프레임(Stack Frame)에 필요한 정보를 원자적(atomic)으로 모두 저장합니다. - NMI 중첩 처리: NMI가 NMI를 재중첩하거나 NMI 중
#PF가 발생하는 경로가 IDT에서는 복잡한 소프트웨어 가드(예:nested_nmi상태 머신)를 요구했습니다. FRED는 하드웨어 레벨에서 중첩을 억제합니다. - SYSRET 취약점:
SYSRET의 RIP 비정규 주소(non-canonical) 반환 시 GPF(#GP)가 CPL0 상태에서 발생하는 CVE-2012-0217 계열 이슈. FRED의ERETU는 이 코너 케이스를 제거합니다.
ERETU / ERETS 명령
FRED는 이벤트 복귀 경로를 두 개의 전용 명령으로 분리합니다. 이 명령들이 IRET/SYSRET을 대체하여 커널 진입/복귀 지연(latency)을 줄이는 핵심입니다.
| 명령 | 대체 대상 | 동작 |
|---|---|---|
ERETS |
supervisor → supervisor 복귀 (IRET CPL0) | FRED 스택 프레임에서 RIP/RFLAGS/SS/RSP를 원자적으로 복원, 특권 레벨 변경 없음. |
ERETU |
supervisor → user 복귀 (IRET CPL3 / SYSRET) | CPL3 복귀 시 세그먼트 레지스터/시프트 영역을 포함해 모두 복원, SYSRET 비정규 주소 취약점 경로 제거. |
결과적으로 FRED 진입/복귀 지연은 최신 CPU에서 IDT 경로 대비 짧은 시간으로 축소되며, 컨텍스트 스위치 한 번에 다수의 이벤트 복귀(스케줄 틱 + 시스템 콜 + 페이지 폴트)가 섞이는 현실 워크로드에서 누적 효과가 큽니다.
컨텍스트 스위칭에 미치는 영향
FRED는 __schedule()나 __switch_to() 내부 구조를 직접 바꾸지는 않지만, 컨텍스트 스위치를 감싸는 이벤트 전달 경로를 단순화하여 실질 비용을 낮춥니다.
- 시스템 콜 진입/복귀 단축:
schedule()→context_switch()→ 유저 복귀의 마지막 단계가ERETU로 바뀌어 구식SYSRET경로의 플래그/스택 정리 로직을 생략합니다. - 인터럽트 핸들러 간략화: IDT 기반
idtentry매크로가 FRED 대응 경로로 대체되어 진입 stub(어셈블리 glue)이 짧아집니다. 이는 캐시/I-TLB 풋프린트를 줄여 컨텍스트 스위치 직후 I-캐시 미스를 완화합니다. - KPTI와의 상호작용: FRED는 KPTI 페이지 테이블 전환 로직과 호환되며, 커널 복귀 경로에서
#PF가 발생할 때 IDT 경로가 필요로 했던 보조 플래그 저장이 사라집니다. - NMI 경로 간소화: perf/RAS/Watchdog이 생성하는 NMI가 컨텍스트 스위치와 겹칠 때의 nested-NMI 가드 코드가 제거되어, NMI-heavy 워크로드(예: PMU 기반 샘플링)에서 측정 가능한 오버헤드 감소가 보고됩니다.
# FRED 활성화 여부 확인 (Linux 6.13+) */
grep fred /proc/cpuinfo | head -1
# 예시 출력: ... fred ... (플래그가 보이면 하드웨어가 FRED 지원) */
dmesg | grep -i "FRED"
# [ 0.123456] x86/fred: FRED is enabled */
# 강제 비활성화 (디버깅용) */
# 커널 파라미터: fred=off */
참고: FRED는 CPU 기능이므로 FRED 지원 하드웨어 없이는 활성화되지 않습니다. 현재 지원 현황은 Panther Lake 이후 클라이언트 CPU와 일부 Xeon 라인업에서 시작되었고, 향후 전 CPU군으로 확대될 예정입니다. 기존 CPU에서는 코드가 빌드되어 있어도 런타임에 IDT 경로가 선택됩니다.
ITS (Indirect Target Selection)와 FineIBT
Spectre V2 완화를 거친 현대 커널에도 Indirect Target Selection(ITS)과 같이 새롭게 공개된 추측 실행(Speculative Execution) 취약점 완화가 컨텍스트 스위치 비용에 꾸준히 추가되고 있습니다. 이 섹션은 2023~2025년에 추가된 두 가지—ITS 완화와 FineIBT 기본 활성화—가 컨텍스트 스위치 경로에 미치는 영향을 정리합니다.
Indirect Target Selection 개요
ITS는 특정 Intel CPU의 간접 분기 예측기가 RET 명령의 예측 결과를 동일한 64B 경계 영역의 다른 간접 분기 타겟으로 선택(select)할 수 있는 결함입니다. 공격자가 제어하는 코드 패턴을 통해 추측 실행이 의도하지 않은 함수 포인터로 분기하도록 유도할 수 있습니다. 커널 완화는 두 축에서 이루어집니다.
- 리턴 썸크(return thunk) 재배치(Relocation):
__x86_return_thunk를 실행 가능하지만 ITS에 안전한 캐시 라인(Cache Line) 정렬로 재배치합니다.objtool이 빌드 시 모든 리턴 경로를 감사합니다. - 간접 호출 썸크 조정:
retpoline/IBRS경로에서 사용되는 간접 호출 스텁을 ITS-safe 레이아웃으로 재정렬합니다.
이 완화는 컨텍스트 스위치 코드 자체는 바꾸지 않지만, 스위치 직전/직후의 간접 호출과 리턴 경로가 ITS-safe 썸크를 경유하게 되어 I-캐시 풋프린트가 증가합니다. 실측에서는 전환 한 번당 수 사이클(cycle) 수준의 간접 비용이 추가되는 것으로 보고됩니다.
# ITS 완화 상태 확인 */
cat /sys/devices/system/cpu/vulnerabilities/indirect_target_selection
# 예: "Mitigation: Aligned branch/return thunks" */
# 완화 선택 파라미터 (커널 부트) */
# indirect_target_selection=on (기본값)
# indirect_target_selection=off (비권장)
# indirect_target_selection=vmexit (호스트만 완화)
FineIBT — 커널 IBT 기본 활성화
Intel CET-IBT(Control-flow Enforcement Technology — Indirect Branch Tracking)는 모든 간접 분기의 착지점(target)에 ENDBR 명령을 요구해 JOP/COP 공격을 차단합니다. Linux 6.2부터 FineIBT—함수 서명(signature) 검증을 결합해 callsite/target 쌍을 더 엄밀히 검증하는 변형—가 커널 IBT의 기본 구현으로 도입되었고, 이후 릴리스에서 기본 활성화로 전환되었습니다.
컨텍스트 스위치 경로에서의 관측 가능한 영향은 다음과 같습니다.
__switch_to()와schedule()을 포함한 모든 간접 분기 착지점에ENDBR64(4바이트) 명령이 추가되어 코드 크기가 소폭 증가합니다.- FineIBT의 함수 서명 검증(Signature Verification)은 callsite 앞에 3~8바이트 prologue를 추가하여 간접 호출당 약간의 비용이 추가됩니다.
- CET-IBT 비지원 CPU에서는
ENDBR이 NOP로 디코딩되므로 성능 영향은 없지만, 명령 캐시(I-Cache) 풋프린트는 동일합니다.
# IBT 활성화 여부 확인 */
grep -E "ibt|cet" /proc/cpuinfo | head -1
cat /sys/devices/system/cpu/ibt
# "on" — 커널 IBT 활성화 */
# IBT 강제 비활성화 (디버깅용) */
# 커널 파라미터: ibt=off */
전체 추측 실행 완화 누적 비용 요약
2025년 기준, 현대 x86-64 커널의 컨텍스트 스위치 1회에 포함되는 추측 실행/CFI 관련 누적 비용은 대략 다음과 같습니다(CPU 세대·설정에 따라 편차 큼).
| 완화 항목 | Context Switch당 비용 | 주요 영향 경로 |
|---|---|---|
| KPTI (CR3 전환) | 상당한 추가 비용(하드웨어·설정 의존) | 유저 복귀 경로 |
| IBPB (프로세스 경계) | 큰 추가 비용(하드웨어·설정 의존) | switch_mm_irqs_off() |
| STIBP / eIBRS | 거의 0 (eIBRS) | MSR 작성 비용 분산 |
| RSB stuffing | 약간의 추가 비용(하드웨어 의존) | vm exit/entry |
| ITS 썸크 재정렬 | 측정 어려움 (수 cycle) | I-캐시 압박 |
| FineIBT prologue | 간접 호출당 소폭 추가(하드웨어 의존) | 커널 내 모든 간접 분기 |
튜닝 팁: 격리된 네트워크 팟(Pod)·하이퍼바이저(Hypervisor) 없는 베어메탈 환경 등 이미 신뢰 경계가 명확한 워크로드에서는 spectre_v2_user=prctl, indirect_target_selection=vmexit과 같이 선택적 완화 모드를 사용해 컨텍스트 스위치 비용을 줄일 수 있습니다. 다만 일반 다중 테넌트(multi-tenant) 환경에서는 기본 mitigations=auto를 유지해야 합니다.
ARM64 Context Switch 구현
ARM64(AArch64)의 Context Switch는 x86-64와 유사하지만, TTBR0_EL1(유저 페이지 테이블)과 TTBR1_EL1(커널 페이지 테이블)이 분리된 레지스터를 사용합니다. 커널은 TCR_EL1.A1을 1로 두므로 유저 공간 ASID는 TTBR1_EL1[63:48]에 기록되고, TTBR0_EL1에는 CONFIG_ARM64_SW_TTBR0_PAN용 ASID 사본만 들어갑니다. 페이지 테이블 교체는 switch_mm() → cpu_switch_mm() → cpu_do_switch_mm()(arch/arm64/mm/context.c) 순서로 이어집니다.
cpu_switch_to 어셈블리
/* arch/arm64/kernel/entry.S */
SYM_FUNC_START(cpu_switch_to)
save_and_disable_daif x11 /* DAIF 마스킹: 전환 중 인터럽트·비동기 예외 차단 */
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 /* 스택 포인터 전환 */
msr sp_el0, x1 /* 태스크별 user 스택 포인터 */
ptrauth_keys_install_kernel x1, x8, x9, x10 /* CONFIG_ARM64_PTR_AUTH */
scs_save x0 /* Shadow Call Stack 저장 (CONFIG_SHADOW_CALL_STACK) */
scs_load_current /* next의 SCS 포인터 로드 */
restore_irq x11 /* DAIF 복원 (진입 시 마스킹 해제) */
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)
__switch_mm(next); /* init_mm이면 reserved TTBR0, 아니면 check_and_switch_context() */
/* CONFIG_ARM64_SW_TTBR0_PAN: 스케줄아웃 시점의 TTBR0_EL1 저장값 갱신 */
update_saved_ttbr0(tsk, next);
}
static inline void cpu_switch_mm(pgd_t *pgd, struct mm_struct *mm)
{
BUG_ON(pgd == swapper_pg_dir);
cpu_do_switch_mm(virt_to_phys(pgd), mm); /* arch/arm64/mm/context.c */
}
/* arch/arm64/mm/context.c — 실제 TTBRx_EL1 레지스터 갱신 */
void cpu_do_switch_mm(phys_addr_t pgd_phys, struct mm_struct *mm)
{
unsigned long ttbr1 = read_sysreg(ttbr1_el1);
unsigned long asid = ASID(mm);
unsigned long ttbr0 = phys_to_ttbr(pgd_phys);
/* SW PAN은 엔트리용 ASID 사본을 TTBR0에 둔다 */
if (IS_ENABLED(CONFIG_ARM64_SW_TTBR0_PAN))
ttbr0 |= FIELD_PREP(TTBRx_EL1_ASID_MASK, asid);
/* TCR.A1=1 → 유저 공간 ASID는 TTBR1_EL1[63:48]에 기록 */
ttbr1 &= ~TTBRx_EL1_ASID_MASK;
ttbr1 |= FIELD_PREP(TTBRx_EL1_ASID_MASK, asid);
cpu_set_reserved_ttbr0_nosync();
write_sysreg(ttbr1, ttbr1_el1);
write_sysreg(ttbr0, ttbr0_el1);
isb();
post_ttbr_update_workaround(); /* Cavium erratum 27456 */
}
/*
* ARM64 ASID (Address Space ID):
* - CPUID ID_AA64MMFR0_EL1.ASIDBits 로 결정: 8비트(256개) 또는 16비트(65536개)
* (알 수 없는 값이면 8비트로 가정 — get_cpu_asid_bits())
* - x86 PCID와 같은 역할: TTBRx_EL1[63:48]에 실려 TLB 태그로 동작
* - ASID 소진 시 generation 증가 + flush_context()로 전역 TLB 무효화 후 재할당
*/
ARM64 vs x86-64 비교
| 항목 | x86-64 | ARM64 |
|---|---|---|
| 레지스터 전환 함수 | __switch_to_asm (asm) + __switch_to (C) | cpu_switch_to (asm) + __switch_to (C) |
| callee-saved 수 | 6개 (RBX, RBP, R12~R15) + RSP(thread.sp) | 12개 (x19~x28, fp, sp) + pc(lr) — cpu_context 13필드 |
| 페이지 테이블 레지스터 | CR3 (하나, PCID 포함) | TTBR0_EL1 + TTBR1_EL1 (분리) |
| TLB 태그 | PCID (12비트) | ASID (8비트 또는 16비트) |
| FPU 저장 | XSAVE/XRSTOR (Eager — 전환 시 즉시 저장) | FPSIMD (stp/ldp q0~q31, FPSR/FPCR) — 마지막 상태 비교로 필요할 때만 저장 |
| Meltdown 완화 | KPTI (CR3 이중 교체) | KPTI (CONFIG_UNMAP_KERNEL_AT_EL0 — 영향 코어에만 적용) |
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 */
/* task_struct.thread 오프셋을 더해 thread_struct 기준 주소 구성 */
li a4, TASK_THREAD_RA /* task_struct.thread 오프셋 */
add a3, a0, a4 /* a3 = &prev->thread */
add a4, a1, a4 /* a4 = &next->thread */
/* prev의 callee-saved 레지스터 저장 (s0~s11, ra, sp) — REG_S는 RV64에서 sd */
REG_S ra, TASK_THREAD_RA_RA(a3) /* 반환 주소 */
REG_S sp, TASK_THREAD_SP_RA(a3) /* 스택 포인터 */
REG_S s0, TASK_THREAD_S0_RA(a3) /* s0(fp) ~ s11: callee-saved */
REG_S s1, TASK_THREAD_S1_RA(a3)
REG_S s2, TASK_THREAD_S2_RA(a3)
REG_S s3, TASK_THREAD_S3_RA(a3)
REG_S s4, TASK_THREAD_S4_RA(a3)
REG_S s5, TASK_THREAD_S5_RA(a3)
REG_S s6, TASK_THREAD_S6_RA(a3)
REG_S s7, TASK_THREAD_S7_RA(a3)
REG_S s8, TASK_THREAD_S8_RA(a3)
REG_S s9, TASK_THREAD_S9_RA(a3)
REG_S s10, TASK_THREAD_S10_RA(a3)
REG_S s11, TASK_THREAD_S11_RA(a3)
/* 유저 공간 접근 허용 플래그(SUM)도 함께 저장 */
csrr s0, CSR_STATUS
REG_S s0, TASK_THREAD_SUM_RA(a3)
scs_save_current /* Shadow Call Stack 포인터 저장 */
/* next의 레지스터 복원 (SUM 플래그부터) */
REG_L s0, TASK_THREAD_SUM_RA(a4)
li s1, SR_SUM
and s0, s0, s1
csrs CSR_STATUS, s0
REG_L ra, TASK_THREAD_RA_RA(a4)
REG_L sp, TASK_THREAD_SP_RA(a4)
REG_L s0, TASK_THREAD_S0_RA(a4)
REG_L s1, TASK_THREAD_S1_RA(a4)
REG_L s2, TASK_THREAD_S2_RA(a4)
REG_L s3, TASK_THREAD_S3_RA(a4)
REG_L s4, TASK_THREAD_S4_RA(a4)
REG_L s5, TASK_THREAD_S5_RA(a4)
REG_L s6, TASK_THREAD_S6_RA(a4)
REG_L s7, TASK_THREAD_S7_RA(a4)
REG_L s8, TASK_THREAD_S8_RA(a4)
REG_L s9, TASK_THREAD_S9_RA(a4)
REG_L s10, TASK_THREAD_S10_RA(a4)
REG_L s11, TASK_THREAD_S11_RA(a4)
/* tp(thread pointer) = next task_struct 주소 (thread_info 오프셋 0) */
move tp, a1
scs_load_current /* next의 Shadow Call Stack 로드 */
ret /* ra로 점프 → next 프로세스 재개 */
SYM_FUNC_END(__switch_to)
RISC-V 주소 공간 전환
/* arch/riscv/mm/context.c — switch_mm()은 SATP를 직접 쓰지 않고 set_mm()에 위임 */
void switch_mm(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *task)
{
unsigned int cpu;
if (unlikely(prev == next))
return;
membarrier_arch_switch_mm(prev, next, task);
cpu = smp_processor_id();
set_mm(prev, next, cpu); /* mm_cpumask 갱신 + SATP 전환 */
flush_icache_deferred(next, cpu, task); /* 다른 hart로 이주했을 때만 icache 무효화 */
}
/* set_mm(): ASID 할당기 활성 여부로 경로 분기 */
static inline void set_mm(struct mm_struct *prev,
struct mm_struct *next, unsigned int cpu)
{
cpumask_set_cpu(cpu, mm_cpumask(next));
if (static_branch_unlikely(&use_asid_allocator))
set_mm_asid(next, cpu);
else {
cpumask_clear_cpu(cpu, mm_cpumask(prev));
set_mm_noasid(next);
}
}
/* ASID 경로 — mm->context.id에서 버전+ASID를 얻어 SATP에 기록 */
cntx = atomic_long_read(&mm->context.id);
/* ... 버전이 다르면 __new_context()로 버전+ASID 재발급 ... */
csr_write(CSR_SATP, virt_to_pfn(mm->pgd) |
(cntx2asid(cntx) << SATP_ASID_SHIFT) |
satp_mode); /* cntx2asid(c) = c & SATP_ASID_MASK */
if (need_flush_tlb)
local_flush_tlb_all();
/* ASID 미사용 경로 — 페이지 테이블만 교체하고 로컬 TLB를 전부 무효화 */
csr_write(CSR_SATP, virt_to_pfn(mm->pgd) | satp_mode);
local_flush_tlb_all_asid(0); /* ASID 0은 "전체"로 동작 */
/*
* SATP (Supervisor Address Translation and Protection) — RV64:
* [63:60] MODE — Sv39(8), Sv48(9), Sv57(10) (커널은 satp_mode 변수로 관리)
* [59:44] ASID — 16비트 (asm/csr.h: SATP_ASID_BITS 16, SATP_ASID_SHIFT 44)
* RV32는 9비트 · shift 22
* [43:0] PPN — 페이지 테이블 루트의 물리 프레임 번호
* ASID 버전은 별도 카운터를 BIT(SATP_ASID_BITS)씩 증가시키며 순환하고,
* 소진 시 __new_context() → __flush_context()가 전역 TLB를 무효화합니다.
*/
RISC-V vs x86-64 vs ARM64 비교
| 항목 | x86-64 | ARM64 | RISC-V (RV64) |
|---|---|---|---|
| 레지스터 전환 함수 | __switch_to_asm (asm) + __switch_to (C) | cpu_switch_to (asm) + __switch_to (C) | __switch_to (asm) + switch_to() 매크로 (FPU·Vector·ENVCFG) |
| callee-saved 수 | 6개 (RBX, RBP, R12~R15) + RSP(thread.sp) | 12개 (x19~x28, fp, sp) + pc(lr) — cpu_context 13필드 | 14개 (s0~s11,ra,sp) |
| 저장/복원 명령 | push/pop (스택 기반) | stp/ldp (메모리 직접) | REG_S/REG_L (RV64에서 sd/ld) |
| 페이지 테이블 레지스터 | CR3 (하나, PCID 포함) | TTBR0/TTBR1 (분리) | SATP (MODE+ASID+PPN) |
| TLB 태그 (ASID) | PCID 12비트 | ASID 8/16비트 | ASID 16비트 (RV64; RV32는 9비트) |
| TLB 무효화 명령 | INVLPG, INVPCID | TLBI VAE1/ASIDE1 | SFENCE.VMA |
| FPU 저장 | XSAVE/XRSTOR (Eager — 전환 시 즉시 저장) | FPSIMD (stp/ldp q0~q31, FPSR/FPCR) | fsd/fld (D 확장) — SR_FS가 DIRTY일 때만 저장(lazy) |
| Meltdown 취약 | Intel (KPTI 필요) | KPTI (UNMAP_KERNEL_AT_EL0 — 영향 코어에만 적용) | 미취약 (대부분) |
| Thread 포인터 | FS/GS base (MSR) | TPIDR_EL0/EL1 | tp 레지스터 (x4) |
RISC-V의 단순성: RISC-V는 x86-64의 복잡한 TSS, I/O 비트맵, 세그먼트 레지스터가 없어 Context Switch 구현이 간결합니다. Thread 포인터는 전용 레지스터(tp)로 MSR 쓰기 없이 단순 레지스터 교체로 처리됩니다. 다만 실제 전환은 switch_to() 매크로가 담당하며, 조건부로 부가 상태를 처리합니다:
__switch_to_fpu()—has_fpu()(D 확장)일 때 호출되고,sstatus.FS가 DIRTY인 경우에만fstate_save()(fsd/fld)를 수행합니다.__switch_to_vector()— Vector 확장(V Extension) 또는 XTheadVector가 있으면 벡터 상태를 저장·복원합니다. AVX-512와 유사한 대용량 상태라 비용이 큽니다.__switch_to_envcfg()—CSR_ENVCFG값을 next 태스크의 것으로 갱신합니다.switch_to_should_flush_icache()— 다른 hart로 이주한 경우에만local_flush_icache_all()을 수행합니다.- 마지막에
__switch_to()(어셈블리)가 호출되어 callee-saved 레지스터와 스택을 교체합니다.
SMP CPU 마이그레이션과 Context Switch
멀티코어 시스템에서 부하 분산기(Load Balancer)는 태스크를 한 CPU에서 다른 CPU로 이동합니다. 이 CPU 마이그레이션은 Context Switch와 밀접하게 연관되어 있으며, 캐시와 TLB를 완전히 콜드 상태로 만들기 때문에 비용이 매우 큽니다.
/* kernel/sched/core.c — CPU 마이그레이션 핵심 경로 */
/* 부하 분산 트리거 (scheduler_tick() → 주기적 호출) */
static void run_rebalance_domains(struct softirq_action *h)
{
struct rq *this_rq = this_rq();
enum cpu_idle_type idle = this_rq->idle_balance ?
CPU_IDLE : CPU_NOT_IDLE;
/* 모든 스케줄링 도메인을 순회하며 부하 불균형 탐지 */
rebalance_domains(this_rq, idle);
}
/* 태스크 마이그레이션 실행 */
static void __migrate_task(struct rq *src_rq, struct rq_flags *src_rf,
struct task_struct *p, int dest_cpu)
{
/* 소스 런큐에서 태스크 제거 */
dequeue_task(src_rq, p, DEQUEUE_NOCLOCK);
/* 마이그레이션 메타데이터 업데이트 */
set_task_cpu(p, dest_cpu); /* p->cpu = dest_cpu */
p->on_rq = TASK_ON_RQ_MIGRATING;
/* 목적지 CPU 런큐에 삽입 */
struct rq *dest_rq = cpu_rq(dest_cpu);
rq_lock(dest_rq, &dest_rf);
enqueue_task(dest_rq, p, ENQUEUE_NOCLOCK);
wakeup_preempt(dest_rq, p, 0); /* 목적지 CPU 선점 요청 */
rq_unlock(dest_rq, &dest_rf);
}
/*
* 마이그레이션 비용 분석:
* 1. 같은 LLC 공유 코어 간: 비교적 저렴 (L3 캐시 공유)
* 2. 다른 LLC 코어 간: L3 Miss 발생, 수십 µs
* 3. NUMA 노드 간: 원격 DRAM 접근 40~100ns/회
*
* 마이그레이션 억제 요소:
* - cache_hot_is_busy(): 최근 사용 태스크는 이동 보류
* - sched_migration_cost_ns(sysctl 이름; 커널 변수는 sysctl_sched_migration_cost, =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) 내) | 낮음 | L3 공유 → 낮음 | 없음 |
| 다른 LLC (소켓 내) | 중간 | L3 미스 → 중간 | 없음 |
| 다른 NUMA 노드 | 높음 | 전체 미스 → 높음 | 원격 메모리 접근 비용이 크게 증가(하드웨어·토폴로지 의존) |
자발적 vs 비자발적 Context Switch
| 종류 | 발생 원인 | 커널 경로 | 성능 영향 | 최적화 |
|---|---|---|---|---|
| 자발적 | sleep(), I/O 대기, mutex_lock(), wait_event() | schedule() 직접 호출 | 불가피하지만 낮음 | 비동기 I/O(io_uring), 잠금 경합(Lock Contention) 감소 |
| 비자발적 | 타임 슬라이스 만료, 고우선순위 깨어남 | TIF_NEED_RESCHED → 인터럽트 후 선점 | 높음 (CPU 경쟁 지표) | 프로세스 수 감소, CPU 어피니티, SCHED_FIFO |
# 프로세스별 자발적/비자발적 Context Switch 조회
cat /proc/$(pgrep nginx | head -1)/status | grep ctxt
voluntary_ctxt_switches: 12345 # 자발적 (I/O 대기 등)
nonvoluntary_ctxt_switches: 234 # 비자발적 (선점)
# 비율 해석:
# nonvoluntary >> voluntary → CPU 경쟁 심각, 프로세스/스레드 수 줄이기
# voluntary >> nonvoluntary → I/O 바운드, 비동기 I/O 검토
# 시스템 전체 비율
awk '/ctxt/ {sum[$1]+=$2} END {for(k in sum) print k, sum[k]}' \
/proc/*/status 2>/dev/null
높은 비자발적 CS: 비자발적 Context Switch가 자발적보다 훨씬 많으면 CPU 경쟁이 심한 것입니다. 프로세스/스레드 수를 CPU 수와 맞추거나, 실시간 우선순위(SCHED_FIFO)로 핵심 스레드를 격리하세요.
스레드(Thread) vs 프로세스(Process) Context Switch 비교
Context Switch는 같은 프로세스 내 스레드 간 전환과 서로 다른 프로세스 간 전환으로 나뉩니다. 핵심 차이는 메모리 주소 공간(mm_struct)의 공유 여부입니다. 같은 프로세스의 스레드들은 동일한 페이지 테이블을 공유하므로 CR3 교체가 필요 없고, TLB를 그대로 유지할 수 있습니다.
| 항목 | 같은 프로세스 스레드 전환 | 다른 프로세스 전환 |
|---|---|---|
| mm_struct (주소 공간) | 동일 (next->mm == prev->mm) |
다름 (next->mm != prev->mm) |
| CR3 교체 | 없음 | 있음 (switch_mm_irqs_off()) |
| TLB 영향 | 유지 (플러시 없음) | 부분/전체 무효화 (PCID 지원 여부에 따라) |
| 레지스터 전환 | 동일 (callee-saved + RSP) | 동일 (callee-saved + RSP) |
| TLS (FS/GS base) | 다름 (각 스레드 고유) | 다름 (각 프로세스 고유) |
| FPU 상태 | Eager 저장 (전환 시 XSAVE) | Eager 저장 (전환 시 XSAVE) |
| 직접 비용 (평균) | 낮음(캐시 공유) | 보통(주소 공간 전환) |
| 간접 비용 (캐시·TLB 미스) | 낮음 (캐시 공유) | 높음 (캐시 오염, TLB 미스) |
같은 mm이면 switch_mm_irqs_off() 생략: context_switch()는 next->mm == prev->active_mm인 경우(같은 프로세스의 스레드) CR3 교체를 건너뜁니다. 이것이 멀티스레드 서버(Nginx, 톰캣 등)가 멀티프로세스 방식보다 Context Switch 비용이 낮은 핵심 이유입니다. 단, TLS 교체(load_TLS())는 스레드 간에도 여전히 필요합니다.
커널 선점 모델 (Preemption Models)
Linux 커널은 PREEMPT_NONE, PREEMPT_VOLUNTARY, PREEMPT(FULL), PREEMPT_RT, PREEMPT_LAZY 등 다양한 선점 모델을 지원하며, 이는 Context Switch 빈도와 응답 지연에 직접적인 영향을 미칩니다. CONFIG_PREEMPT_DYNAMIC(v5.12+)으로 런타임 전환도 가능합니다.
협력형에서 선점형으로: Linux 선점 발전 역사
초기 Linux는 협력형(Cooperative) 멀티태스킹에 가까웠습니다. 커널 코드가 실행 중일 때는 선점이 불가능했으며, Context Switch는 태스크가 자발적으로 schedule()을 호출할 때만 발생했습니다. 이후 데스크톱·실시간 요구사항이 커지면서 선점 기능이 단계적으로 강화되었습니다.
| 커널 버전 | 이정표 | 선점 동작 변화 |
|---|---|---|
| v1.0 ~ 2.4 | 협력형 커널(Non-preemptible) | 유저 공간 선점만 가능. 커널 코드 실행 중 선점 불가. 타임 슬라이스가 만료돼도 커널 경로가 끝날 때까지 대기 |
| v2.5 (2001) | CONFIG_PREEMPT 도입 |
spinlock 밖이면 커널 어디서든 선점 가능. Ingo Molnár 패치. preempt_count 필드 추가 |
| v2.6.0 (2003) | PREEMPT_VOLUNTARY 추가 | 고지연 커널 경로에 might_sleep() / cond_resched() 삽입. 데스크톱 응답성과 처리량 균형 |
| v2.6.18 ~ | PREEMPT_RT 외부 트리 시작 | spinlock을 rtmutex로 교체, 인터럽트 핸들러 스레드화. 목표: <100 µs 응답 보장. 메인라인 미포함 |
| v5.12 (2021) | CONFIG_PREEMPT_DYNAMIC |
재부팅 없이 /sys/kernel/debug/sched/preempt로 런타임 전환. none/voluntary/full 지원 |
| v6.9 (2024) | PREEMPT_RT 메인라인 완전 통합 | 수십 년간 별도 트리로 유지된 RT 패치가 공식 커널에 통합 |
| v6.13 (2025) | PREEMPT_LAZY 추가 | 비RT 태스크는 유저 복귀 시 선점(처리량↑), RT 태스크는 즉시 선점(지연↓). FULL의 대체 후보 |
협력형 vs 선점형 핵심 차이: 협력형에서는 프로세스가 직접 schedule()을 호출해야만 전환됩니다. 선점형에서는 타이머(Timer) 인터럽트나 높은 우선순위 태스크 활성화 시 커널이 강제로 Context Switch를 수행합니다. 현재 Linux는 기본이 선점형이지만, preempt_disable() / spin_lock()으로 일시적 선점 억제가 가능합니다.
선점 모델 비교
| 선점 모델 | 커널 내 선점 | 비자발적 CS 빈도 | 응답 지연 | 처리량 | 대상 워크로드 |
|---|---|---|---|---|---|
| PREEMPT_NONE | 불가 (cond_resched만) | 최저 | 길다 | 최고 | 서버, 배치 처리 |
| PREEMPT_VOLUNTARY | might_resched() 지점만 | 낮음 | 중간 | 높음 | 데스크톱 (기본값) |
| PREEMPT (FULL) | spinlock 밖 어디서든 | 중간 | 짧음 | 중간 | 저지연 서버, 임베디드 |
| PREEMPT_LAZY (v6.13+) | 비RT: 유저 복귀 시 / RT: 즉시 | 낮음~중간 | 짧음~중간 | 높음 | FULL 대체 (처리량+지연 균형) |
| PREEMPT_RT | 거의 모든 곳 (rtmutex) | 높음 | 매우 짧음(실시간 보장 목표) | 낮음 | 실시간, 산업용, 오디오 |
# 현재 선점 모델 확인
zcat /proc/config.gz | grep PREEMPT
# CONFIG_PREEMPT_NONE=y → 서버 기본
# CONFIG_PREEMPT_VOLUNTARY=y → 데스크톱
# CONFIG_PREEMPT=y → FULL
# PREEMPT_DYNAMIC: 런타임 전환 (v5.12+)
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 상태 저장 빈도 (Context Switch 시점)
# 현대 커널은 Lazy FPU(#NM 지연 저장) 방식이 아니므로 x86_fpu tracepoint 사용
bpftrace -e '
tracepoint:x86_fpu:x86_fpu_regs_deactivated {
@[comm] = count();
}'
Off-CPU 분석
# Off-CPU Time: 프로세스가 CPU를 떠나 있던 시간 분석
# (BCC 도구 사용)
/usr/share/bcc/tools/offcputime -p $(pgrep myapp) 10
# 결과: 어떤 커널 경로(잠금, I/O, 페이지 폴트)에서 오래 대기하는지 표시
# 예시 출력:
# finish_task_switch.isra.0
# schedule
# futex_wait_queue_me ← mutex 대기
# myapp_worker_thread
# Duration: 150000 µs (150ms)
성능 최적화
Context Switch 감소 전략
- CPU Affinity 설정 — 프로세스를 특정 CPU에 고정해 캐시 재사용률 향상
- 스레드 풀 크기 최적화 — CPU 코어 수와 동일하게 설정(I/O 바운드는 2~4배)
- 비동기 I/O 사용 — io_uring, epoll로 블로킹 줄이기
- 배치 처리 — 여러 요청을 묶어서 처리, Context Switch 횟수 감소
- 실시간 정책 — SCHED_FIFO/RR로 비자발적 Context Switch 제거
- NUMA 인식 — numactl로 메모리와 CPU를 같은 노드에 할당
/* CPU Affinity 설정 */
#include <sched.h>
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset); /* CPU 0에 고정 */
sched_setaffinity(0, sizeof(cpuset), &cpuset);
/* 실시간 우선순위 + CPU 고정 */
struct sched_param param;
param.sched_priority = 80; /* 1(최저) ~ 99(최고) */
sched_setscheduler(0, SCHED_FIFO, ¶m);
/* 커널 스레드 생성 (mm=NULL, 주소 공간 전환 없음) */
struct task_struct *kthread = kthread_create(worker_fn, data, "worker");
kthread_bind(kthread, cpu_id); /* CPU 고정 */
wake_up_process(kthread);
io_uring으로 Context Switch 감소
#include <liburing.h>
struct io_uring ring;
/* 256개 항목 링 버퍼 초기화 */
io_uring_queue_init(256, &ring, 0);
/* 배치 I/O 요청 (Context Switch 없음) */
for (int i = 0; i < 64; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], bufs[i], BUF_SIZE, 0);
sqe->user_data = i;
}
/* 한 번의 시스템 콜로 64개 I/O 제출 */
io_uring_submit(&ring);
/* 완료 이벤트 대기 (블로킹 없음) */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
/* 결과: Context Switch 64회 → 1회로 감소 */
커널 스레드 vs 유저 스레드 비교
| 항목 | 유저 프로세스 간 | 유저→커널 스레드 | 커널 스레드 간 |
|---|---|---|---|
| CR3 변경 | 예 (switch_mm) | 아니오 (active_mm 차용) | 아니오 |
| TLB 플러시 | 예 (PCID 없으면) | 아니오 | 아니오 |
| 상대적 비용 | 높음 | 중간 | 낮음 |
| 예시 | nginx worker 간 | app → kworker | kworker ↔ ksoftirqd |
실사용 사례
고빈도 서버 최적화 (Nginx)
# Nginx — worker 수를 CPU 수에 맞추고 CPU 고정
# /etc/nginx/nginx.conf
worker_processes auto; # CPU 수 자동 감지
worker_cpu_affinity auto; # CPU 고정 (각 worker를 별도 CPU에)
# 기대 효과: Context Switch 크게 감소
# worker 간 캐시 공유 최소화 → 캐시 히트율 향상
데이터베이스 최적화 (PostgreSQL)
# PostgreSQL — max_connections을 CPU 수의 2~4배로 제한
# /etc/postgresql/14/main/postgresql.conf
max_connections = 200 # 너무 많으면 Context Switch 폭발
# pg_bouncer로 연결 풀링 (실제 백엔드 수 줄이기)
# 결과: 5000 연결 → 50 백엔드로 Context Switch 100배 감소
# 성능 측정
watch -n1 "psql -c \"SELECT sum(xact_commit) FROM pg_stat_bgwriter\""
실시간 오디오 처리
/* 오디오 처리 스레드: Context Switch 완전 차단 */
struct sched_param param;
param.sched_priority = 99; /* 최고 우선순위 */
sched_setscheduler(0, SCHED_FIFO, ¶m);
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(2, &cpuset); /* 오디오 전용 CPU */
sched_setaffinity(0, sizeof(cpuset), &cpuset);
/* 메모리 락: 페이지 폴트로 인한 Context Switch 방지 */
mlockall(MCL_CURRENT | MCL_FUTURE);
/* isolcpus=2 3 nohz_full=2 3 rcu_nocbs=2 3 ← 커널 파라미터
CPU 2,3을 완전히 격리: 틱 인터럽트, RCU 콜백 없음
결과: 비자발적 Context Switch = 0 */
컨테이너(Container) 환경 최적화
Docker/Kubernetes 환경에서는 cgroup v2의 CPU 대역폭(Bandwidth) 제어가 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 가상화(Virtualization) 환경
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 자체의 오버헤드는 사실상 없습니다). KVM 게스트는 VM Exit/Entry 오버헤드가 추가됩니다. 성능 민감 워크로드에서 컨테이너의 Context Switch 비용이 VM보다 낮은 이유입니다. 단, cpu.max 스로틀링이 발생하면 컨테이너 환경에서도 Context Switch가 급증할 수 있으므로, nr_throttled 지표를 모니터링하세요.
__schedule() 함수 심층 분석
__schedule()는 Linux 스케줄러의 핵심 진입점으로, 모든 Context Switch의 시작점입니다. 이 함수는 선점(preempt), 자발적 양보(voluntary), 협력적(cooperative) 세 가지 경로에서 호출되며, 각 경로마다 다른 선점 조건과 비용 특성을 가집니다.
/* kernel/sched/core.c — __schedule() 핵심 구현 */
static void __sched notrace __schedule(unsigned int sched_mode)
{
struct task_struct *prev, *next;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
/* ── 선점 모드 분기 ── */
if (sched_mode == SM_PREEMPT) {
/* 비자발적 선점: prev->state를 변경하지 않음
* prev는 여전히 TASK_RUNNING → 런큐에 유지
* 선점된 태스크는 즉시 다시 실행 대상이 됨 */
} else {
/* 자발적 양보: prev->state가 이미 TASK_INTERRUPTIBLE 등으로 변경됨
* dequeue_task()로 런큐에서 제거 (대기 큐에서 깨어날 때까지) */
if (!signal_pending_state(prev->__state, prev)) {
prev->sched_contributes_to_load =
(prev->__state & TASK_UNINTERRUPTIBLE) &&
!(prev->flags & PF_FROZEN);
deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
}
}
/* ── 다음 태스크 선택 ── */
next = pick_next_task(rq, prev, &rf);
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
/* ── 실제 문맥 전환 수행 ── */
rq = context_switch(rq, prev, next, &rf);
} else {
/* 선택된 태스크가 현재 태스크와 동일 → 전환 불필요 */
rq_unpin_lock(rq, &rf);
__balance_callbacks(rq);
raw_spin_rq_unlock_irq(rq);
}
}
/* 세 가지 호출 경로 */
/* 1. 자발적: schedule() → __schedule(SM_NONE) */
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
__schedule(SM_NONE);
} while (need_resched());
sched_update_worker(tsk);
}
/* 2. 선점: preempt_schedule_irq() → __schedule(SM_PREEMPT) */
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
do {
preempt_disable();
local_irq_enable();
__schedule(SM_PREEMPT);
local_irq_disable();
sched_preempt_enable_no_resched();
} while (need_resched());
}
/* 3. 협력적: cond_resched() */
int __sched __cond_resched(void)
{
if (should_resched(0) && !irqs_disabled()) {
preempt_schedule_common(); /* → __schedule(SM_PREEMPT) */
return 1;
}
/* PREEMPT_RCU가 아니면 RCU 정지 상태(quiescent state)를 여기서 보고 */
#ifndef CONFIG_PREEMPT_RCU
rcu_all_qs();
#endif
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인 경우에만 실제 문맥 전환(Context Switch)을 수행합니다. 주소 공간 전환(switch_mm)과 레지스터 전환(switch_to)을 포함합니다. - schedule() 래퍼
sched_submit_work()로 보류 중인 작업(블록 I/O 플러그 등)을 제출한 뒤,need_resched()가 해제될 때까지__schedule(SM_NONE)을 반복 호출합니다. - __cond_resched()협력적 양보 경로입니다. 실제 심볼 이름은
__cond_resched()이며,should_resched(0)로 선점 카운터가 0이고TIF_NEED_RESCHED가 설정되어 있고 인터럽트가 꺼져 있지 않은 경우에만preempt_schedule_common()을 호출합니다. 양보하지 않고 빠져나온 경우에는rcu_all_qs()로 RCU 정지 상태를 보고합니다(컨텍스트 스위치가 곧 RCU 정지 상태이므로 커널이 아닌 코드는 보고하지 않아도 됩니다).
호출 체인: 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 | 런큐 유지 | 발생 시점 |
|---|---|---|---|---|
| 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.4KB(AVX-512), AMX 포함 시 약 10.4KB에 달합니다. Context Switch 시 이 상태의 저장/복원 전략은 성능에 직접적인 영향을 미칩니다. 커널 4.6(commit 58122bf1d856)부터 보안(사이드 채널)과 예측 가능한 지연 이유로 Eager 모드가 기본값이지만, 내부 최적화(last_cpu, XSAVEOPT)로 실제 비용은 상당히 절감되었습니다.
/* arch/x86/kernel/fpu/context.h — Eager FPU 전환 (현대 커널) */
/* arch/x86/include/asm/fpu/sched.h — 전환 시 호출: FPU 상태 저장 (Eager) */
static inline void switch_fpu(struct task_struct *old, int cpu)
{
if (!test_tsk_thread_flag(old, TIF_NEED_FPU_LOAD) &&
cpu_feature_enabled(X86_FEATURE_FPU) &&
!(old->flags & (PF_KTHREAD | PF_USER_WORKER))) {
struct fpu *old_fpu = x86_task_fpu(old);
/* 즉시 저장 (XSAVE/XSAVEOPT) — 복원은 유저 복귀 시점으로 지연 */
set_tsk_thread_flag(old, TIF_NEED_FPU_LOAD);
save_fpregs_to_fpstate(old_fpu);
old_fpu->last_cpu = cpu; /* 같은 CPU 복귀 시 XRSTOR 생략 */
}
}
/* 유저 공간 복귀 시: TIF_NEED_FPU_LOAD가 세트되어 있으면 XRSTOR */
/* (fpregs_restore_userregs() — syscall/irq 반환 경로에서 호출) */
/* 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()전환 시 현재 태스크의 FPU 상태를
save_fpregs_to_fpstate()로 즉시 저장합니다(Eager). 내부에서 XSAVEOPT/XSAVES 계열 명령을 실행하며,XSTATE_BV비트맵에서 변경된 컴포넌트만 실제 메모리에 기록합니다. 커널 스레드는 FPU 상태가 없으므로(PF_KTHREAD) 제외됩니다. - 유저 복귀 시 복원저장 후
TIF_NEED_FPU_LOAD를 세트해 복원을 유저 복귀 시점으로 지연합니다.fpregs_restore_userregs()가last_cpu를 확인해 같은 CPU에서 컨텍스트를 소유한 경우 XRSTOR을 생략하여 수백 ns를 절약합니다. - struct fpu
last_cpu필드로 FPU 레지스터 캐싱 최적화를 구현합니다. 태스크가 이전과 같은 CPU에서 실행되면 레지스터가 이미 올바른 상태이므로 복원이 불필요합니다. - struct fpstateXSAVE 영역의 메타데이터를 관리합니다.
xfeatures비트맵은 활성 컴포넌트(x87, SSE, AVX, AVX-512 등)를 나타내며,size는 실제 사용 크기(576B~2.4KB+, AMX 시 10.5KB)를 추적합니다. - avx512_timestamp 필드AVX-512 사용 시점을
jiffies로 기록합니다. Intel CPU에서 AVX-512 실행 후 수백 µs 동안 코어 주파수가 다운클럭되므로, 같은 코어의 다른 태스크 성능 영향 판단에 활용됩니다.
호출 체인: context_switch() → switch_fpu(prev, cpu) → switch_to() → __switch_to_asm → ... → 유저 복귀 시 fpregs_restore_userregs() (XRSTOR)
AVX-512 주파수 다운클럭: Intel CPU에서 AVX-512 명령을 실행하면 코어 주파수가 일정 수준 감소할 수 있습니다(정도·지속 시간은 CPU와 설정에 따라 달라짐). 이 다운클럭은 짧은 시간 지속되며, 같은 물리 코어의 다른 하이퍼스레드에도 영향을 줍니다. perf stat -e cpu/event=0xc6,umask=0x01/로 AVX-512 사용 빈도를 확인하고, clearcpuid=avx512f 커널 파라미터로 비활성화할 수 있습니다.
| XSTATE 컴포넌트 | CPUID 비트 | 크기 | XSAVEOPT 비용 | 비고 |
|---|---|---|---|---|
| x87 FPU (ST0-ST7) | bit 0 | 160B | 낮음 | 레거시, 항상 저장 |
| SSE (XMM0-XMM15) | bit 1 | 256B | 낮음 | MXCSR 포함 |
| AVX (YMM hi128) | bit 2 | 256B | 중간 | 변경 시에만 저장 |
| MPX (bnd0-bnd3) | bit 3-4 | 128B | 낮음 | Linux 5.6에서 제거 |
| AVX-512 (ZMM hi256 + Opmask + Hi16) | bit 5-7 | ~1,600B | 높음 | 주파수 다운클럭 유발 |
| PKRU (보호키) | bit 9 | 8B | 매우 낮음 | MPK 사용 시 |
| AMX TILE (tmm0-tmm7) | bit 17-18 | 8,192B | 매우 높음 | Intel Sapphire Rapids+ |
KPTI 페이지 테이블 전환
KPTI(Kernel Page-Table Isolation)는 Meltdown 취약점 완화를 위해 유저 공간과 커널 공간에 각각 별도의 페이지 테이블(PGD)을 사용합니다. 유저 PGD에는 커널 주소 공간 대부분이 매핑되지 않으므로, Meltdown을 통한 커널 메모리 읽기가 불가능합니다. 이 이중 PGD 구조가 Context Switch와 시스템 콜에 미치는 영향을 분석합니다.
/* arch/x86/include/asm/pgtable.h — 유저 PGD 주소 계산 */
/* 커널 PGD 와 유저 PGD 는 연속된 2페이지에 할당되어
* 두 주소는 비트 12 하나만 다릅니다 (8KB 정렬 2페이지).
*/
static inline pgd_t *kernel_to_user_pgdp(pgd_t *pgdp)
{
return ptr_set_bit(pgdp, PTI_PGTABLE_SWITCH_BIT); /* = PAGE_SHIFT = 12 */
}
/* arch/x86/mm/pti.c — 유저 PGD 초기화 */
void __init pti_init(void)
{
if (!boot_cpu_has(X86_FEATURE_PTI))
return;
pr_info("enabled\n");
/* cpu_entry_area, entry 스택, GDT/IDT 등 최소 공유 영역만 복제 */
pti_clone_user_shared();
/* 커널 이미지의 global 비트를 모두 해제 */
pti_set_kernel_image_nonglobal();
/* 공유 entry 텍스트에 필요한 부분만 다시 global 로 */
pti_clone_entry_text(false);
pti_setup_espfix64();
pti_setup_vsyscall();
}
/* 시스템 콜 진입 시 CR3 전환 (어셈블리 매크로) */
/* arch/x86/entry/calling.h */
#define PTI_USER_PGTABLE_BIT PAGE_SHIFT /* 비트 12 */
#define PTI_USER_PGTABLE_MASK (1 << PTI_USER_PGTABLE_BIT)
#define PTI_USER_PCID_BIT X86_CR3_PTI_PCID_USER_BIT /* 비트 11 */
#define PTI_USER_PCID_MASK (1 << PTI_USER_PCID_BIT)
#define PTI_USER_PGTABLE_AND_PCID_MASK (PTI_USER_PCID_MASK | PTI_USER_PGTABLE_MASK)
.macro ADJUST_KERNEL_CR3 reg:req
ALTERNATIVE "", "SET_NOFLUSH_BIT \reg", X86_FEATURE_PCID
/* PCID 비트(11)와 PTI 비트(12)를 함께 클리어 → 커널 PGD */
andq $(~PTI_USER_PGTABLE_AND_PCID_MASK), \reg
.endm
.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
ALTERNATIVE "jmp .Lend_\@", "", X86_FEATURE_PTI
mov %cr3, \scratch_reg
ADJUST_KERNEL_CR3 \scratch_reg
mov \scratch_reg, %cr3
.Lend_\@:
.endm
/* 유저 복귀는 SWITCH_TO_USER_CR3 / SWITCH_TO_USER_CR3_STACK 가 담당합니다
* (앞 절의 SWITCH_TO_USER_CR3 발췌 참조). */
코드 설명
arch/x86/mm/pti.c와 arch/x86/entry/calling.h의 KPTI(Kernel Page Table Isolation) 구현입니다. Meltdown 취약점 완화를 위해 커널/유저 공간에 별도의 페이지 테이블을 사용합니다.
- kernel_to_user_pgdp()커널 PGD 주소에서
PTI_PGTABLE_SWITCH_BIT(=PAGE_SHIFT, 비트 12)만 세워 유저 PGD 주소를 계산합니다. 두 PGD 는 연속된 2페이지(8KB)에 할당되므로 비트 하나만 뒤집으면 됩니다. - pti_init()유저 PGD 를 초기화합니다.
pti_clone_user_shared()로cpu_entry_area·entry 스택·GDT/IDT 등 최소 공유 영역만 복제하고, 커널 이미지의 global 비트를 해제한 뒤pti_clone_entry_text(false)로 진입 경로에 필요한 텍스트만 다시 global 로 되돌립니다. 나머지 커널 주소는 유저 PGD 에 매핑되지 않습니다. - SWITCH_TO_KERNEL_CR3시스템 콜/인터럽트 진입 시 실행되는 어셈블리 매크로입니다.
ADJUST_KERNEL_CR3가 PCID 비트(11)와 PTI 비트(12)를 함께 클리어(andq $~PTI_USER_PGTABLE_AND_PCID_MASK)해 커널 PGD 로 전환하고, PCID 지원 CPU 에서는 그 전에 NOFLUSH 비트를 세워 TLB 를 보존합니다.ALTERNATIVE매크로로 PTI 비활성화 시 이 코드를 NOP 으로 패치(Patch)합니다. - SWITCH_TO_USER_CR3_STACK유저 공간 복귀 시 실행됩니다. 현재 ASID 를
andq $(0x7FF)로 뽑아 per-CPUuser_pcid_flush_mask를 검사하고, 플러시가 필요 없으면 NOFLUSH 비트를 세운 뒤 PCID 비트(11)와 PTI 비트(12)를 차례로 세워 uPCID·유저 PGD 로 전환합니다.
비용: KPTI 활성화 시 시스템 콜당 CR3 쓰기 2회 추가(진입 + 복귀), Context Switch 시 총 3회. PCID 미지원 CPU에서는 매번 TLB 전체 플러시가 발생하여 성능 저하가 심각합니다.
PCID와 KPTI 시너지: KPTI 활성화 시 하나의 mm 에 유저·커널 두 주소 공간이 생기므로 PCID 값도 두 개를 씁니다. 커널 쪽은 kPCID(= ASID + 1), 유저 쪽은 여기에 비트 11 을 세운 uPCID(= kPCID + 2048)입니다. CR3 쓰기 시 NOFLUSH 비트를 세트하면 TLB를 보존하므로, 시스템 콜 진입/복귀 시 TLB 플러시 없이 PGD를 전환할 수 있습니다. PCID가 없는 구형 CPU에서는 매 전환마다 TLB 전체 플러시가 발생해 성능 저하가 극심합니다.
TLB 플러시 전략
TLB(Translation Lookaside Buffer) 관리는 Context Switch 성능의 핵심입니다. 커널은 상황에 따라 전체 플러시, 단일 페이지 무효화, 원격 CPU IPI 기반 무효화 등 여러 전략을 사용합니다. PCID와 INVPCID 명령의 조합으로 불필요한 TLB 플러시를 최소화합니다.
/* arch/x86/mm/tlb.c — TLB 플러시 핵심 구현 */
/* 원격 CPU TLB 플러시 (IPI 기반) */
void flush_tlb_mm_range(struct mm_struct *mm,
unsigned long start, unsigned long end,
unsigned int stride_shift, bool freed_tables)
{
struct flush_tlb_info info = {
.mm = mm,
.start = start,
.end = end,
.stride_shift = stride_shift,
.freed_tables = freed_tables,
};
/* 1. 로컬 CPU TLB 무효화 */
if (mm == current->active_mm)
flush_tlb_func_local(&info, TLB_LOCAL_MM_SHOOTDOWN);
/* 2. 이 mm을 사용 중인 다른 CPU들에 IPI 전송 */
if (cpumask_any_but(mm_cpumask(mm),
smp_processor_id()) < nr_cpu_ids) {
flush_tlb_multi(mm_cpumask(mm), &info);
}
}
/* IPI 수신 핸들러: 원격 CPU에서 실행 */
static void flush_tlb_func(void *info)
{
struct flush_tlb_info *f = info;
struct mm_struct *loaded_mm = this_cpu_read(cpu_tlbstate.loaded_mm);
/* Lazy TLB 모드: mm이 활성 상태가 아니면 플래그만 세트 */
if (loaded_mm != f->mm) {
/* 나중에 이 mm으로 복귀할 때 TLB 전체 플러시 */
this_cpu_write(cpu_tlbstate.is_lazy, 1);
return;
}
/* 활성 mm: 요청된 범위만 무효화 */
if (f->end == TLB_FLUSH_ALL) {
/* 전체 플러시: 범위가 너무 넓거나 freed_tables인 경우 */
local_flush_tlb();
} else {
/* 범위 플러시: INVLPG 또는 INVPCID type 0 사용 */
unsigned long addr;
for (addr = f->start; addr < f->end;
addr += 1UL << f->stride_shift) {
invlpg(addr);
}
}
}
/* 페이지 수에 따른 플러시 전략 결정 */
/* tlb_single_page_flush_ceiling (기본 33):
* 무효화할 페이지 수가 이 값 이하면 INVLPG 반복,
* 초과하면 전체 플러시가 더 효율적 */
static unsigned long tlb_single_page_flush_ceiling = 33;
코드 설명
arch/x86/mm/tlb.c의 TLB 플러시 핵심 구현입니다. 페이지 테이블 변경 시 다른 CPU의 stale TLB 항목을 무효화하는 메커니즘입니다.
- flush_tlb_mm_range()페이지 테이블 변경(munmap, mprotect 등) 후 호출됩니다. 로컬 CPU의 TLB를 먼저 무효화한 뒤,
mm_cpumask()로 해당 mm을 사용 중인 원격 CPU 목록을 확인하여 IPI(Inter-Processor Interrupt)를 전송합니다. - flush_tlb_func() — IPI 핸들러원격 CPU에서 IPI 수신 시 실행됩니다.
loaded_mm이 요청된 mm과 다르면(Lazy TLB 모드) 플래그만 세트하고 즉시 반환합니다. 활성 mm이면 요청 범위에 따라 INVLPG(개별) 또는 전체 플러시를 수행합니다. - Lazy TLB 최적화커널 스레드는 자체 mm이 없으므로(
mm == NULL), 이전 태스크의active_mm을 차용합니다. 이때 원격 TLB shootdown IPI를 받으면is_lazy플래그만 세트하고, 다음에 유저 태스크로 전환할 때 일괄 처리합니다. - TLB_FLUSH_ALL무효화 범위가
tlb_single_page_flush_ceiling(기본 33페이지)을 초과하거나 페이지 테이블 자체가 해제된 경우(freed_tables), 개별 INVLPG 반복보다 전체 플러시가 효율적이므로local_flush_tlb()를 호출합니다. - tlb_single_page_flush_ceilingINVLPG 반복과 전체 플러시의 비용 교차점입니다. INVLPG는 페이지당 비용이 낮고 전체 플러시(CR3 재로드)는 비용이 높으므로, 페이지 수가 많아지면 전체 플러시가 유리해집니다.
| TLB 플러시 트리거 | 플러시 범위 | 비용 | IPI 필요 |
|---|---|---|---|
| Context Switch (switch_mm) | PCID: 없음 / 미지원: 전체 | 낮음(PCID) / 높음(미지원) | 아니오 |
| munmap() / mprotect() | 변경된 VA 범위 | 페이지당 소폭 | 예 (mm 활성 CPU들) |
| 페이지 마이그레이션 | 이동된 페이지 | 페이지당 소폭 | 예 |
| 커널 모듈(Kernel Module) 로드/해제 | 전체 (글로벌) | 높음 | 예 (모든 CPU) |
| KPTI 유저/커널 전환 | PCID: 없음 / 미지원: 전체 | 낮음(PCID) / 높음(미지원) | 아니오 |
TLB shootdown 비용: IPI 기반 원격 TLB 무효화는 대상 CPU에 인터럽트를 발생시키므로, 코어 수가 많은 시스템에서 munmap()이 상당히 지연될 수 있습니다. perf stat -e tlb:tlb_flush로 빈도를 측정하고, 가능하면 madvise(MADV_FREE)로 즉시 해제 대신 지연 해제를 사용하세요.
ftrace/perf/bpftrace 문맥전환 분석 실전
문맥전환 병목을 진단할 때는 단순 횟수뿐 아니라, 어떤 프로세스가 어떤 이유로 전환되는지, 전환 지연(스케줄링 지연)이 얼마인지, 캐시/TLB 영향은 얼마인지를 종합적으로 분석해야 합니다. 이 섹션은 ftrace sched_switch 이벤트, perf sched 지연, bpftrace 고급 분석 기법을 실전 예제로 다룹니다.
ftrace sched_switch 이벤트
# ftrace로 sched_switch 이벤트 활성화
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
# 출력 형식 확인
cat /sys/kernel/debug/tracing/trace_pipe | head -20
# 출력 예시:
# nginx-1234 [002] d..2 12345.678901: sched_switch:
# prev_comm=nginx prev_pid=1234 prev_prio=120 prev_state=S ==>
# next_comm=kworker/2:1 next_pid=5678 next_prio=120
#
# 필드 해석:
# prev_state: R=Running(비자발적), S=Sleeping(자발적),
# D=Uninterruptible, T=Stopped, X=Dead
# 특정 프로세스만 필터링
echo 'prev_comm == "myapp" || next_comm == "myapp"' > \
/sys/kernel/debug/tracing/events/sched/sched_switch/filter
# 함수 그래프 트레이서로 schedule() 내부 경로 추적
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo __schedule > /sys/kernel/debug/tracing/set_graph_function
cat /sys/kernel/debug/tracing/trace
# 출력: __schedule 내부 호출 트리 + 소요 시간
# | 0.312 us | pick_next_task_fair();
# | 0.156 us | switch_mm_irqs_off();
# | 0.891 us | __switch_to_asm();
# | 0.234 us | finish_task_switch();
# 비활성화
echo 0 > /sys/kernel/debug/tracing/events/sched/sched_switch/enable
echo nop > /sys/kernel/debug/tracing/current_tracer
perf sched 심층 지연 분석
# 10초간 스케줄링 이벤트 기록
perf sched record -- sleep 10
# 프로세스별 스케줄링 레이턴시 분석 (최대 지연순 정렬)
perf sched latency --sort max
# 출력 예시:
# Task | Runtime | Switches | Avg delay | Max delay |
# nginx:1234 | 2345.678ms | 5678 | 0.012ms | 2.345ms |
# postgres:5678 | 1234.567ms | 3456 | 0.045ms | 15.678ms |
#
# Max delay가 높은 프로세스: 스케줄링 지연 심각
# 시간순 전환 타임라인 (각 전환별 지연 표시)
perf sched timehist --summary
# 출력: CPU별 전환 타임라인
# 12345.001 [002] nginx:1234 0.003ms → kworker/2:1:5678
# 12345.002 [002] kworker:5678 0.001ms → nginx:1234
# wakeup 지연 분석 (깨어난 후 실제 실행까지)
perf sched timehist -w
# wakeup_time 컬럼: wake_up() → 실제 CPU 획득까지의 지연
# Context Switch와 캐시 미스 상관관계 분석
perf stat -e context-switches,cache-misses,dTLB-load-misses,\
dTLB-store-misses,L1-dcache-load-misses -p $(pgrep myapp) sleep 10
# context-switches가 높을수록 cache-misses도 비례해서 증가하면
# → Context Switch 감소가 최우선 최적화 목표
bpftrace 문맥전환 고급 분석
# 문맥전환 원인별 분류 (스택 트레이스 포함)
bpftrace -e '
tracepoint:sched:sched_switch {
/* prev_state별 분류 */
if (args->prev_state == 0) {
@preempted[args->prev_comm] = count();
} else if (args->prev_state == 1) {
@sleeping[args->prev_comm] = count();
} else if (args->prev_state == 2) {
@disk_wait[args->prev_comm] = count();
}
}
interval:s:5 {
printf("--- 비자발적 (선점됨) ---\n");
print(@preempted, 10);
printf("--- 자발적 (sleep) ---\n");
print(@sleeping, 10);
printf("--- I/O 대기 ---\n");
print(@disk_wait, 10);
clear(@preempted); clear(@sleeping); clear(@disk_wait);
}'
# CPU별 문맥전환 빈도 히트맵
bpftrace -e '
tracepoint:sched:sched_switch {
@[cpu] = count();
}
interval:s:3 { print(@); clear(@); }'
# 스케줄링 레이턴시 히스토그램 (깨어남 → 실행 시작)
bpftrace -e '
tracepoint:sched:sched_wakeup {
@wake[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$ts = @wake[args->next_pid];
if ($ts > 0) {
$lat_us = (nsecs - $ts) / 1000;
@latency_us = hist($lat_us);
if ($lat_us > 1000) {
printf("HIGH LAT: %s pid=%d lat=%d us\n",
args->next_comm, args->next_pid, $lat_us);
}
delete(@wake[args->next_pid]);
}
}
END { print(@latency_us); }'
# switch_mm 빈도 분석 (주소 공간 전환 비용)
bpftrace -e '
kprobe:switch_mm_irqs_off {
@switch_mm[comm] = count();
}
kprobe:enter_lazy_tlb {
@lazy_tlb[comm] = count();
}
interval:s:5 {
printf("=== switch_mm (full TLB cost) ===\n");
print(@switch_mm, 5);
printf("=== lazy_tlb (no TLB cost) ===\n");
print(@lazy_tlb, 5);
clear(@switch_mm); clear(@lazy_tlb);
}'
실전 진단 순서: ① vmstat 1로 cs 빈도 확인 → ② /proc/PID/status로 자발적/비자발적 비율 확인 → ③ perf sched latency로 최대 지연 프로세스 식별 → ④ bpftrace로 원인 분류(선점/sleep/I/O) → ⑤ perf stat로 캐시/TLB 영향 정량화. 이 순서로 병목을 좁혀가면 최적화 대상을 빠르게 특정할 수 있습니다.
문맥전환 비용 정량 분석
Context Switch의 실제 비용은 레지스터 저장/복원의 직접 비용보다 캐시/TLB 오염으로 인한 간접 비용이 훨씬 큽니다. 이 섹션은 두 비용을 정량적으로 분석하고, CPU 세대별/워크로드별 벤치마크 결과를 비교합니다.
# Context Switch 직접 비용 측정 (lmbench)
# 2개 프로세스, 0 바이트 파이프 전달
lat_ctx -s 0 2
# "size=0k ovr=0.53 2 1.23" → 1.23us/전환
# 프로세스 수 증가 시 비용 변화 (캐시 오염 증가)
for n in 2 4 8 16 32; do
echo "=== $n processes ==="
lat_ctx -s 0 $n
done
# 프로세스 수 증가 → 캐시 오염 → 비용 급증
# 2: 1.2us, 4: 1.8us, 8: 3.5us, 16: 7.2us, 32: 15.1us
# perf로 직접/간접 비용 분리 측정
perf stat -e context-switches,\
cache-references,cache-misses,\
L1-dcache-loads,L1-dcache-load-misses,\
dTLB-loads,dTLB-load-misses,\
branch-instructions,branch-misses \
-- lat_ctx -s 0 8 2>/dev/null
# 결과 해석 예시:
# context-switches: 100,000
# cache-misses: 2,500,000 → 25/CS (캐시 라인 오염)
# dTLB-load-misses: 800,000 → 8/CS (TLB 미스)
# branch-misses: 300,000 → 3/CS (분기 예측 실패)
# KPTI 영향 정량화 (nopti 커널 파라미터로 비교)
# KPTI 활성: lat_ctx -s 0 2 → 1.5us
# KPTI 비활성: lat_ctx -s 0 2 → 1.2us (약 20% 차이)
| 비용 항목 | 크기 (ns) | 측정 방법 | 최적화 전략 |
|---|---|---|---|
| 레지스터 push/pop | 낮음 | ftrace function_graph | 최적화 불가 (하드웨어 한계) |
| CR3 전환 | 낮음~중간 | bpftrace kprobe:write_cr3 | PCID 활성화 (비용 절감) |
| FPU XSAVE/XRSTOR | 중간~높음 | perf stat cycles (함수별) | AVX-512 격리, init state 활용 |
| L1d 캐시 콜드 | 높음 | perf stat L1-dcache-load-misses | CPU 어피니티, 워킹셋 축소 |
| TLB Miss (PCID 없음) | 매우 높음 | perf stat dTLB-load-misses | PCID 활성화, Huge Pages |
| NUMA 원격 접근 | 매우 높음 | numastat, perf c2c | numactl --membind, CPU 어피니티 |
rseq 사용자 복귀 경로 최적화
rseq(Restartable Sequences)는 Linux 4.18에서 추가된 사용자 공간(User Space) per-CPU 프리미티브지만, 그 여파는 모든 커널→유저 복귀 경로에 매 호출마다 얇은 체크를 덧붙이는 형태로 나타납니다. glibc 2.35가 모든 스레드에 자동으로 rseq를 등록하면서, 실제로 rseq를 사용하지 않는 프로세스도 매 시스템 콜·인터럽트 복귀마다 이 오버헤드를 지불해 왔습니다. Linux 6.19에서 이 공용 경로가 정리되어 컨텍스트 스위치 이후 사용자 복귀까지의 지연(Latency)이 측정 가능하게 줄었습니다.
기존 경로: exit_to_user_mode_loop() 속의 rseq 훅
/* kernel/entry/common.c — 6.18까지 공용 유저 복귀 경로 */
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs,
unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
if (ti_work & _TIF_NEED_RESCHED)
schedule();
if (ti_work & _TIF_SIGPENDING)
arch_do_signal_or_restart(regs);
if (ti_work & _TIF_NOTIFY_RESUME)
resume_user_mode_work(regs); /* 여기서 rseq_handle_notify_resume() 호출 */
ti_work = read_thread_flags();
}
/* ... */
}
문제는 _TIF_NOTIFY_RESUME이 rseq 때문이 아닌 다른 이유(예: mempolicy 변경, 태스크 work)로 설정되어도 rseq_handle_notify_resume()이 매번 호출된다는 점이었습니다. 내부에서는 rseq_cs가 없음을 확인하고 즉시 반환하지만, 캐시 라인 액세스와 branch mispredict이 누적되어 시스템 콜 heavy 워크로드에서 관측 가능한 슬로우다운을 유발했습니다.
Linux 6.19의 개선
6.19에서는 rseq의 "복귀 시 갱신해야 할 상태"(CPU ID, NUMA 노드 ID, mm_cid)가 실제로 변경된 경우에만 노티파이를 발생시키도록 플래그 설정을 한 경로로 묶었고, 유저 복귀 루프에서는 rseq 업데이트를 마지막 반복에서 한 번만 수행하도록 리팩터링했습니다. 그 결과 실제로 rseq 임계 구간이 없는 스레드는 복귀 경로에서 rseq 관련 작업을 사실상 건너뜁니다.
| 워크로드 | 개선 전 | 개선 후 | 개선 폭 |
|---|---|---|---|
getpid() 루프 (단순 시스템 콜) |
측정 가능 | 감소 | 수 % 수준 |
컨텍스트 스위치 heavy (pipe 핑퐁) |
기준선 | 소폭 처리량(Throughput) 개선(워크로드 의존) | 워크로드 의존 |
| rseq critical section을 실제로 사용하는 라이브러리 (TCMalloc) | 이미 hit | 변화 없음 | — |
관련 작업: rseq는 또한 캐시 로컬 동시 ID(mm_cid)를 통해 per-CPU 자료구조의 공간 압축을 도와 캐시 효율을 크게 향상시킵니다. 2024년에는 간헐적 워크로드에서 mm_cid 재사용을 최적화하여 큰 폭의 속도 향상이 보고되었습니다. 컨텍스트 스위치 자체보다는 스위치 이후 사용자 공간 실행 효율이 향상되는 간접 효과입니다.
cond_resched()와 자발적 양보
cond_resched()는 PREEMPT_NONE/PREEMPT_VOLUNTARY 모델에서 긴 커널 경로의 지연을 줄이기 위한 자발적 양보 지점입니다. 내부 심볼 __cond_resched()가 TIF_NEED_RESCHED가 설정되어 있고 선점 카운터가 0일 때 preempt_schedule_common() → __schedule(SM_NONE) 경로로 들어가 Context Switch를 유발합니다. 양보하지 않고 빠져나올 때도 rcu_all_qs()로 RCU 정지 상태를 보고하므로, RCU 정책 관점에서는 컨텍스트 스위치와 같은 역할을 합니다.
/* cond_resched() — 커널 루프 내 자발적 양보 */
int __sched __cond_resched(void)
{
if (should_resched(0) && !irqs_disabled()) {
preempt_schedule_common();
return 1;
}
rcu_all_qs();
return 0;
}
선점 모델과 cond_resched() 관계: PREEMPT(FULL) 모델에서는 cond_resched()가 사실상 no-op입니다(커널이 이미 완전 선점 가능). PREEMPT_LAZY(v6.13+)는 비실시간 태스크에 대해 cond_resched()와 유사한 지연 선점을 자동으로 적용합니다. 각 모델별 상세 비교는 preempt_count & 선점 모델을 참고하세요.
최신 변경사항 (6.14+)
관련 연혁: Context Switching 변경 이력
이 문서에 남기는 기준: 현재 코드 경로를 읽을 때 해석이 달라지는 버전 차이만 유지합니다. 릴리즈별 나열형 changelog는 중앙 문서에서 관리합니다.
커널 6.14 변경사항
-
x86 TLB 플러시 확장성 최적화 —
mm_cpumask지연 갱신 (Rik van Riel, Meta) — 컨텍스트 스위치 시mm_cpumask를 즉시 갱신하는 대신 첫 번째 TLB 플러시 시점까지 지연합니다. 멀티스레드 워크로드에서switch_mm_irqs_off()의 CPU 경합을 크게 줄여 Hackbench 기준 약 7% 성능 향상을 달성했습니다. 자세한 내용은 mm_cpumask 지연 갱신 최적화 섹션을 참조하세요.커널 6.14부터: x86
mm_cpumask지연 갱신 방식 도입. Intel/AMD x86_64 모두 적용. -
sched_ext: 유휴 CPU 마스크(idle cpumask)를 NUMA 노드별로 분리 — BPF 기반 스케줄링 정책 프레임워크인 sched_ext가 유휴 CPU를 추적하는 글로벌 cpumask를 NUMA 노드별 cpumask로 분리하여, 대규모 NUMA 시스템에서 유휴 CPU 상태 갱신의 경합과 캐시 코어런시(Cache Coherency) 압력을 줄였습니다. 변경 위치는
kernel/sched/ext.c이며, 일반 커널 스케줄러(CFS/EEVDF)의 유휴 CPU 탐색 경로는 그대로입니다.sched_ext용 6.14 patchset(Andrea Righi, 2024-12). 동일 조건의 병렬 커널 빌드 측정에서 약 4% 가속(DGX B200, Xeon Platinum 8570, 224 SMT 스레드, 2 NUMA 노드, scx_simple 기준 — 측정 환경에 따라 달라질 수 있음). 출처: lwn.net/Articles/1001179
커널 6.15 변경사항
-
CONFIG_SCHED_DEBUG무조건 포함 — 기존에는CONFIG_SCHED_DEBUG=y로 설정해야만 활성화되던 스케줄러 디버그(Debug) 기능이 이제 조건 없이 빌드에 포함됩니다. 컨텍스트 스위치 관련 통계와 디버그 정보를 별도 커널 빌드 없이 관찰할 수 있습니다.커널 6.15부터: CONFIG_SCHED_DEBUG 조건부 컴파일 제거.
-
sched_ext 위상 인식 유휴 CPU 선택 개선 — sched_ext(확장 스케줄러(Extended Scheduler) 클래스)의 기본 유휴 CPU 선택 로직이 토폴로지(Topology) 인식 방식으로 개선되어, 물리 코어·소켓·NUMA 노드 계층을 고려한 더 효율적인 컨텍스트 스위치 대상 CPU를 결정합니다. 또한 코어 이벤트 카운터(Core Event Counter) 지원이 추가되어 스케줄러 동작을 세밀하게 모니터링할 수 있습니다.
커널 6.15부터: sched_ext 토폴로지 인식 유휴 선택, 코어 이벤트 카운터 추가.
참고자료
- Kernel Documentation — Scheduler — 커널 공식 스케줄러 문서로 context switch를 유발하는 스케줄링 정책과 메커니즘을 설명합니다
- Kernel Documentation — x86 Architecture — x86 아키텍처별 context switch 구현에 관련된 레지스터, 세그먼트, TSS 등의 공식 문서입니다
- Kernel Documentation — ARM64 Architecture — ARM64 아키텍처의 context switch, TTBR 전환, ASID 관리 관련 공식 문서입니다
- Kernel Documentation — Proper Locking Under a Preemptible Kernel — 선점 가능 커널에서의 올바른 잠금 사용법과 preempt_count의 역할을 설명합니다
- Kernel Documentation — CPU Idle — CPU 유휴 상태(Idle State) 진입 시 context switch와 관련된 전력 관리 메커니즘을 다룹니다
- 커널 소스: kernel/sched/core.c — __schedule(), context_switch() 함수가 구현된 스케줄러 핵심 소스입니다
- 커널 소스: arch/x86/kernel/process_64.c — x86_64의 __switch_to() 함수 구현이 포함되어 있습니다
- 커널 소스: arch/x86/entry/entry_64.S — x86_64 엔트리 포인트와 스택 전환 어셈블리 코드가 구현되어 있습니다
- 커널 소스: arch/x86/kernel/fpu/core.c — FPU/XSTATE 상태 저장 및 복원 로직이 구현되어 있습니다
- 커널 소스: arch/x86/mm/tlb.c — switch_mm_irqs_off()와 TLB 플러시, PCID 관리 코드가 구현되어 있습니다
- 커널 소스: arch/arm64/kernel/process.c — ARM64의 cpu_switch_to()와 __switch_to() 구현입니다
- 커널 소스: arch/riscv/kernel/process.c — RISC-V의 __switch_to() 함수 구현이 포함되어 있습니다
- 커널 소스: kernel/fork.c — copy_thread()를 통한 새 태스크의 초기 커널 스택 설정과 ret_from_fork 준비 과정이 포함되어 있습니다
- Linux commit 58122bf1d856 — x86/fpu: Default eagerfpu=on on all CPUs — Eager FPU 가 모든 CPU 의 기본값이 된 시점과 Lazy 방식 폐지 배경을 커밋 메시지에서 확인할 수 있습니다
- Linux commit 304bceda6a18 — x86, fpu: use non-lazy fpu restore for processors supporting xsave — Eager/Lazy 두 모드가 도입된 원 커밋입니다
- LWN: The x86 context switch — x86 아키텍처에서의 context switch 메커니즘을 상세히 분석하는 기사입니다
- LWN: KAISER — hiding the kernel from user space — KPTI(Kernel Page-Table Isolation)의 도입 배경과 context switch에 미치는 영향을 다룹니다
- LWN: The current state of kernel page-table isolation — Meltdown 대응을 위한 KPTI 구현 세부 사항과 성능 영향을 분석합니다
- LWN: PCID is now a critical performance/security feature — PCID(Process Context Identifier)가 TLB 관리와 context switch 성능에 미치는 영향을 설명합니다
- LWN: The search for a more flexible scheduler — 스케줄러 설계와 context switch 빈도에 영향을 주는 선점 정책 논의입니다
- LWN: Preemption and context switching in the Linux kernel — 커널 선점 모델과 context switch의 관계를 종합적으로 설명합니다
- LWN: Software overhead of context switching — context switch의 직접/간접 비용을 정량적으로 분석한 연구를 소개합니다
- sched_setaffinity(2) — CPU 어피니티 설정을 통한 context switch 및 마이그레이션 제어 매뉴얼입니다
- perf-stat(1) — context-switches, cpu-migrations 등 context switch 관련 하드웨어/소프트웨어 이벤트 측정 도구입니다
- proc(5) — /proc/[pid]/status — voluntary_ctxt_switches와 nonvoluntary_ctxt_switches 카운터를 통한 프로세스별 context switch 모니터링 방법을 설명합니다