컨텍스트 스위칭
CPU가 실행 흐름을 갈아타는 방식과 비용
CPU는 한 순간에 단 하나의 명령어 흐름만 실행할 수 있지만, OS가 매우 빠르게 실행 흐름을 교체해 여러 작업이 동시에 도는 것처럼 만든다. 이 교체가 컨텍스트 스위칭이며, 타이머 인터럽트마다 현재 상태를 저장하고 다음 흐름의 상태를 복원해 실행을 재개한다. 스레드 간 전환은 페이지 테이블과 TLB를 유지한 채 일어나 저렴하지만, 프로세스 간 전환은 TLB 플러시와 캐시 미스를 동반해 훨씬 비싸다.
개념
CPU 코어(정확히는 하드웨어 스레드)는 한 순간에 단 하나의 명령어 흐름만 실행할 수 있다. 그런데도 크롬, 슬랙, 스포티파이가 동시에 도는 것처럼 보이는 이유는 OS가 매우 빠르게 실행 흐름을 교체하기 때문이다. 이 교체 과정이 컨텍스트 스위칭이다. CPU의 현재 상태를 메모리에 스냅샷으로 저장하고, 다음에 돌릴 흐름의 스냅샷을 꺼내 CPU에 덮어쓴 뒤, 그 흐름이 멈췄던 위치부터 실행을 재개한다. 사람 눈엔 동시에 돌아 보이지만 실제로는 아주 짧은 간격으로 순차 실행되는 착시다.
배경
프로그램이 많이 돌아도 CPU 코어는 한정되어 있다. 1코어 컴퓨터에서 수백 개의 프로세스가 동시에 동작하는 것처럼 보이게 하려면 OS가 일종의 속임수를 써야 한다. 그 속임수가 시분할(time slicing)이고, 시분할을 실제로 구현해주는 하드웨어·소프트웨어 메커니즘이 컨텍스트 스위칭이다.
컨텍스트란 무엇인가
"지금 이 순간 CPU가 무엇을 하고 있는지"를 전부 담은 상태 스냅샷이다. CPU 안에는 다음 값들이 들어 있다.
- Program Counter — 지금 실행 중인 명령어 주소
- 범용 레지스터들
- 스택 포인터
- 상태 플래그
이 값들의 묶음이 컨텍스트다. 스레드 하나가 멈추면 그 시점의 컨텍스트를 메모리의 TCB(Thread Control Block)라는 저장 영역에 베껴둔다. 나중에 그 스레드가 다시 돌 차례가 되면 TCB의 값을 CPU 레지스터로 다시 로드한다.
스위칭 절차
스레드 A에서 스레드 B로 넘어간다고 할 때:
- CPU의 현재 레지스터 값들을 A의 TCB로 복사 (A의 스냅샷 저장)
- B의 TCB에서 저장돼 있던 값들을 CPU 레지스터로 로드 (B의 스냅샷 복원)
- CPU가 B의 Program Counter가 가리키는 주소부터 실행 재개
B는 자기가 멈췄던 정확한 지점에서, 레지스터 값까지 그대로 복원된 상태로 돌아와 아무 일도 없었다는 듯 이어진다.
누가 언제 전환시키나
CPU는 스스로 "이제 다른 거 할까" 같은 판단을 못 한다. 전환 타이밍은 외부에서 주어진다. 가장 흔한 계기는 타이머 인터럽트다. 하드웨어 타이머가 일정 간격(보통 수 밀리초)마다 신호를 보내면, CPU는 하던 일을 멈추고 커널 코드로 강제 점프한다.
커널로 들어간 뒤 실행되는 것이 스케줄러다. 스케줄러는 여러 스레드 중 다음에 돌릴 놈 하나를 고르고, 위의 절차대로 스위칭을 수행한다. 이 과정이 1초에 수백~수천 번 일어난다.
I/O 대기(파일 읽기, 소켓 수신 등)로 스레드가 자발적으로 멈추는 경우에도 스위칭이 일어난다. 할 일이 없어진 스레드는 스케줄러가 다른 스레드를 그 자리에 앉힌다.
가상 메모리와 TLB
여기서부터가 프로세스 스위칭이 왜 비싼지 이해하는 핵심이다.
각 프로세스는 자기만의 가상 주소 공간을 가진다. 크롬이 "주소 1000번"이라고 말해도 실제 물리 RAM의 1000번이 아니다. OS가 프로세스별로 페이지 테이블이라는 번역 표를 유지해서 "크롬의 가상 1000 → 물리 50000", "슬랙의 가상 1000 → 물리 80000" 같은 식으로 번역한다.
문제는 페이지 테이블 자체가 RAM에 있다는 점이다. 메모리를 한 번 읽으려면:
- 페이지 테이블 조회 (RAM 접근 1회)
- 실제 데이터 읽기 (RAM 접근 1회)
모든 메모리 접근에 RAM을 두 번 찔러야 한다. 너무 느리다.
이걸 해결하려고 CPU 안에 번역 결과를 캐싱하는 작은 초고속 저장소를 뒀다. 이것이 TLB (Translation Lookaside Buffer) 다. TLB는 최근에 쓴 번역 규칙들을 들고 있어서, 같은 가상 주소를 다시 찾을 때 페이지 테이블까지 내려가지 않고 즉시 답한다. 평소 TLB 적중률은 99% 이상이다.
TLB는 데이터를 캐싱하는 게 아니라 주소 번역 규칙을 캐싱한다는 점에 주의. 데이터 캐싱은 CPU의 L1/L2/L3 캐시가 따로 담당한다.
왜 TLB가 이득인가
TLB는 프로세스 스위칭 때 대부분 플러시된다 (번역 규칙이 바뀌므로). "어차피 버릴 건데 왜 만들어?"라는 의문이 자연스럽다.
답은 스위칭 사이에 일어나는 일이 어마어마하기 때문이다. 타이머 인터럽트 간격이 10ms라면 그 사이에 CPU는 대략 3천만 개의 명령어를 실행하고 그 중 천만 개 가까이가 메모리 접근이다. TLB가 없다면 이 천만 번 전부 페이지 테이블 조회가 추가로 들어간다. TLB가 있으면 99%는 즉시 답이 나오고 1%만 페이지 테이블까지 내려간다.
10ms 동안의 이득이 스위칭 시 플러시 + 재학습 비용보다 압도적으로 크다. "잠깐 쓰고 버려도, 쓰는 동안의 수익이 버리는 비용보다 크다면 이익" — 캐시라는 개념 전체를 관통하는 논리다. DB 버퍼 풀, 브라우저 캐시, CDN도 전부 같은 원리로 정당화된다.
프로세스 스위칭 vs 스레드 스위칭
이제 두 스위칭의 비용 차이가 자연스럽게 설명된다.
스레드 스위칭 (같은 프로세스 안)
- 같은 페이지 테이블을 공유 → TLB 유지 (플러시 안 함)
- 같은 코드와 데이터를 어느 정도 공유 → CPU 캐시도 대체로 유지
- 레지스터 값만 저장/복원
- 대략 수 μs 내외
프로세스 스위칭
- 페이지 테이블이 바뀜 → TLB 플러시 필요
- 새 프로세스는 다른 코드·데이터를 읽음 → CPU 캐시가 사실상 무의미 (캐시 미스 폭풍)
- 레지스터 저장/복원은 똑같이 필요
- 스위칭 직후 한참 동안 TLB와 캐시를 다시 데우는 오버헤드
- 대략 수십~수백 μs, 경우에 따라 훨씬 더
이 차이가 "프로세스는 무겁고 스레드는 가볍다"의 기술적 근거다. Nginx, Node.js, Go 런타임처럼 동시 연결을 많이 처리하는 서버가 요청마다 프로세스를 띄우지 않고 스레드나 이벤트 루프로 가는 이유도 여기에 있다 — 스위칭 비용을 아껴야 높은 동시성을 감당할 수 있다.
더 보기
- 프로세스와-스레드 — 이 노트의 전제가 되는 실행 단위 개념