스레드를 누가 스케줄하느냐에 따른 구분.
-
커널 스레드(1:1) — 커널이 안다. 커널이 스케줄한다
- 진짜 병렬 실행 가능. 하나가 블로킹돼도 다른 것은 돈다
- 생성·전환이 비싸다 (시스템 콜)
-
유저 스레드(N:1) — 커널은 모른다. 런타임 라이브러리가 스케줄한다
- 생성·전환이 매우 싸다
- 하나가 블로킹하면 전부 멈춘다. 멀티코어를 못 쓴다
-
M:N — 유저 스레드 M 개를 커널 스레드 N 개에 다중화
- 둘의 장점을 노리지만 구현이 복잡하다
왜 N:1이 블로킹에 약한가
커널 입장에서는 프로세스에 스레드가 하나뿐이다 그 하나가 read() 로 블로킹되면 커널은 프로세스 전체를 재운다
- 런타임이 다른 유저 스레드를 돌리고 싶어도 기회가 없다
현대의 M:N 사례
-
Go 고루틴 — 런타임이 M:N 으로 다중화. 블로킹 시스템 콜 시
- 런타임이 다른 커널 스레드를 붙여 준다
-
자바 가상 스레드 — Java 21. 캐리어 스레드 풀 위에서 다중화
- 블로킹 I/O 시 unmount 되어 캐리어를 놓아 준다
-
둘 다 '블로킹 코드를 그대로 쓰면서 대규모 동시성' 을 노린다
왜 예전에 M:N이 실패했나
1990년대 여러 유닉스가 M:N 을 시도했다가 1:1 로 회귀했다
- 스케줄러가 둘(커널·런타임) 이라 상호작용이 복잡했다
- 시그널·디버깅·우선순위 처리가 어려웠다
지금 다시 되는 이유는 언어 런타임이 I/O 까지 통합 관리하기 때문이다
스택 크기가 만드는 차이
-
OS 스레드 — 스택이 고정 크기로 예약된다 (리눅스 기본 8MB 가상 예약)
- 수만 개를 만들면 가상 주소 공간과 커널 메모리가 부담
- 실제 커밋은 쓰는 만큼이지만 커널 자료구조는 실제로 든다
-
고루틴 — 2KB 로 시작해 필요하면 늘린다(스택 복사·확장) 가상 스레드 스택을 힙에 청크로 둔다. 대기 중이면 힙 객체일 뿐이다
-
'수십만 개 동시성' 이 가능한 근본 이유는 스케줄링보다 스택에 있다
마운트·언마운트
가상 스레드가 블로킹 I/O 를 만나면
- 캐리어 스레드에서 분리(unmount)되고 스택이 힙에 보관된다
- I/O 가 끝나면 아무 캐리어에나 다시 붙는다(mount)
분리가 안 되는 경우 = pinning
-
synchronized 블록 안 (JDK 21~23) — JDK 24 JEP 491 에서 해소
-
네이티브 프레임(JNI) 안
-
pinning 이 나면 캐리어가 묶여 처리량이 급락한다
-
jdk.tracePinnedThreads 로 추적한다
언제 이득이 없나
- CPU 바운드 작업 — 코어 수만큼만 병렬이므로 이득이 없다
- 오히려 전환·스케줄링 오버헤드만 는다
- 스레드 로컬 남용 — 경량 스레드를 수십만 개 만들면 ThreadLocal 이 그만큼 복제된다
- ScopedValue(JEP 506) 로 대체하는 흐름
면접 함정
- ❌ "유저 스레드가 항상 빠르다" → 전환은 싸지만 멀티코어와 블로킹에 약하다.
- ❌ "가상 스레드는 OS 스레드보다 많이 만들 수 있으니 무조건 좋다" → CPU 바운드 작업에는 이득이 없다.