스레드 구현 — 유저 레벨, 커널 레벨, 하이브리드(M:N)
스레드에도 종류가 있습니다. 커널이 관리하는 스레드와 사용자 공간 라이브러리가 관리하는 스레드는 성능 특성이 크게 다릅니다. Java 21의 Virtual Thread가 왜 혁신적인지 이해하려면 이 차이를 알아야 합니다.
세 가지 스레드 모델
┌──────────────────────────────────────────────┐
│ 1:1 (커널 레벨) N:1 (유저 레벨) M:N (하이브리드) │
│ │
│ U1 U2 U3 U1 U2 U3 U1 U2 U3 U4 │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ └──┴──┘ └──┴──┘ │ │
│ K1 K2 K3 K1 K1 K2 │
│ │
│ 유저 스레드마다 모든 유저 스레드가 M개 유저 스레드가│
│ 커널 스레드 1개 커널 스레드 1개에 N개 커널 스레드에│
└──────────────────────────────────────────────┘
1:1 모델 — 커널 레벨 스레드
각 유저 스레드가 커널 스레드에 1:1 매핑 됩니다.
유저 공간: Thread A Thread B Thread C
│ │ │
│ │ │
커널 공간: KThread A KThread B KThread C
│ │ │
CPU 코어 CPU 코어 CPU 코어
- 스레드 생성/관리를 커널이 직접 수행
- 시스템 콜로 스레드를 생성 (Linux:
clone(), Windows:CreateThread())
장점:
- 멀티코어 활용 가능 — 커널이 각 스레드를 다른 코어에 스케줄링
- 하나의 스레드가 블로킹 I/O 해도 다른 스레드는 계속 실행
단점:
- 스레드 생성/전환 비용이 큼 — 시스템 콜 필요
- 커널 메모리 사용 (스레드당 커널 스택 약 8KB)
- 수천 개 이상의 스레드는 무거워짐
대표 구현:
- Linux NPTL (Native POSIX Threads Library)
- Java Platform Thread (OS 스레드와 1:1)
- Windows Thread
N:1 모델 — 유저 레벨 스레드
모든 유저 스레드가 하나의 커널 스레드에 매핑 됩니다.
유저 공간: Thread A Thread B Thread C
│ │ │
┌───┴───────────┴───────────┘
│ 유저 공간 스케줄러 (라이브러리)
│
커널 공간: KThread 1 (커널은 스레드 1개만 인식)
- 스레드 관리를 사용자 공간 라이브러리 가 수행
- 커널은 이 프로세스에 스레드가 여러 개인지 모름
장점:
- 스레드 전환이 매우 빠름 — 시스템 콜 불필요, 라이브러리 함수 호출
- 커널 수정 없이 스레드 라이브러리만으로 구현 가능
- 스레드당 메모리 사용 적음
단점:
- 멀티코어 활용 불가 — 커널 입장에서는 프로세스 하나
- 하나가 블록되면 전체 블록 — 커널 스레드가 1개이므로
대표 구현:
- GNU Pth (POSIX Thread 라이브러리)
- 초기 Java Green Thread (Java 1.1)
- Ruby 1.8 이전의 Thread
M:N 모델 — 하이브리드
M개의 유저 스레드를 N개의 커널 스레드에 매핑 (M > N)합니다.
유저 공간: Thread1 Thread2 Thread3 Thread4 Thread5
│ │ │ │ │
┌───┴───────┴────────┴────────┴────────┘
│ 유저 공간 스케줄러 (런타임)
│
커널 공간: KThread1 KThread2
│ │
CPU 코어 CPU 코어
유저 공간 스케줄러가 유저 스레드를 커널 스레드에 동적으로 배분합니다.
장점:
- 멀티코어 활용 가능
- 유저 스레드 전환은 빠름 (유저 공간에서 처리)
- 블로킹 발생 시 해당 커널 스레드만 블록 → 다른 유저 스레드는 다른 커널 스레드로
단점:
- 구현이 매우 복잡 — 유저 스케줄러 + 커널 스케줄러 2단계
- 디버깅이 어려움
대표 구현:
- Go의 goroutine (가장 성공적인 M:N 구현)
- Java 21 Virtual Thread (M:N 모델 기반)
- Erlang 프로세스
- Kotlin Coroutine (디스패처를 통한 M:N)
Go goroutine의 M:N 모델
Go는 M:N 모델의 가장 대표적인 성공 사례입니다.
Go 런타임의 GMP 모델:
G = Goroutine (유저 레벨 스레드, 초기 스택 2KB)
M = Machine (OS 스레드, 커널 스레드)
P = Processor (논리적 프로세서, GOMAXPROCS로 설정)
G1 G2 G3 G4 ... (수만~수백만 개)
│ │ │ │
└──┴──┴──┘
│
P1 P2 (GOMAXPROCS 개)
│ │
M1 M2 (OS 스레드)
- goroutine은 초기 스택 2KB (OS 스레드는 보통 1~8MB)
- 수십만 개의 goroutine을 생성해도 문제없음
- 블로킹 I/O 시 해당 M만 블록되고, P는 다른 M에 연결
Java Virtual Thread
Java 21에서 도입된 Virtual Thread는 사실상 M:N 모델입니다.
// 기존 Platform Thread (1:1, OS 스레드)
Thread thread = new Thread(() -> {
// 스레드당 약 1MB 스택
});
// Virtual Thread (M:N)
Thread vThread = Thread.ofVirtual().start(() -> {
// 가벼운 스택 (수 KB)
// 블로킹 I/O 시 자동으로 carrier thread에서 unmount
});
| 항목 | Platform Thread | Virtual Thread |
|---|---|---|
| 모델 | 1:1 | M:N |
| 스택 크기 | ~1MB | ~수 KB |
| 생성 가능 수 | 수천 개 | 수백만 개 |
| 블로킹 시 | OS 스레드 블록 | carrier에서 분리 |
| 적합한 상황 | CPU 집약적 작업 | I/O 집약적 작업 |
비교 정리
| 모델 | 비율 | 멀티코어 | 블로킹 | 전환 비용 | 복잡도 |
|---|---|---|---|---|---|
| 1:1 | 유저:커널 = 1:1 | O | 독립적 | 높음 | 낮음 |
| N:1 | 유저:커널 = N:1 | X | 전체 블록 | 낮음 | 중간 |
| M:N | 유저:커널 = M:N | O | 독립적 | 낮음 | 높음 |
핵심 포인트
- Java Virtual Thread가 해결하는 문제: 기존 1:1 모델에서 스레드 수천 개가 한계 → 수백만 개의 동시 I/O 처리 가능
- Go goroutine이 빠른 이유: 2KB 스택, 유저 공간 스케줄링, 시스템 콜 없는 전환
- N:1 모델의 치명적 단점: 블로킹 I/O 하나로 전체 스레드 중단
- C10K/C10M 문제와 스레드 모델: 1:1 모델로는 수만 연결 처리가 어려움 → M:N이나 이벤트 루프 필요
정리
스레드 모델은 성능 vs 구현 복잡도 의 트레이드오프입니다. 1:1은 단순하지만 무겁고, N:1은 가볍지만 멀티코어를 못 쓰고, M:N은 둘 다 잡지만 구현이 어렵습니다. Go와 Java 21이 M:N 모델을 성공적으로 구현하면서, 대량 동시성이 필요한 서버 프로그래밍의 기본 선택지가 되고 있습니다.