
문맥 전환(Context Switching): CPU가 딴짓을 할 때 생기는 엄청난 비용 (완전정복)
CPU 스위칭 비용은 왜 비쌀까요? 캐시 오염, TLB 초기화, 커널 모드, vmstat 튜닝, 그리고 리눅스 커널 내부의 switch_to 매크로까지. 성능 최적화의 끝판왕.

CPU 스위칭 비용은 왜 비쌀까요? 캐시 오염, TLB 초기화, 커널 모드, vmstat 튜닝, 그리고 리눅스 커널 내부의 switch_to 매크로까지. 성능 최적화의 끝판왕.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

몇 년 전, 나는 "내 컴퓨터는 동시에 여러 작업을 한다"고 굳게 믿고 있었다. 유튜브로 노래를 들으면서 VS Code로 코딩하고, 슬랙 알림을 받는 모습을 보면서 "멀티태스킹 천재네" 싶었다. 하지만 운영체제 책을 펼쳤을 때, 나는 처음으로 거대한 환상이 무너지는 경험을 했다.
싱글 코어 CPU는 한 번에 단 하나의 일만 한다. 이게 진짜였다. 동시에 여러 일을 하는 게 아니라, 엄청난 속도로 번갈아가며 조금씩 처리하는 것이었다. 마치 저글링 하는 곡예사가 공을 동시에 들고 있는 것처럼 보이지만, 실제로는 한 손에 하나씩만 쥐고 빠르게 던지고 받는 것처럼 말이다. 이게 바로 시분할 시스템(Time Sharing System)의 정체였다.
그런데 문제는 이 "번갈아가며 처리하는 과정"이 공짜가 아니라는 거였다. 수학 공부를 하다가 친구가 와서 수다를 떨고, 다시 수학 문제로 돌아가려면 "어디까지 풀었더라? 이 공식을 왜 쓰고 있었지?" 하고 머릿속을 다시 로딩하는 시간이 필요하다. 컴퓨터도 똑같았다. CPU가 프로그램 A에서 B로 넘어갈 때, 레지스터를 저장하고 복구하는 비용이 든다. 이걸 문맥 전환(Context Switching) 비용이라고 부르는데, 이게 바로 내가 처음으로 "성능을 갉아먹는 침묵의 살인자"라고 이해했던 개념이었다.
처음엔 "문맥을 저장한다"는 표현이 너무 추상적으로 와닿았다. 대체 무엇을 어디에 저장한다는 건지 막연했다. 내가 직접 CPU 레지스터를 뜯어본 건 아니지만, 인텔 매뉴얼과 리눅스 커널 소스를 읽으면서 "아, 결국 이거였구나" 하고 정리해본 내용을 공유한다.
문맥(Context)은 곧 CPU 레지스터의 상태다. 프로그램이 실행되는 순간, CPU는 다음과 같은 레지스터들을 사용해서 일한다.
Program Counter (PC / EIP / RIP)
가장 중요하다. "다음에 실행할 명령어의 메모리 주소"를 담는다. 만약 이걸 잃어버리면 CPU는 "어디로 돌아가야 하지?" 하고 길을 잃은 미아가 된다. x86 아키텍처에서는 EIP (32비트) 또는 RIP (64비트)라고 부른다.
Stack Pointer (SP / ESP / RSP)
현재 함수의 스택 프레임 위치를 가리킨다. 지역 변수, 함수 인자, 리턴 주소가 모두 이 스택에 쌓인다. push와 pop 명령어가 이 포인터를 조작한다.
General Purpose Registers (EAX, EBX, ECX, EDX, etc.)
int a = 3 + 4; 같은 연산을 하던 도중의 임시 값들이 여기 저장된다. 프로세스가 바뀌면 이전 값은 날아가기 때문에 모두 백업해야 한다.
Status Register (FLAGS / EFLAGS / RFLAGS)
"방금 뺄셈 결과가 0이었음(Zero Flag, ZF)", "오버플로우 발생(Overflow Flag, OF)" 같은 CPU의 상태 정보가 비트 단위로 저장된다. 분기문(if, jmp)이 이 플래그를 보고 점프 여부를 결정한다.
이 귀중한 레지스터 값들은 RAM 어딘가에 안전하게 보관되어야 한다. 운영체제는 이를 위해 커널 메모리 영역에 특별한 자료구조를 만들어둔다.
프로세스 문맥 → PCB (Process Control Block)에 저장 프로세스 ID, 부모 프로세스 ID, 우선순위, 상태(Running/Ready/Blocked), 열린 파일 디스크립터 목록, 메모리 맵 정보 등이 함께 저장된다.
스레드 문맥 → TCB (Thread Control Block)에 저장 스레드는 같은 프로세스 내에서 메모리를 공유하므로, PC와 SP, 레지스터만 별도로 저장하면 된다.
이때 나는 "아, 그래서 스레드가 더 가볍다는 거구나"라고 받아들였다. 메모리 맵 전체를 바꿀 필요 없이 레지스터만 교체하면 되니까.
처음엔 "레지스터 몇 개(수백 바이트) 저장하는 게 뭐가 그리 비싸?" 하고 의아했다. 메모리 쓰기는 나노초(ns) 단위잖아? 그런데 리눅스 성능 튜닝 자료를 읽다가 "Context Switch 10만 번/sec이면 시스템이 거의 죽는다"는 글을 보고 충격을 받았다.
알고 보니 진짜 성능 저하는 레지스터 저장/복구 자체가 아니라, 그로 인한 부수적인 효과(Side Effects)에서 왔다. 이게 내가 가장 크게 이해했던 지점이다.
CPU는 RAM보다 100배 빠른 L1/L2/L3 캐시를 가지고 있다. 프로세스 A가 열심히 돌면서 자기가 자주 쓰는 데이터를 캐시에 따뜻하게 데워놨다고 해보자. 이걸 Warm Cache 상태라고 한다. 캐시 히트율(Cache Hit Rate)이 95%면 메모리 접근이 엄청 빠르다.
그런데 문맥 전환이 일어나 프로세스 B가 CPU를 잡으면?
이걸 Cold Cache 현상이라고 부른다. 캐시가 차가워졌다는 뜻이다. 처음 몇십만 번의 메모리 접근은 거의 다 RAM에서 가져오니까, 마치 페라리를 타다가 갑자기 손수레로 갈아탄 것 같은 기분이다.
나는 이 개념을 받아들이면서, "아, 그래서 서버 튜닝할 때 CPU Affinity(특정 코어에 프로세스를 묶어두는 기법)를 쓰는 거구나" 하고 연결 지었다.
TLB(Translation Lookaside Buffer)는 가상 주소(Virtual Address)를 물리 주소(Physical Address)로 변환해주는 일종의 "캐시"다. 페이지 테이블을 RAM에서 매번 읽으면 너무 느리니까, 최근에 변환한 주소 매핑을 TLB에 저장해둔다.
그런데 프로세스가 바뀌면? 프로세스마다 가상 주소 공간이 다르다. A의 가상 주소 0x1000과 B의 가상 주소 0x1000은 완전히 다른 물리 메모리를 가리킨다. 그래서 TLB를 싹 다 지워버려야(Flush) 한다.
이후 프로세스 B가 메모리에 접근할 때마다, TLB 미스(Miss)가 발생하고, 페이지 테이블을 RAM에서 읽어와야 한다. 이게 수십 사이클씩 잡아먹는다. 나는 이 과정을 "지도를 잃어버린 택시 기사"에 비유했다. 목적지는 아는데, 지도를 다시 펼쳐야 하니까 시간이 오래 걸리는 거다.
스레드 문맥 전환은 TLB Flush가 없다. 같은 프로세스 내 스레드들은 가상 주소 공간을 공유하니까. 이게 바로 "스레드가 프로세스보다 훨씬 가볍다"는 말의 핵심이었다.
이제 "왜 비싼지"를 이해했으니, "어떻게 작동하는지"를 파고들 차례였다. 운영체제 역사를 보면, 문맥 전환은 가장 큰 혁신 중 하나였다.
초기 운영체제는 협력형(Cooperative) 멀티태스킹을 썼다. 프로그램이 자발적으로 "나 다 했어, 이제 다른 프로그램 실행해"라고 양보(Yield)를 해야만 문맥 전환이 일어났다.
문제는 개발자가 코드를 잘못 짜서 while(1) {} 같은 무한 루프를 돌면? 양보를 안 한다. 그러면 다른 프로그램은 CPU를 못 받고, 마우스도 안 움직이고, 컴퓨터 전체가 얼어붙는다(Freeze). Windows 3.1 시절에 흔했던 "Ctrl+Alt+Del도 안 먹히는" 상황이 바로 이거다.
현대 운영체제는 선점형(Preemptive) 멀티태스킹을 쓴다. OS 스케줄러가 독재자처럼 "시간 다 됐어, 비켜!"라고 강제로 CPU를 뺏는다.
이걸 가능하게 하는 건 하드웨어 타이머 인터럽트(Timer Interrupt)다. CPU에는 프로그래머블 인터벌 타이머(PIT)나 APIC 타이머 같은 장치가 있어서, 일정 시간마다(예: 1ms) 인터럽트 신호를 쏜다. 그러면 CPU는 현재 실행 중인 명령어를 중단하고, 커널의 인터럽트 핸들러로 점프한다.
이 핸들러가 바로 스케줄러를 호출하는 거다. 프로그램이 양보를 안 해도 OS가 강제로 CPU를 뺏어오니까, 시스템이 안정적이다. 이 지점에서 나는 "아, 그래서 운영체제가 '운영'하는 거구나" 하고 와닿았다.
문맥 전환은 언제 일어날까? "누군가 멈추라고 할 때" 일어난다. 크게 두 가지 트리거가 있다.
가장 강력하고 비싼 문맥 전환이다. CPU는 즉시 커널 모드로 전환하고, 인터럽트 벡터 테이블(IDT)을 참조해서 핸들러 주소를 찾아 점프한다.
read(), write(), fork() 같은 커널 기능을 요청할 때. x86에서는 int 0x80 또는 syscall 명령어를 쓴다.이 둘을 혼동하면 안 된다.
시스템 콜을 한다고 무조건 문맥 전환이 일어나는 건 아니다. 예를 들어 getpid()는 그냥 커널 메모리에서 PID 값만 읽어서 돌려주면 끝이다. 하지만 read()를 호출했는데 디스크 I/O가 필요하면? 그 프로세스는 Blocked 상태가 되고, 스케줄러가 다른 프로세스로 문맥 전환을 시킨다.
실제 리눅스 커널에서는 어떻게 구현되어 있을까? kernel/sched/core.c의 schedule() 함수가 호출되면, 결국 아키텍처별 switch_to 매크로가 실행된다. 이 부분을 직접 읽으면서 "결국 이거였다" 하고 소름 돋았던 기억이 난다.
sched_yield()개발자가 스스로 CPU를 양보하고 싶을 때 쓰는 시스템 콜이다.
#include <sched.h>
#include <stdio.h>
int main() {
printf("CPU 양보하기 전\n");
sched_yield(); // "나 할 일 없으니까 다른 친구 시켜주세요" (문맥 전환 발생!)
printf("CPU 다시 받음\n");
return 0;
}
컴파일하고 strace로 보면 sched_yield() 시스템 콜이 찍힌다. 커널은 현재 프로세스를 Ready Queue 맨 뒤로 보내고, 다음 프로세스를 선택한다.
switch_to 매크로 (x86_64 개념도)이 부분은 arch/x86/include/asm/switch_to.h에 어셈블리어로 작성되어 있다. 개념적으로 이해하면 이렇다.
# prev: 현재 프로세스 (Process A)
# next: 다음 프로세스 (Process B)
# 1. 현재 프로세스(A)의 레지스터를 스택에 저장
pushq %rbp # 베이스 포인터 저장
pushq %rbx # callee-saved 레지스터 저장
pushq %r12
pushq %r13
pushq %r14
pushq %r15
# 2. 현재 스택 포인터를 A의 TCB에 저장
movq %rsp, prev->sp
# 3. (핵심!) B의 스택 포인터를 CPU로 로드
movq next->sp, %rsp
# 4. B의 레지스터를 스택에서 복구
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
# 5. 점프! 이제부터 B 프로세스가 실행됨
jmp __switch_to # 또는 ret (스택의 리턴 주소로 점프)
핵심은 스택 포인터(%rsp)를 바꿔치기하는 순간, 실행 흐름이 A에서 B로 완전히 넘어간다는 점이다. movq next->sp, %rsp 이 한 줄이 마법 같은 순간이다. 스택이 바뀌면, 이후 pop 명령어는 B의 스택에서 값을 꺼낸다. ret는 B의 리턴 주소로 점프한다. 이제 CPU는 B의 세계에 살고 있다.
나는 이 코드를 읽으면서 "프로그램이란 결국 스택과 PC의 조합이구나" 하고 정리해봤다.
"스위칭 비용이 비싸면, 그냥 스위칭을 안 하면 되잖아?"
인텔의 하이퍼스레딩(Hyper-Threading) 기술, 정확히는 SMT(Simultaneous Multithreading)의 핵심 아이디어가 바로 이거다.
레지스터 세트를 2개 만들어버린다. 물리 코어는 1개지만, 문맥을 저장할 레지스터(PC, SP, General Registers, FLAGS 등)가 하드웨어적으로 2세트 존재한다. 마치 책상은 하나인데, 노트를 두 권 펼쳐놓고 번갈아가며 쓰는 느낌이다.
Thread A가 메모리 로딩(Cache Miss)으로 멈칫하는 순간, CPU는 즉시 0ns 만에 Thread B의 레지스터 세트를 활성화해서 실행한다. 메모리로 문맥을 저장/복구(Save/Restore)하는 과정 자체가 생략되므로, 문맥 전환 비용이 거의 0에 가깝다.
단, ALU나 FPU 같은 실행 유닛(Execution Unit)은 공유한다. 그래서 두 스레드가 모두 CPU를 100% 쓰면 성능 향상은 30~40% 정도에 그친다. 하지만 I/O 대기가 많은 워크로드에서는 거의 2배 가까이 성능이 나온다.
"내 서버가 느려요. 그런데 CPU 사용량은 낮은데요?"
이런 상황이 오면, 문맥 전환을 의심해야 한다. 터미널을 열고 진단해보자.
vmstat 활용법리눅스에서 vmstat 1 명령어를 입력하면 1초마다 시스템 상태를 출력한다.
$ vmstat 1
procs -----------memory---------- ... -system-- ------cpu-----
r b swpd free buff cache ... in cs us sy id wa st
2 0 0 456789 12345 678901 ... 300 12000 5 15 75 5 0
r (runnable): 실행 대기 중인 프로세스 수. CPU 코어 수보다 많으면 과부하.cs (context switches): 초당 문맥 전환 횟수. 10만을 넘어가면 위험 신호다. 스레드가 너무 많거나, I/O 대기가 과도하다는 뜻.us (user CPU): 사용자 프로그램이 쓴 CPU 시간.sy (system CPU): 커널이 쓴 CPU 시간. sy가 us보다 높다면 문맥 전환 과다를 의심해야 한다.내 경험상, 웹 서버에서 cs가 5만을 넘어가면 응답 시간이 눈에 띄게 느려졌다.
스레드 풀 (Thread Pool) 사용
new Thread()를 남발하지 말고, 고정된 크기의 스레드 풀을 써라. Java의 ExecutorService, Python의 ThreadPoolExecutor 등. 스레드가 너무 많으면 문맥 전환 지옥에 빠진다.
CPU Affinity 설정
특정 프로세스를 특정 CPU 코어에 묶어두면, 캐시 지역성(Cache Locality)이 올라간다. 리눅스에서는 taskset 명령어를 쓴다.
taskset -c 0,1 ./my_program # 코어 0, 1번만 사용
프로세스가 계속 같은 코어에서 돌면, L1/L2 캐시가 따뜻하게 유지된다(Warm Cache).
Interrupt Affinity 조정 네트워크 카드(NIC)의 인터럽트를 특정 코어로 몰아주면, 나머지 코어는 방해받지 않고 일할 수 있다.
echo 1 > /proc/irq/30/smp_affinity # 인터럽트 30번을 코어 0으로
switch_to 매크로에서 스택 포인터를 교체하는 마법을 부린다. 이 한 줄로 세상이 바뀐다.vmstat으로 cs 수치를 감시하고, Thread Pool과 CPU Affinity로 최적화해라.나는 이 개념을 이해하고 나서, "성능 문제는 눈에 보이는 코드보다 보이지 않는 시스템 레벨에서 온다"는 교훈을 얻었다. 문맥 전환은 그 첫 번째 수업이었다.