안동민 개발노트

본문 시작

멀티스레딩 모델

사용자·커널 스레드의 관리 주체와 다대일·일대일·다대다 매핑을 비교하고 현대 경량 스레드의 적용 범위를 구분합니다.

스레드를 누가 관리하느냐에 따라 구현이 완전히 달라집니다.

사용자 공간의 라이브러리가 스레드를 관리할 수도 있고, 커널이 직접 관리할 수도 있습니다.

그리고 이 두 수준의 스레드가 어떻게 매핑되느냐에 따라 멀티스레딩 모델이 결정됩니다.

이 선택은 성능, 동시성, 프로그래밍 모델 전체에 영향을 미칩니다.


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

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

사용자 수준 스레드는 런타임이 실행 문맥과 전환을 관리합니다. 커널은 개별 사용자 스레드 대신 이를 실행하는 OS 스레드를 스케줄링합니다.

런타임 안에서 전환하면 커널 진입 비용 일부를 줄일 수 있지만, 비용은 구현과 문맥에 따라 달라집니다. 고정된 나노초 수로 일반화하지 않습니다.

여러 사용자 스레드를 OS 스레드 하나에만 올리는 M:1 모델에서는 그 OS 스레드가 블로킹되면 나머지도 실행되지 못합니다. 런타임이 논블로킹 I/O로 대기를 분리하면 이 상황을 피할 수 있지만 CPU 병렬 실행 폭은 하나입니다. 이 한계를 모든 사용자 수준 스레드에 적용하면 M:N과 혼동하게 됩니다.

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

커널 수준 스레드는 운영체제 커널이 직접 생성·관리·스케줄링하는 스레드입니다.

커널은 각 스레드를 독립적인 스케줄링 단위로 인식합니다.

핵심 장점은 독립적 스케줄링입니다.

스레드 A가 블로킹 시스템 콜을 호출해도, 커널은 스레드 B를 실행할 수 있습니다.

멀티코어 CPU에서 스레드를 서로 다른 코어에 배치하여 실제 병렬 실행이 가능합니다.

단점은 생성과 스케줄링에 커널 자원과 처리 비용이 든다는 것입니다. 전환이 항상 애플리케이션의 명시적 시스템 콜에서 시작되는 것은 아닙니다.

clone() 시스템 콜(Linux), 커널 자료구조 할당, 스케줄러 큐 삽입 등의 오버헤드가 발생합니다.

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

Linux에서 스레드는 사실 자원을 공유하는 프로세스입니다.

Linux NPTL의 pthread_create()는 clone/clone3 계열 기능을 통해 태스크를 만들며, CLONE_VM·CLONE_FILES·CLONE_SIGHAND·CLONE_THREAD 등의 플래그로 자원 공유와 같은 스레드 그룹 소속을 지정합니다. 이 목록은 완전한 시스템 콜 인자 예제가 아닙니다.

커널 내부에서 각 스레드는 별도의 task_struct를 가지지만, 이들이 같은 mm_struct(메모리 관리), 같은 files_struct(파일 테이블)를 공유하는 구조입니다.

이 설계 때문에 Linux에서 ps -eLf를 실행하면 스레드가 별도의 LWP(Light Weight Process)로 나타납니다.

/proc/<PID>/task/ 디렉터리에서 각 스레드의 정보를 확인할 수 있습니다.


매핑 모델

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

먼저 사용자 스레드와 커널 스레드가 어떤 비율로 연결되는지 그림으로 잡아두면, 다대일·일대일·다대다의 차이가 훨씬 분명해집니다.

사용자 작업과 커널 스레드의 매핑
M:1, 1:1, M:N사용자 실행 문맥 세 개가 M:1에서는 K 하나, 1:1에서는 각자의 K, M:N에서는 여러 K에 런타임을 통해 매핑됩니다.M:1 · 커널 실행 문맥 하나U1U2U3K11:1 · 각자 커널 스레드U1U2U3K1K2K3M:N · 런타임이 다중화U1U2U3K1K2

U는 사용자 실행 문맥, K는 커널이 스케줄링하는 OS 스레드입니다. 선은 가능한 매핑을 나타내며 동시에 실행된다는 보장은 아닙니다. M:N의 블로킹 처리와 풀 크기는 런타임에 따라 다릅니다.

다대일 (Many-to-One)

여러 사용자 스레드가 하나의 커널 스레드에 매핑됩니다.

스레드 관리가 전부 사용자 공간에서 이루어지므로, 스레드 라이브러리가 스케줄링을 담당합니다.

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

단점은 앞서 설명한 사용자 수준 스레드의 한계를 그대로 가집니다.

하나의 스레드가 블로킹되면 전체가 멈추고(커널 스레드가 하나뿐이므로), 멀티코어 활용이 불가능합니다.

일부 초기 JVM의 그린 스레드 구현이 이 모델을 사용했습니다. Solaris의 전통적인 LWP 스레드 모델은 아래의 M:N으로 구분합니다.

단일 이벤트 루프의 협력적 작업 실행에서도 OS 스레드 하나라는 제약을 생각할 수 있습니다. 다만 코루틴과 고전적인 그린 스레드는 같은 API가 아닙니다.

일대일 (One-to-One)

각 사용자 스레드가 별도의 커널 스레드에 매핑됩니다.

현대 대부분의 운영체제가 이 모델을 사용합니다.

Linux의 pthread, Windows의 Win32 스레드, Java의 플랫폼 스레드가 일대일 모델입니다.

한 OS 스레드의 블로킹이 다른 모든 스레드를 자동으로 정지시키지는 않습니다. 공유 잠금이나 자원 고갈로 함께 대기하는 경우는 여전히 가능합니다.

스레드 A가 read()를 호출해 블로킹되면, 커널은 스레드 A의 커널 스레드만 대기 상태로 바꾸고 스레드 B의 커널 스레드를 실행합니다.

멀티코어에서 진정한 병렬 실행이 가능합니다.

단점은 사용자 스레드를 만들 때마다 커널 스레드가 생성되므로, 수만 개의 스레드를 만들면 커널 리소스(스택, 스케줄러 큐 항목)가 부족해질 수 있습니다.

Linux NPTL의 기본 스택 크기는 시작 시 RLIMIT_STACK 등 설정에 따라 달라집니다. 스레드당 8MiB로 가정하면 1만 개는 약 78GiB의 가상 주소 범위를 요구합니다. 예약된 주소 공간과 실제 상주 메모리는 구분합니다.

이것이 10K 문제(C10K Problem)의 하나의 원인입니다.

다대다 (Many-to-Many)

M개의 사용자 스레드를 N개의 커널 스레드에 매핑합니다(M ≥ N).

사용자 공간에서 스레드를 자유롭게 생성하되, 커널 스레드 수를 제한하여 리소스를 절약합니다.

Solaris의 LWP(Light Weight Process) 모델이 대표적이었습니다.

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

실제 동작을 살펴보겠습니다.

사용자 스레드 10개, 커널 스레드 4개인 경우: 모두 실행 가능하고 CPU 자원이 충분하면 최대 4개의 실행 흐름을 병렬로 진행할 수 있습니다.

사용자 스레드 중 하나가 블로킹되면, 그 커널 스레드가 대기하는 동안 다른 사용자 스레드가 남은 커널 스레드에 매핑됩니다.

이론적으로 이상적이지만, 구현이 매우 복잡합니다.

사용자 수준 스케줄러와 커널 스케줄러의 조정, 블로킹 감지 및 커널 스레드 우회(upcall), LWP 풀 크기 조절 등의 난제가 있습니다.

결국 Linux, Windows 등 주류 운영체제는 일대일 모델을 선택했고, 다대다의 유연성은 언어 런타임 수준에서 구현하게 되었습니다.

그것이 바로 고루틴과 가상 스레드입니다.

블로킹 호출의 영향 범위는 매핑 모델을 고를 때 가장 먼저 확인해야 할 기준입니다.

같은 read() 대기라도 M:1, 1:1, M:N 모델에서 멈추는 범위가 달라집니다.


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

일대일 모델은 단순하고 강력하지만, 스레드 하나당 커널 스레드 하나씩 소비하므로 수십만 개의 동시 작업이 필요한 상황에서는 비효율적입니다.

이를 극복하기 위해 언어 런타임이 다대다 모델을 구현하는 접근법이 발전해 왔습니다.

그린 스레드 (Green Thread)

일부 초기 JVM에서 사용했던 사용자 수준 스레드 구현입니다. 모든 플랫폼과 초기 JVM이 동일한 구현을 썼다는 뜻은 아닙니다.

Green은 Sun Microsystems의 Green Team에서 유래합니다.

해당 JVM은 자체 스케줄러로 Java 스레드를 관리하고, 하나의 OS 실행 문맥에 다중화했습니다.

커널을 거치지 않으므로 생성·전환이 빨랐지만, 다대일 모델의 한계를 그대로 가졌습니다.

하나의 스레드가 블로킹 I/O를 하면 전체 JVM이 멈추고, 멀티코어 CPU에서 병렬 실행이 불가능했습니다.

이후 주요 JVM 구현에서는 플랫폼 스레드를 OS 스레드와 일대일로 연결하는 방식이 일반화되었습니다.

코루틴 (Coroutine)

코루틴은 함수가 실행 도중 자발적으로 양보(yield)하고, 나중에 양보한 지점부터 재개(resume)할 수 있는 구조입니다.

일반 함수는 호출하면 처음부터 끝까지 실행되지만, 코루틴은 중간에 멈추고 다른 코루틴에게 제어를 넘깁니다.

OS 스레드와의 핵심 차이: OS 스레드는 선점형(Preemptive)으로 OS가 타이머 인터럽트로 강제 전환하지만, 코루틴은 협력형(Cooperative)으로 코루틴이 yield 또는 실제로 중단되는 await/suspend 지점에서 제어를 반환합니다. await가 이미 준비된 결과를 읽으면 중단되지 않을 수도 있습니다.

따라서 단일 이벤트 루프에서 하나의 코루틴이 제어를 반환하지 않고 CPU를 독점하면 같은 루프의 다른 코루틴이 지연됩니다.

Python async/await 예시
import asyncio

async def fetch_user(user_id):
    print(f"사용자 {user_id} 조회 시작")
    await asyncio.sleep(1)  # 타이머로 대기 모사 → 다른 코루틴 실행 가능
    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)이 호출되면, 현재 코루틴은 양보하고 이벤트 루프가 다른 코루틴을 실행합니다.

실제 네트워크 조회 대신 타이머로 대기를 모사합니다. 세 대기를 겹치므로 정상적인 스케줄링에서는 약 1초에 오버헤드를 더한 시간이 예상되지만, 이 절에서 실행해 측정한 결과는 아닙니다.

OS 스레드 하나에서 이 모든 것이 동작합니다.

Kotlin의 코루틴은 한 단계 더 나아가, 코루틴을 여러 스레드에 분산(dispatch)할 수 있는 Dispatchers.Default를 제공합니다.

여러 OS 스레드에 코루틴을 분산할 수 있지만 suspend 선언만으로 블로킹 함수가 논블로킹이 되지는 않습니다. Dispatcher와 사용하는 I/O API를 함께 확인해야 합니다.

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

Java 21에서 정식 도입된 가상 스레드는 그린 스레드의 부활이자 진화입니다.

핵심 아이디어: 일대일처럼 프로그래밍하되, 런타임이 다대다처럼 실행합니다.

기존 Java 스레드(Thread)는 일대일 모델이므로, 스레드 하나당 OS 스레드 하나가 필요했고, 스레드 생성이 무거워 스레드 풀을 사용해야 했습니다.

가상 스레드는 JVM이 관리하는 경량 스레드로, 소수의 OS 스레드(캐리어 스레드)위에 수백만 개의 가상 스레드를 다중화합니다.

지원되는 블로킹 I/O에서 JVM은 가상 스레드를 캐리어에서 분리(unmount)하고 다른 가상 스레드를 탑재(mount)할 수 있습니다. 모든 호출에서 분리가 가능한 것은 아닙니다.

I/O가 완료되면 가상 스레드가 다시 캐리어에 탑재됩니다.

프로그래머가 async/await를 작성할 필요 없이, 기존의 동기식 코드를 그대로 사용하면서 동시성을 얻습니다.

다대일 그린 스레드와 달리 가상 스레드는 여러 캐리어에 다중화됩니다. Java 21의 synchronized 관련 pinning은 JDK 24에서 개선됐지만, Java 25 문서에서도 native·foreign function 실행 중의 pinning을 구분합니다.

동시 대기 작업을 많이 처리하는 데 도움이 되지만, CPU 계산 자체를 더 빨리 하거나 공유 잠금의 병목을 없애지는 않습니다.

고루틴 (Goroutine) — Go

Go 언어의 고루틴은 가상 스레드보다 먼저 같은 문제를 해결했습니다.

go 키워드 하나로 고루틴을 생성하면, Go 런타임의 M:N 스케줄러가 소수의 OS 스레드 위에서 수십만 개의 고루틴을 다중화합니다.

Go 런타임은 블로킹 시스템 콜에 들어간 OS 스레드(M)에서 실행 권한(P)을 분리해 다른 M에 넘길 수 있습니다. 유휴 M을 재사용하거나 필요하면 만들며, 막힌 M을 즉시 폐기한다는 뜻은 아닙니다. 네트워크 대기는 netpoller로 고루틴을 park하는 경로도 있습니다.

고루틴은 작은 초기 스택을 필요에 따라 키우는 방식입니다. 초기 크기와 OS 스레드 스택 설정이 달라질 수 있으므로 모든 환경에서 정확히 1000배 작다고 비교하지 않습니다.

이것이 수십만 개의 고루틴을 만들 수 있는 비결입니다.

경량 스레드 비교

기술실행과 대기 방식
초기 그린 스레드일부 JVM의 M:1 구현. OS 스레드가 막히면 전체 진행이 멈춤
Python asyncio보통 이벤트 루프 하나의 협력적 작업. 블로킹 함수를 직접 호출하면 루프도 막힘
Kotlin 코루틴Dispatcher에 따라 한 스레드 또는 여러 스레드. 중단 가능한 API 사용 여부 확인
Java 가상 스레드여러 캐리어에 다중화. 지원되는 대기는 분리하지만 pinning·캐리어 점유 예외가 있음
Go 고루틴런타임의 M:N 스케줄링과 netpoll. 시스템 콜 대기와 네트워크 대기는 경로가 다름
Rust Tokiocurrent-thread·multi-thread 설정에 따라 실행 폭이 다름. 블로킹 작업은 별도 처리 필요

실무에서의 모델 선택

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

CPU 바운드 작업 — 이미지 처리, 행렬 연산, 영상 인코딩: 커널 수준 스레드(일대일 모델)가 적합합니다.

멀티코어에서 실제 병렬 실행이 필요하기 때문입니다.

GIL이 활성화된 CPython의 순수 Python 계산에서는 multiprocessing이 선택지입니다. GIL을 해제하는 확장이나 free-threaded 빌드에서는 스레드 병렬성도 따로 평가합니다.

I/O 바운드 작업 — 웹 서버, DB 연결, 파일 읽기: 경량 스레드(코루틴, 가상 스레드, 고루틴)가 적합합니다.

I/O 대기 시간 동안 다른 작업을 처리하는 것이 핵심이므로, 수만 개의 동시 연결을 적은 OS 스레드로 처리할 수 있습니다.

실제로 현대적인 웹 서버 프레임워크들은 대부분 경량 스레드 모델을 사용합니다.

Spring Boot 3.2+는 가상 스레드 지원을 추가했고, Go의 net/http는 요청마다 고루틴을 생성합니다.

FastAPI의 async 경로는 이벤트 루프를 사용하고 일반 def 경로는 스레드 풀에서 실행할 수 있으므로, 실제 함수와 호출 API를 함께 봅니다.

Node.js는 JavaScript 이벤트 루프와 일부 작업을 처리하는 libuv 스레드 풀을 사용합니다. JavaScript용 worker_threads는 별도 기능입니다.

모델 이름만으로 결정하지 않고 실행 가능한 CPU 수, 블로킹 API와 외부 자원 한도를 함께 확인합니다.

다음 절에서는 실제 코드로 스레드를 생성하고 관리하는 스레드 프로그래밍을 다루겠습니다.

POSIX pthread와 Python threading을 사용하여 스레드 생성, 종료, 합류(join), 스레드 풀을 실습합니다.