개요
멀티태스킹 운영체제에서 두 개 이상의 작업을 동시에 실행하는 것처럼 보이려면, CPU는 실행 중인 작업을 자주 바꿔야 한다. 이를 **컨텍스트 스위칭(Context Switching)**이라 부른다.
문제는 이 전환이 무료가 아니라는 것이다.
- 프로세스 간 전환: 수 마이크로초 (µs) ~ 수십 마이크로초
- 스레드 간 전환: 수백 나노초 (ns) ~ 수 마이크로초
10배 이상의 차이가 난다. 왜일까?
답은 메모리 주소 공간에 있다.
1. 프로세스와 스레드의 메모리 구조
프로세스의 메모리 격리
프로세스 A와 프로세스 B는 완전히 독립된 가상 주소 공간을 갖는다.
Process A Process B
┌─────────────────────┐ ┌─────────────────────┐
│ Kernel Space │ │ Kernel Space │
│ (공유) │ │ (공유) │
├─────────────────────┤ ├─────────────────────┤
│ Stack │ │ Stack │
│ (0x7fff...) │ │ (0x7fff...) │
├─────────────────────┤ ├─────────────────────┤
│ Heap │ │ Heap │
│ (malloc 영역) │ │ (malloc 영역) │
├─────────────────────┤ ├─────────────────────┤
│ Data Segment │ │ Data Segment │
│ (전역 변수) │ │ (전역 변수) │
├─────────────────────┤ ├─────────────────────┤
│ Code Segment │ │ Code Segment │
│ (0x400000...) │ │ (0x400000...) │
└─────────────────────┘ └─────────────────────┘
같은 주소(예: 0x1000)를 참조해도 실제 메모리 위치가 다르다.
스레드의 메모리 공유
같은 프로세스 내의 스레드들은 Code, Data, Heap을 모두 공유한다. 각 스레드만 자신의 Stack을 갖는다.
Process C (Single Memory Space)
┌──────────────────────────────┐
│ Kernel Space (모든 스레드 공유) │
├──────────────────────────────┤
│ Stack (Thread 1) │
│ ───────────────── │
│ Stack (Thread 2) │
│ ───────────────── │
│ Stack (Thread 3) │
├──────────────────────────────┤
│ Heap (모든 스레드 공유) │
├──────────────────────────────┤
│ Data Segment (공유) │
├──────────────────────────────┤
│ Code Segment (공유) │
└──────────────────────────────┘
스레드 전환 시에는 Stack Pointer와 레지스터만 바꾸면 된다.
2. 컨텍스트 스위칭 비용: 프로세스 vs 스레드
프로세스 전환의 비용
프로세스 A에서 프로세스 B로 전환할 때:
-
CPU 레지스터 저장/복원 (~100 ns)
- Program Counter, Stack Pointer, Accumulator 등 현재 상태 저장
- 새 프로세스의 레지스터 값 로드
-
메모리 관리 장치(MMU) 업데이트 (~수백 ns ~ 수 µs)
- 프로세스 A의 페이지 테이블(Page Table) → 프로세스 B의 페이지 테이블로 교체
- CPU의 CR3 레지스터(x86)에 새로운 페이지 테이블 기본 주소(PDBR) 설정
- 가상 주소 → 물리 주소 매핑 캐시인 TLB(Translation Lookaside Buffer) 무효화(TLB flush)
-
TLB Miss로 인한 후유증 (진짜 비용)
- 프로세스 B가 실행을 시작하면 메모리에 접근할 때마다 TLB 미스 발생
- 각 미스마다 페이지 테이블을 참조해 가상→물리 주소 변환 (수백 ns)
- 특히 초기 워밍업 단계에서 캐시 미스 폭주 (메모리 bandwidth 저하, 1-10% 성능 저하 관측)
프로세스 A 실행 중 프로세스 B로 전환 프로세스 B 실행 시작
┌─────────────┐ ┌──────────────┐ ┌────────────────────┐
│ TLB (warm) │ ─flush→ │ TLB (empty) │ ─misses→ │ TLB (재 warm까지 시간)│
│ [A의 매핑] │ │ │ │ │
└─────────────┘ └──────────────┘ └────────────────────┘
빠름 비용 발생 느림 (초반)
스레드 전환의 비용
같은 프로세스 내의 스레드 A에서 스레드 B로 전환할 때:
- CPU 레지스터 저장/복원 (~100 ns)
메모리 관리 장치 업데이트— 필요 없음!- 페이지 테이블은 같음 (같은 프로세스)
- TLB flush 불필요
- 가상 주소 → 물리 주소 매핑은 여전히 유효
메모리 주소 공간이 같으므로 단순히 Stack Pointer와 레지스터 포인터만 교체하면 된다.
3. Linux 커널에서 본 프로세스와 스레드
Linux 커널은 프로세스와 스레드를 구분하지 않는다. 둘 다 task_struct라는 동일한 구조체로 관리하며, clone() 시스템콜의 플래그로 어떤 자원을 공유할지 결정할 뿐이다.
clone() 플래그
#include <sched.h>
// 프로세스 생성 (fork()의 내부 구현)
clone(fn, child_stack,
SIGCHLD, // 부모 프로세스 신호 핸들러만 사용
NULL);
// 스레드 생성 (pthread_create()의 내부 구현)
clone(fn, child_stack,
CLONE_VM | // 메모리 공간 공유
CLONE_FS | // 파일 시스템 정보 공유
CLONE_FILES | // 파일 디스크립터 테이블 공유
CLONE_SIGHAND | // 신호 핸들러 공유
CLONE_THREAD, // 같은 스레드 그룹
NULL);
| 플래그 | 의미 | 프로세스 | 스레드 |
|---|---|---|---|
| CLONE_VM | 메모리 공간 공유 | ✗ | ✓ |
| CLONE_FS | 현재 디렉토리 공유 | ✗ | ✓ |
| CLONE_FILES | 파일 디스크립터 공유 | ✗ | ✓ |
| CLONE_SIGHAND | 신호 핸들러 공유 | ✗ | ✓ |
| CLONE_THREAD | 스레드 그룹 | ✗ | ✓ |
4. 메모리 계산: C10K 문제
C10K 문제: 동시에 10,000개 클라이언트 연결을 처리할 수 있는가?
스레드 기반 접근의 한계
한 스레드가 얼마나 메모리를 먹는가?
Stack 크기: 8 MB (x86-64 리눅스 기본값)
× 10,000개 스레드
──────────────────
= 80 GB
80GB는 단일 서버의 메모리로는 불가능하다. 실제로는 14GB의 서버에서 수십수백 개 스레드만 만들 수 있다.
메모리 10 GB (실제 서버)
÷ 스레드당 8 MB Stack
────────────────────
≈ 1,250 스레드 (이론적 최대)
실제로는 추가 메모리(Heap, TLS)를 고려하면 더 적음
이벤트 루프 기반 접근
대안은 컨텍스트 스위칭 자체를 최소화하는 것이다.
[Single Thread / Event Loop]
┌──────────────────────────────────┐
│ while true { │
│ events = epoll_wait() │ ← 10,000개 FD 모니터링
│ for event in events { │
│ process(event) │
│ } │
│ } │
└──────────────────────────────────┘
- 스레드 수: 1개 (또는 CPU 코어 수)
- 메모리: 수 MB
- 컨텍스트 스위칭: 거의 없음
epoll()은 커널이 관심 있는 10,000개의 파일 디스크립터를 추적하고, 실제로 이벤트가 발생한 것만 돌려준다.
5. 스케줄러의 역할: CFS (Completely Fair Scheduler)
Linux의 기본 스케줄러 CFS는 “모든 태스크에게 공정한 CPU 시간을 준다”는 원칙에 따라 동작한다.
런큐 (Run Queue)
CPU 0의 런큐
┌─────────────────────────────┐
│ [Task A] [Task B] [Task C] │ ← ready 상태의 모든 태스크
└─────────────────────────────┘
↓
CFS 선택
↓
현재 Task A 실행 (time slice: 몇 ms)
↓
컨텍스트 스위칭 → Task B
스케줄링 비용
- 런큐에 태스크가 적을수록 (프로세스 수가 적을수록) 선택이 빠르다
- 태스크가 많을수록 (100+ 프로세스) 스케줄러 선택 오버헤드 증가
- 멀티 코어에서는 각 코어가 독립 런큐를 가짐 (로드 밸런싱 별도)
6. 실무에서의 선택 기준
멀티프로세스 (Process-based)
장점:
- 메모리 격리: 한 프로세스 크래시가 다른 프로세스에 영향 없음
- 보안: 프로세스 간 메모리 접근 불가
단점:
- 컨텍스트 스위칭 비용 (TLB flush)
- IPC (Pipe, Socket, Shared Memory) 오버헤드
- 프로세스 생성 비용
사용 사례:
- 웹 서버 (nginx, Apache): Worker process 수 = CPU 코어 수
- 데이터베이스 (PostgreSQL): 클라이언트당 1 프로세스
# nginx 설정
worker_processes auto; # CPU 코어 수와 동일하게 설정
# 각 worker가 epoll로 수천 개 연결 처리
# (프로세스 수는 적지만, 연결 수는 많음)
멀티스레드 (Thread-based)
장점:
- 컨텍스트 스위칭 비용 저렴
- 메모리 공유로 IPC 간단
- 공유 메모리를 통한 빠른 데이터 교환
단점:
- 메모리 격리 없음: 한 스레드의 메모리 손상이 전체 프로세스 영향
- Race condition, Deadlock 위험
- 동기화(Lock) 오버헤드
사용 사례:
- Java/Spring: ThreadPool 기반 요청 처리
- Python/Ruby: GIL 고려한 I/O 바운드 작업
// Spring에서의 스레드풀 기본값 (Tomcat)
ThreadPool size = CPU 코어 수 (또는 수십~수백)
요청당 1 스레드 할당
이벤트 루프 (Event-driven)
장점:
- 컨텍스트 스위칭 최소화
- 높은 동시 연결 수 처리 가능
- 메모리 효율적
단점:
- CPU 바운드 작업에 부적합 (이벤트 루프를 블로킹)
- 디버깅 어려움 (콜백 지옥, 스택 추적 복잡)
사용 사례:
- Node.js (libuv 기반 이벤트 루프)
- Redis (Single-threaded event loop)
- Nginx (Worker process × epoll)
// Node.js: 단일 이벤트 루프
const server = http.createServer((req, res) => {
// 이 콜백이 블로킹되면 다른 요청 처리 불가
res.end('OK');
});
// CPU 바운드 작업은 Worker Threads로 분리
const { Worker } = require('worker_threads');
const worker = new Worker('./cpu-intensive-task.js');
7. 성능 측정: 실제 컨텍스트 스위칭 비용
측정 도구
Linux에서 프로세스/스레드 간 컨텍스트 스위칭 비용을 측정하는 방법:
# 1. vmstat로 컨텍스트 스위칭 횟수 모니터링
vmstat 1 # 1초마다 갱신
# 출력:
# cs = context switches (초당 컨텍스트 스위칭 횟수)
# in = interrupts (초당 인터럽트 횟수)
# 2. perf로 캐시 미스 측정
perf stat -e cache-references,cache-misses ./program
# 3. 직접 측정 (C 코드)
벤치마크 예제
#include <time.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// 프로세스/스레드 생성 + 컨텍스트 스위칭 시간 측정
int main() {
struct timespec start, end;
double elapsed_ns;
// 프로세스 생성 + fork()
clock_gettime(CLOCK_REALTIME, &start);
for (int i = 0; i < 1000; i++) {
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스
exit(0);
} else {
// 부모 프로세스
wait(NULL);
}
}
clock_gettime(CLOCK_REALTIME, &end);
elapsed_ns = (end.tv_sec - start.tv_sec) * 1e9 +
(end.tv_nsec - start.tv_nsec);
printf("1000 × fork() + wait: %.2f ns/operation\n",
elapsed_ns / 1000);
return 0;
}
전형적인 결과 (x86-64, 4GHz CPU, 무부하 상황):
- 프로세스 생성 + 컨텍스트 스위칭: 5-50 µs
- 스레드 생성 + 컨텍스트 스위칭: 0.5-5 µs
- 같은 프로세스 내 스레드 전환: 0.1-1 µs
8. 트레이드오프 정리
| 측면 | 멀티프로세스 | 멀티스레드 | 이벤트루프 |
|---|---|---|---|
| 컨텍스트 스위칭 | 높음 (µs) | 중간 (ns-µs) | 최소 |
| 메모리/연결 | 크다 (MB/프로세스) | 중간 (KB/스레드) | 최소 (KB) |
| 동시 연결 수 | 수십~수백 | 수백~수천 | 수만~수십만 |
| 격리 (안정성) | 완벽 | 취약 | 중간 |
| 개발 복잡도 | 단순 | 복잡 (동기화) | 복잡 (비동기) |
| CPU 활용 | 좋음 (멀티코어) | 좋음 (멀티코어) | CPU-bound에 부적합 |
결론
- 프로세스 전환 비용이 비싼 이유: TLB flush로 인한 메모리 주소 재매핑 캐시 미스
- 스레드 전환이 싼 이유: 메모리 공간 동일 → TLB 무효화 불필요
- C10K 해결 방식: 프로세스/스레드 수 자체를 늘리지 말고, 이벤트 루프로 컨텍스트 스위칭 최소화
어떤 방식을 선택할지는 동시 연결 수, 격리 요구사항, CPU 바운드 vs I/O 바운드 특성에 따라 결정된다.