icon

안동민 개발노트

4장 : 스레드

멀티스레딩 모델


스레드를 누가 관리하느냐에 따라 구현이 완전히 달라집니다. 사용자 공간의 라이브러리가 스레드를 관리할 수도 있고, 커널이 직접 관리할 수도 있습니다. 그리고 이 두 수준의 스레드가 어떻게 매핑되느냐에 따라 멀티스레딩 모델이 결정됩니다. 이 선택은 성능, 동시성, 프로그래밍 모델 전체에 영향을 미칩니다.


사용자 수준 스레드 vs 커널 수준 스레드

사용자 수준 스레드 (User-Level Thread)

사용자 수준 스레드는 커널의 도움 없이, 사용자 공간의 스레드 라이브러리가 생성·전환·스케줄링을 모두 처리합니다. 커널은 이 스레드의 존재를 전혀 모릅니다. 커널의 관점에서는 그냥 "하나의 프로세스"일 뿐입니다.

장점은 명확합니다. 스레드 전환이 시스템 콜 없이 사용자 모드에서 이루어지므로 레지스터 저장/복원 + 스택 포인터 변경만으로 완료됩니다. 커널 모드 전환(mode switch)이 없어 기존 함수 호출 수준의 비용으로 스레드를 전환할 수 있습니다. 구체적으로 사용자 수준 스레드 전환은 수십 나노초, 커널 수준 스레드 전환은 수백 나노초~수 마이크로초입니다.

하지만 치명적인 단점이 있습니다. 커널은 프로세스 안의 스레드를 모르므로, 하나의 사용자 스레드가 read() 같은 블로킹 시스템 콜을 호출하면 커널은 전체 프로세스를 블로킹합니다. 같은 프로세스 안에서 I/O를 기다리지 않는 다른 스레드들도 전부 멈추게 됩니다. 또한 커널이 스레드를 모르기 때문에, 멀티코어 CPU에서 같은 프로세스의 스레드들을 여러 코어에 배치할 수 없습니다.

이 두 한계는 실질적으로 사용자 수준 스레드만으로는 진정한 병렬성을 달성할 수 없다는 것을 의미합니다.

커널 수준 스레드 (Kernel-Level Thread)

커널 수준 스레드는 운영체제 커널이 직접 생성·관리·스케줄링하는 스레드입니다. 커널은 각 스레드를 독립적인 스케줄링 단위로 인식합니다.

핵심 장점은 독립적 스케줄링입니다. 스레드 A가 블로킹 시스템 콜을 호출해도, 커널은 스레드 B를 실행할 수 있습니다. 멀티코어 CPU에서 스레드를 서로 다른 코어에 배치하여 실제 병렬 실행이 가능합니다.

단점은 스레드 생성과 전환에 시스템 콜이 필요하다는 것입니다. clone() 시스템 콜(Linux), 커널 자료구조 할당, 스케줄러 큐 삽입 등의 오버헤드가 발생합니다.

특성사용자 수준커널 수준
관리 주체사용자 라이브러리커널
생성/전환 비용매우 낮음 (함수 호출 수준)상대적으로 높음 (시스템 콜)
블로킹 시스템 콜전체 프로세스 블로킹해당 스레드만 블로킹
멀티코어 활용불가능가능
커널 인식인식하지 못함각 스레드를 독립 인식

Linux에서의 구현: clone()과 task_struct

Linux에서 스레드는 사실 자원을 공유하는 프로세스입니다. pthread_create()를 호출하면 내부적으로 clone() 시스템 콜이 호출되고, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND 플래그로 메모리, 파일 시스템, 파일 디스크립터, 시그널 핸들러를 공유합니다. 커널 내부에서 각 스레드는 별도의 task_struct를 가지지만, 이들이 같은 mm_struct(메모리 관리), 같은 files_struct(파일 테이블)를 공유하는 구조입니다.

이 설계 때문에 Linux에서 ps -eLf를 실행하면 스레드가 별도의 LWP(Light Weight Process)로 나타납니다. /proc/<PID>/task/ 디렉터리에서 각 스레드의 정보를 확인할 수 있습니다.


매핑 모델

사용자 수준 스레드(ULT)와 커널 수준 스레드(KLT)의 매핑 방식에 따라 세 가지 모델이 존재합니다.

다대일 (Many-to-One)

여러 사용자 스레드가 하나의 커널 스레드에 매핑됩니다. 스레드 관리가 전부 사용자 공간에서 이루어지므로, 스레드 라이브러리가 스케줄링을 담당합니다.

장점은 스레드 전환이 빠르고, 스레드 라이브러리를 자유롭게 구현할 수 있다는 것입니다.

단점은 앞서 설명한 사용자 수준 스레드의 한계를 그대로 가집니다. 하나의 스레드가 블로킹되면 전체가 멈추고(커널 스레드가 하나뿐이므로), 멀티코어 활용이 불가능합니다.

초기 Java의 그린 스레드, Solaris 2의 초기 구현이 이 모델이었습니다. 현재는 거의 사용되지 않습니다.

일대일 (One-to-One)

각 사용자 스레드가 별도의 커널 스레드에 매핑됩니다. 현대 대부분의 운영체제가 이 모델을 사용합니다.

Linux의 pthread, Windows의 Win32 스레드, Java의 네이티브 스레드(J2SE 1.3 이후)가 일대일 모델입니다.

블로킹 문제가 없습니다. 스레드 A가 read()를 호출해 블로킹되면, 커널은 스레드 A의 커널 스레드만 대기 상태로 바꾸고 스레드 B의 커널 스레드를 실행합니다. 멀티코어에서 진정한 병렬 실행이 가능합니다.

단점은 사용자 스레드를 만들 때마다 커널 스레드가 생성되므로, 수만 개의 스레드를 만들면 커널 리소스(스택, 스케줄러 큐 항목)가 부족해질 수 있습니다. Linux에서 기본 스레드 스택 크기가 8MB이므로, 스레드 1만 개면 스택만으로 80GB의 가상 메모리가 필요합니다(물리 메모리는 사용 시에만 할당되지만 주소 공간은 소비). 이것이 10K 문제(C10K Problem)의 하나의 원인입니다.

다대다 (Many-to-Many)

M개의 사용자 스레드를 N개의 커널 스레드에 매핑합니다(M ≥ N). 사용자 공간에서 스레드를 자유롭게 생성하되, 커널 스레드 수를 제한하여 리소스를 절약합니다.

Solaris의 LWP(Light Weight Process) 모델이 대표적이었습니다. 사용자 프로세스가 커널 스레드 풀 크기를 조절할 수 있었고, 사용자 라이브러리가 사용자 스레드를 LWP 위에 다중화(multiplex)했습니다.

실제 동작을 살펴보겠습니다. 사용자 스레드 10개, 커널 스레드 4개인 경우: 4개의 커널 스레드가 4개의 코어에서 병렬 실행됩니다. 사용자 스레드 중 하나가 블로킹되면, 그 커널 스레드가 대기하는 동안 다른 사용자 스레드가 남은 커널 스레드에 매핑됩니다.

이론적으로 이상적이지만, 구현이 매우 복잡합니다. 사용자 수준 스케줄러와 커널 스케줄러의 조정, 블로킹 감지 및 커널 스레드 우회(upcall), LWP 풀 크기 조절 등의 난제가 있습니다. 결국 Linux, Windows 등 주류 운영체제는 일대일 모델을 선택했고, 다대다의 유연성은 언어 런타임 수준에서 구현하게 되었습니다. 그것이 바로 고루틴과 가상 스레드입니다.


현대적 경량 스레드: 그린 스레드에서 가상 스레드까지

일대일 모델은 단순하고 강력하지만, 스레드 하나당 커널 스레드 하나씩 소비하므로 수십만 개의 동시 작업이 필요한 상황에서는 비효율적입니다. 이를 극복하기 위해 언어 런타임이 다대다 모델을 구현하는 접근법이 발전해 왔습니다.

그린 스레드 (Green Thread)

JVM 1.0~1.2에서 사용한 사용자 수준 스레드입니다. Green은 Sun Microsystems의 Green Team에서 유래합니다. JVM이 자체 스케줄러로 Java 스레드를 관리했고, 커널 스레드를 사용하지 않았습니다.

커널을 거치지 않으므로 생성·전환이 빨랐지만, 다대일 모델의 한계를 그대로 가졌습니다. 하나의 스레드가 블로킹 I/O를 하면 전체 JVM이 멈추고, 멀티코어 CPU에서 병렬 실행이 불가능했습니다. 결국 Java 1.3부터 플랫폼에 따라 네이티브 스레드(일대일 모델)로 전환되었습니다.

코루틴 (Coroutine)

코루틴은 함수가 실행 도중 자발적으로 양보(yield)하고, 나중에 양보한 지점부터 재개(resume)할 수 있는 구조입니다. 일반 함수는 호출하면 처음부터 끝까지 실행되지만, 코루틴은 중간에 멈추고 다른 코루틴에게 제어를 넘깁니다.

OS 스레드와의 핵심 차이: OS 스레드는 선점형(Preemptive)으로 OS가 타이머 인터럽트로 강제 전환하지만, 코루틴은 협력형(Cooperative)으로 코루틴 자신이 yield를 호출해야 전환됩니다. 따라서 하나의 코루틴이 CPU를 독점하면 다른 코루틴이 실행되지 못합니다.

Python async/await 예시
import asyncio

async def fetch_user(user_id):
    print(f"사용자 {user_id} 조회 시작")
    await asyncio.sleep(1)  # I/O 대기 → 이 시점에서 다른 코루틴으로 전환
    print(f"사용자 {user_id} 조회 완료")
    return {"id": user_id, "name": f"User{user_id}"}

async def main():
    # 3개의 코루틴을 동시에 실행 (하나의 OS 스레드에서!)
    results = await asyncio.gather(
        fetch_user(1),
        fetch_user(2),
        fetch_user(3),
    )
    print(f"결과: {results}")

asyncio.run(main())

이 코드에서 await asyncio.sleep(1)이 호출되면, 현재 코루틴은 양보하고 이벤트 루프가 다른 코루틴을 실행합니다. 3개의 코루틴이 각각 1초씩 기다리지만, 동시에 대기하므로 전체 소요 시간은 약 1초입니다. OS 스레드 하나에서 이 모든 것이 동작합니다.

Kotlin의 코루틴은 한 단계 더 나아가, 코루틴을 여러 스레드에 분산(dispatch)할 수 있는 Dispatchers.Default를 제공합니다. 이렇게 하면 협력형 + 다대다 모델이 됩니다.

가상 스레드 (Virtual Thread) — Java 21

Java 21에서 정식 도입된 가상 스레드는 그린 스레드의 부활이자 진화입니다. 핵심 아이디어: 일대일처럼 프로그래밍하되, 런타임이 다대다처럼 실행합니다.

기존 Java 스레드(Thread)는 일대일 모델이므로, 스레드 하나당 OS 스레드 하나가 필요했고, 스레드 생성이 무거워 스레드 풀을 사용해야 했습니다. 가상 스레드는 JVM이 관리하는 경량 스레드로, 소수의 OS 스레드(캐리어 스레드)위에 수백만 개의 가상 스레드를 다중화합니다.

가상 스레드가 I/O 블로킹 호출을 하면, JVM은 자동으로 해당 가상 스레드를 캐리어 스레드에서 분리(unmount)하고 다른 가상 스레드를 탑재(mount)합니다. I/O가 완료되면 가상 스레드가 다시 캐리어에 탑재됩니다. 프로그래머가 async/await를 작성할 필요 없이, 기존의 동기식 코드를 그대로 사용하면서 동시성을 얻습니다.

그린 스레드와 다른 점은: 그린 스레드는 다대일이었지만, 가상 스레드는 다대다이며, 블로킹 I/O 감지 시 자동으로 캐리어 스레드가 해제됩니다. 그린 스레드의 두 가지 한계(블로킹 시 전체 멈춤 + 멀티코어 미활용)가 모두 해결된 것입니다.

고루틴 (Goroutine) — Go

Go 언어의 고루틴은 가상 스레드보다 먼저 같은 문제를 해결했습니다. go 키워드 하나로 고루틴을 생성하면, Go 런타임의 M:N 스케줄러가 소수의 OS 스레드 위에서 수십만 개의 고루틴을 다중화합니다.

Go 런타임은 블로킹 시스템 콜을 감지하면 해당 OS 스레드를 포기하고 새 OS 스레드를 할당하여 다른 고루틴이 블로킹되지 않게 합니다. 고루틴 하나의 초기 스택은 약 2~8KB로, OS 스레드의 기본 8MB에 비해 1000배 작습니다. 이것이 수십만 개의 고루틴을 만들 수 있는 비결입니다.

경량 스레드 비교

기술언어/환경모델블로킹 처리프로그래밍 모델
그린 스레드Java 1.0~1.2M:1전체 멈춤동기식
Python asyncPythonM:1 (단일 OS 스레드)await에서 양보async/await
Kotlin 코루틴KotlinM:N (Dispatcher)suspend에서 양보suspend fun
가상 스레드Java 21+M:N자동 감지 + unmount동기식 (투명)
고루틴GoM:N자동 감지 + OS 스레드 교체go 키워드
TokioRustM:Nawait에서 양보async/await

실무에서의 모델 선택

어떤 모델을 사용할지는 스레드가 무슨 일을 하는가에 따라 달라집니다.

CPU 바운드 작업 — 이미지 처리, 행렬 연산, 영상 인코딩: 커널 수준 스레드(일대일 모델)가 적합합니다. 멀티코어에서 실제 병렬 실행이 필요하기 때문입니다. Python의 경우 GIL 때문에 스레드가 아닌 multiprocessing을 써야 합니다.

I/O 바운드 작업 — 웹 서버, DB 연결, 파일 읽기: 경량 스레드(코루틴, 가상 스레드, 고루틴)가 적합합니다. I/O 대기 시간 동안 다른 작업을 처리하는 것이 핵심이므로, 수만 개의 동시 연결을 적은 OS 스레드로 처리할 수 있습니다.

실제로 현대적인 웹 서버 프레임워크들은 대부분 경량 스레드 모델을 사용합니다. Spring Boot 3.2+는 가상 스레드 지원을 추가했고, Go의 net/http는 요청마다 고루틴을 생성합니다. Python의 FastAPI는 async/await 기반입니다. Node.js는 이벤트 루프 + 워커 스레드 풀 조합입니다.

다음 절에서는 실제 코드로 스레드를 생성하고 관리하는 스레드 프로그래밍을 다루겠습니다. POSIX pthread와 Python threading을 사용하여 스레드 생성, 종료, 합류(join), 스레드 풀을 실습합니다.

목차