멀티스레딩 모델
스레드를 사용자 공간의 런타임이 관리하는 방식과 커널이 관리하는 방식을 비교하고, 둘을 다대일, 일대일, 다대다로 짝짓는 모델에서 블로킹과 병렬 실행이 어떻게 달라지는지 계산해 봅니다. 코루틴, Java 가상 스레드, Go 고루틴처럼 언어 런타임이 되살린 경량 스레드와 작업의 성격에 맞는 선택 기준을 정리합니다.
앞 글에서는 스레드를 프로세스 안의 실행 흐름이라고만 했습니다. 그런데 이 실행 흐름을 누가 만들고, 누가 전환하며, 누가 CPU에 올릴지를 정하는 방법은 하나가 아닙니다. 사용자 공간의 라이브러리나 언어 런타임이 스레드를 관리할 수도 있고, 운영체제의 커널이 직접 관리할 수도 있습니다. 그리고 두 수준의 스레드를 어떤 비율로 짝짓느냐에 따라, 스레드 하나가 입출력을 기다릴 때 다른 스레드도 멈추는지, 여러 CPU 코어를 동시에 쓸 수 있는지가 달라집니다.
이 차이는 오래된 이론 문제가 아니라 지금 우리가 쓰는 도구의 동작을 설명합니다. Python의 async 함수 안에서 time.sleep()을 부르면 왜 다른 작업까지 멈추는지, Java 21의 가상 스레드는 기존 스레드와 무엇이 다른지, Go가 고루틴을 수십만 개 만들 수 있는 이유가 무엇인지가 모두 이 글의 내용과 이어집니다.
누가 스레드를 관리하는가
사용자 수준 스레드(user-level thread)는 사용자 공간의 라이브러리나 언어 런타임이 만들고 전환하는 스레드입니다. 커널은 이 스레드들의 존재를 모르고, 런타임이 올라타 있는 운영체제 스레드만 스케줄링합니다. 런타임이 스레드를 전환할 때는 커널에 들어가지 않고 레지스터와 스택 위치만 바꾸면 되므로, 만들고 전환하는 비용이 작습니다. 대신 커널은 런타임 안에 스레드가 여럿 있다는 것을 모르므로, 런타임이 올라탄 운영체제 스레드가 블로킹 시스템 콜에서 잠들면 그 위의 사용자 스레드들도 모두 함께 멈춥니다.
커널 수준 스레드(kernel-level thread)는 커널이 직접 만들고 관리하며 스케줄링하는 스레드입니다. 커널이 스레드 하나하나를 스케줄링 단위로 알고 있으므로, 스레드 A가 디스크 읽기를 기다리며 잠들어도 커널은 같은 프로세스의 스레드 B를 실행할 수 있고, 스레드들을 서로 다른 코어에 올려 실제로 동시에 실행할 수도 있습니다. 대신 스레드를 만들 때마다 커널 자료구조와 스택이 필요하고, 전환할 때마다 커널을 거치므로 비용이 더 듭니다.
사용자 스레드와 커널 스레드를 잇는 세 방식
두 수준의 스레드를 짝짓는 방식에 따라 세 가지 모델이 있습니다. 그림에서 U는 사용자 수준의 실행 흐름이고, K는 커널이 스케줄링하는 스레드입니다.
스레드 매핑 · 실행 폭과 대기 영향
사용자 작업 수와 커널이 실행하는 스레드 수는 다를 수 있습니다
U는 사용자 실행 문맥이고 K는 커널이 스케줄링하는 OS 스레드입니다. 연결은 매핑 가능성이며, 병렬 실행은 CPU 자원·런타임·대기 처리에 달려 있습니다.
U는 사용자 실행 문맥, K는 커널이 스케줄링하는 OS 스레드입니다. 선은 가능한 매핑을 나타내며 동시에 실행된다는 보장은 아닙니다. M:N의 블로킹 처리와 풀 크기는 런타임에 따라 다릅니다.
다대일: 여러 사용자 스레드가 커널 스레드 하나에
다대일(many-to-one) 모델은 사용자 스레드 여러 개를 커널 스레드 하나에 올립니다. 스레드의 생성과 전환과 스케줄링을 모두 사용자 공간의 라이브러리가 하므로 빠르고, 커널이 스레드를 지원하지 않는 운영체제에서도 쓸 수 있습니다. 하지만 사용자 수준 스레드의 약점을 그대로 가집니다. 커널 스레드가 하나뿐이므로, 사용자 스레드 하나가 블로킹 시스템 콜을 부르면 나머지도 모두 멈추고, CPU가 여러 개여도 한 번에 하나의 코어만 씁니다. 1990년대 일부 초기 JVM이 쓰던 그린 스레드(green thread)가 이 모델이었습니다.
일대일: 사용자 스레드마다 커널 스레드 하나
일대일(one-to-one) 모델은 사용자 스레드마다 커널 스레드를 하나씩 붙입니다. 오늘날 Linux의 pthread, Windows의 스레드, Java의 플랫폼 스레드가 모두 이 모델입니다. 스레드 A가 read()에서 잠들면 커널은 A의 커널 스레드만 대기 상태로 바꾸고 B를 실행하므로, 한 스레드의 블로킹이 다른 스레드를 멈추지 않습니다. 물론 A가 쥔 잠금을 B가 기다리고 있다면 B도 함께 기다리게 되지만, 그것은 매핑 모델이 아니라 프로그램의 동기화 때문입니다. 여러 코어에서 정말로 동시에 실행할 수도 있습니다.
일대일 모델의 약점은 스레드 수가 많아질 때 드러납니다. 스레드마다 커널 스레드와 스택이 필요하기 때문입니다. Linux의 glibc는 새 스레드의 기본 스택 크기를 프로그램이 시작할 때의 스택 한도(RLIMIT_STACK)로 정하는데, 이 값이 8MiB라면 스레드 1만 개는 약 78GiB의 가상 주소 공간을 예약합니다. 예약한 주소가 모두 물리 메모리를 차지하는 것은 아니고 실제로 쓴 페이지만 메모리에 올라오지만, 연결마다 스레드를 하나씩 쓰는 서버가 수만 개의 동시 연결을 감당하기 어려웠던 이유 가운데 하나입니다. 1999년 무렵 동시 연결 1만 개를 처리하는 문제를 C10K 문제라고 부르며 많은 논의가 있었습니다. 기본 스택 크기의 규칙은 pthread_create(3)에 있습니다.
다대다: 사용자 스레드 M개를 커널 스레드 N개에
다대다(many-to-many) 모델은 사용자 스레드 M개를 그보다 적거나 같은 커널 스레드 N개에 나누어 올립니다. 사용자 스레드는 마음껏 만들되 커널 스레드 수는 제한해 자원을 아끼고, 커널 스레드가 여럿이므로 여러 코어를 쓸 수 있으며, 한 커널 스레드가 잠들면 남은 커널 스레드에 다른 사용자 스레드를 올릴 수 있습니다. Solaris가 오랫동안 이 모델을 썼습니다.
이론상으로는 두 모델의 장점을 모두 가지지만, 운영체제 수준에서 구현하기는 매우 까다로웠습니다. 사용자 공간의 스케줄러와 커널의 스케줄러가 서로의 결정을 모르는 채 움직이므로, 커널 스레드가 잠들려 할 때 커널이 사용자 공간에 알려 주는 장치 같은 복잡한 협조가 필요했기 때문입니다. 결국 Solaris도 9부터 일대일을 기본으로 바꾸었고, Linux와 Windows 같은 주류 운영체제는 모두 일대일을 택했습니다. 다대다의 유연함은 나중에 운영체제가 아닌 언어 런타임에서 되살아나는데, 이것은 아래에서 다룹니다.
동시에 실행될 수 있는 수 계산하기
매핑 모델을 놓고 "지금 몇 개의 실행 흐름이 CPU에서 동시에 진행될 수 있는가"를 따질 때는 세 수를 먼저 적습니다. 실행할 수 있는 사용자 실행 흐름(U)의 수, 쓸 수 있는 커널 스레드(K)의 수, 쓸 수 있는 CPU의 수입니다. 동시에 실행되는 사용자 실행 흐름의 수는 이 셋 가운데 가장 작은 값을 넘을 수 없습니다. 사용자 실행 흐름은 커널 스레드에 올라타야 CPU를 받을 수 있고, 커널 스레드도 CPU가 있어야 실행되기 때문입니다.
예를 들어 실행할 수 있는 U가 10개, K가 4개, CPU가 2개라면 동시에 실행되는 수의 상한은 2입니다. 같은 조건에서 K 하나가 블로킹 시스템 콜에 묶여도 남은 K가 3개이므로 상한은 여전히 2입니다. 다대일 모델처럼 K가 하나뿐인데 그 K가 묶이면, CPU가 비어 있어도 U를 실행할 K가 없으므로 상한은 0이 됩니다. 반대로 런타임이 기다리는 U를 K에서 떼어 내고 다른 U를 올릴 수 있다면, U가 기다린다고 해서 K를 잃는 것은 아니므로 K의 수를 줄여 세지 않습니다. 이 상한은 자원이 허락하는 최대치일 뿐 실제로 그만큼 빨라진다는 뜻은 아니며, Python의 GIL 같은 런타임의 제약은 따로 따져야 합니다.
Linux의 일대일 구현
앞 글의 더 깊이에서 보았듯이 Linux 커널은 스레드를 자원을 공유하는 태스크로 구현합니다. Linux의 pthread 구현인 NPTL(Native POSIX Thread Library)의 pthread_create()는 clone 계열 시스템 콜에 CLONE_VM, CLONE_FILES, CLONE_SIGHAND, CLONE_THREAD 같은 플래그를 주어 주소 공간과 파일 표와 신호 처리 설정을 공유하는 새 태스크를 만듭니다. 각 스레드는 자기 task_struct를 가지지만, 그 안의 포인터가 같은 mm_struct와 files_struct를 가리키는 구조입니다. 그래서 ps -eLf로 보면 스레드마다 LWP(light weight process)라는 별도의 번호가 붙어 나타나고, /proc/<PID>/task/ 아래에도 스레드마다 디렉터리가 있습니다.
언어 런타임이 되살린 경량 스레드
일대일 모델은 단순하고 믿을 만하지만, 동시에 기다리는 작업이 수십만 개에 이르는 서버에서는 스레드마다 커널 스레드를 쓰는 비용이 부담이 됩니다. 그래서 언어 런타임이 사용자 공간에서 가벼운 실행 흐름을 많이 만들고, 이를 소수의 커널 스레드에 나누어 올리는 방식이 다시 널리 쓰이게 되었습니다. 운영체제 수준에서 실패했던 다대다를 런타임이 직접 맡은 셈입니다. 런타임은 자기가 관리하는 실행 흐름이 언제 입출력을 기다리는지 알 수 있으므로, 커널과 협조하지 않고도 기다리는 흐름을 커널 스레드에서 떼어 낼 수 있습니다.
코루틴과 이벤트 루프
코루틴(coroutine)은 실행 도중 스스로 제어를 내놓았다가, 나중에 내놓은 지점부터 다시 실행할 수 있는 함수입니다. 일반 함수는 호출되면 끝날 때까지 실행되지만, 코루틴은 await 같은 지점에서 멈추고 다른 코루틴에게 차례를 넘깁니다. 운영체제의 스레드는 타이머 인터럽트로 강제로 전환되는 선점형이지만, 코루틴은 스스로 내놓는 지점에서만 전환되는 협력형입니다.
Python의 asyncio는 스레드 하나에서 이벤트 루프를 돌리며 여러 코루틴을 번갈아 실행합니다. 다음 코드는 응답을 0.3초 기다리는 조회를 세 번 하는 세 가지 방법을 비교합니다. 걸린 시간은 실행 환경에 따라 조금씩 다르므로, 기다리는 시간 0.3초의 몇 배인지로 나타냈습니다.
import asyncio
import threading
import time
DELAY = 0.3 # 네트워크 응답을 기다리는 시간이라고 가정한다
async def fetch_user(user_id):
await asyncio.sleep(DELAY) # 기다리는 동안 이벤트 루프에 제어를 돌려준다
return f"User{user_id}"
async def blocking_fetch(user_id):
time.sleep(DELAY) # 제어를 돌려주지 않고 스레드째 잠든다
return f"User{user_id}"
async def measure(label, make_tasks):
start = time.perf_counter()
names = await make_tasks()
rounds = (time.perf_counter() - start) / DELAY
print(f"{label}: 대기 {rounds:.0f}번 분량 {names}")
async def one_by_one():
return [await fetch_user(i) for i in (1, 2, 3)]
async def together():
return await asyncio.gather(*(fetch_user(i) for i in (1, 2, 3)))
async def together_blocking():
return await asyncio.gather(*(blocking_fetch(i) for i in (1, 2, 3)))
async def main():
print("스레드 수:", threading.active_count())
await measure("차례로 await", one_by_one)
await measure("gather로 함께", together)
await measure("블로킹 호출을 gather", together_blocking)
asyncio.run(main())스레드 수: 1
차례로 await: 대기 3번 분량 ['User1', 'User2', 'User3']
gather로 함께: 대기 1번 분량 ['User1', 'User2', 'User3']
블로킹 호출을 gather: 대기 3번 분량 ['User1', 'User2', 'User3']첫 줄은 이 모든 일이 스레드 하나에서 일어난다는 것을 보여 줍니다. 조회를 하나씩 await하면 앞의 조회가 끝나야 다음 조회를 시작하므로 대기 시간의 세 배가 걸립니다. gather로 세 코루틴을 함께 실행하면, 첫 코루틴이 asyncio.sleep()에서 제어를 내놓는 사이 이벤트 루프가 나머지 코루틴도 시작하므로 세 대기가 겹쳐 한 번 분량에 끝납니다.
마지막 줄이 이 글의 핵심과 맞닿아 있습니다. blocking_fetch도 async 함수이고 gather로 함께 실행했지만, 안에서 부른 time.sleep()은 이벤트 루프에 제어를 돌려주지 않고 스레드 자체를 잠재웁니다. 이벤트 루프는 스레드 하나에서 돌고 있으므로, 스레드가 잠든 동안 다른 코루틴은 아무것도 하지 못하고 세 대기가 차례로 이어집니다. 다대일 모델에서 사용자 스레드 하나의 블로킹 호출이 전체를 멈추게 하는 것과 같은 현상입니다. 그래서 이벤트 루프 안에서는 블로킹 함수를 직접 부르지 말고, asyncio.to_thread()처럼 별도 스레드로 넘기는 방법을 써야 합니다.
Kotlin의 코루틴은 디스패처(dispatcher)를 통해 코루틴을 여러 스레드에 나누어 실행할 수 있습니다. 예를 들어 Dispatchers.Default는 기본으로 CPU 코어 수만큼, 적어도 두 개의 스레드에 코루틴을 나눕니다. 다만 함수에 suspend를 붙인다고 그 안의 블로킹 호출이 블로킹하지 않게 바뀌지는 않으므로, 어떤 디스패처에서 어떤 입출력 API를 부르는지 함께 확인해야 합니다.
Java의 가상 스레드
Java 21에서 정식 기능이 된 가상 스레드(virtual thread)는 "코드는 일대일처럼 쓰고, 실행은 다대다처럼 한다"는 생각에서 나왔습니다. 기존의 Java 스레드는 플랫폼 스레드(platform thread)라고 부르며 운영체제 스레드와 일대일로 짝지어지므로, 많이 만들기 어려워 스레드 풀로 재사용해야 했습니다. 가상 스레드는 JVM이 관리하는 가벼운 스레드로, 캐리어 스레드(carrier thread)라고 부르는 소수의 플랫폼 스레드 위에 아주 많은 가상 스레드를 나누어 올립니다.
가상 스레드가 지원되는 블로킹 입출력을 하면, JVM은 그 가상 스레드를 캐리어 스레드에서 떼어 내고(unmount) 다른 가상 스레드를 올립니다(mount). 입출력이 끝나면 그 가상 스레드는 다시 어느 캐리어 스레드에 올라 실행을 이어 갑니다. 그래서 프로그래머는 async와 await 없이 평범한 동기식 코드를 쓰면서도, 기다리는 작업을 많이 동시에 처리할 수 있습니다.
다만 가상 스레드를 캐리어에서 떼어 낼 수 없는 경우가 있습니다. 이를 고정(pinning)이라고 하는데, Java 21에서는 synchronized 블록 안에서 블로킹하면 고정되는 문제가 있었고 JDK 24에서 개선되었습니다. 그 뒤에도 네이티브 코드를 실행하는 동안의 고정은 남아 있으며, 자세한 내용은 Java 25의 가상 스레드 문서에 있습니다. 또 가상 스레드는 기다리는 작업을 많이 동시에 다루게 해 줄 뿐, CPU 계산을 더 빠르게 하거나 하나의 잠금에 몰리는 병목을 없애 주지는 않습니다.
Go의 고루틴
Go 언어의 고루틴(goroutine)은 가상 스레드보다 먼저 같은 문제를 풀었습니다. go 키워드 하나로 고루틴을 만들면, Go 런타임의 스케줄러가 고루틴을 소수의 운영체제 스레드에 나누어 실행합니다. 런타임 안에서는 고루틴을 G, 운영체제 스레드를 M, 고루틴을 실행할 권한인 논리 프로세서를 P라고 부르며, P의 수(GOMAXPROCS)는 기본적으로 쓸 수 있는 논리 CPU 수와 같습니다. 다만 Go 1.25부터 Linux에서는 컨테이너에 정해 둔 CPU 한도가 이보다 작으면 그 한도를 올림한 값을 기본값으로 쓰며, 논리 CPU가 둘 이상이면 2보다 작게 정하지는 않습니다.
고루틴이 블로킹 시스템 콜에 들어가면, 런타임은 그 M에서 P를 떼어 내 다른 M에 넘겨주고 나머지 고루틴을 계속 실행합니다. 네트워크를 기다리는 고루틴은 시스템 콜에서 잠들지 않고, 런타임의 네트워크 폴러(netpoller)에 등록된 채 대기열에서 빠져 있다가 데이터가 오면 다시 실행됩니다. 시스템 콜에 들어간 M이 P를 내놓는 규칙은 Go 런타임 안내 문서에 설명되어 있습니다. 또 고루틴은 수 KB의 작은 스택으로 시작해 필요할 때 늘리므로, 수 MB를 예약하는 운영체제 스레드보다 훨씬 많이 만들 수 있습니다.
경량 실행 흐름 비교
| 기술 | 실행 방식 | 블로킹 호출을 만나면 |
|---|---|---|
| 초기 그린 스레드 | 일부 JVM의 다대일 구현 | 커널 스레드가 잠들어 모든 스레드가 멈춘다 |
Python asyncio | 이벤트 루프 하나에서 협력형으로 실행 | 블로킹 함수를 직접 부르면 루프 전체가 멈춘다 |
| Kotlin 코루틴 | 디스패처에 따라 한 스레드 또는 여러 스레드 | 디스패처의 스레드 하나가 묶인다 |
| Java 가상 스레드 | 여러 캐리어 스레드에 다대다로 실행 | 지원되는 입출력은 떼어 내지만, 고정되면 캐리어가 묶인다 |
| Go 고루틴 | 런타임의 G, M, P 스케줄러와 네트워크 폴러 | 시스템 콜은 P를 넘기고, 네트워크 대기는 폴러가 맡는다 |
| Rust Tokio | 설정에 따라 스레드 하나 또는 여러 스레드 | 블로킹 작업은 spawn_blocking으로 따로 실행해야 한다 |
더 깊이: 계산만 하는 경량 스레드는 누가 멈출까
경량 실행 흐름은 대부분 기다리는 지점에서 스레드를 내놓습니다. 그렇다면 기다리지 않고 계산만 오래 하는 실행 흐름은 언제 멈추는지가 궁금해지는데, 답은 런타임마다 다릅니다. Go 1.13까지는 함수 호출이 없는 반복문이 스케줄러를 멈추게 하거나 가비지 컬렉션을 크게 늦출 수 있었습니다. Go 1.14 릴리스 노트에 따르면 이 버전부터 고루틴을 비동기적으로 선점(asynchronous preemption)할 수 있게 되어, 그런 반복문도 런타임이 중간에 멈출 수 있습니다.
이 선점에는 눈에 띄는 부작용이 하나 있습니다. 선점을 구현한 방식 때문에 Go 1.14로 빌드한 프로그램은 Linux와 macOS 같은 Unix 시스템에서 이전보다 신호를 더 많이 받습니다. 그래서 syscall 패키지 등으로 느린 시스템 콜을 직접 부르는 코드는 호출이 EINTR 오류로 끝나는 일을 더 자주 만나며, 보통 같은 호출을 다시 시도하도록 처리해야 합니다.
Java는 다른 선택을 했습니다. Java 21에서 가상 스레드를 정식 기능으로 만든 JEP 444는 가상 스레드 스케줄러가 시분할(time sharing), 곧 정해진 CPU 시간을 다 쓴 스레드를 강제로 선점하는 기능을 아직 구현하지 않는다고 적고 있습니다. 플랫폼 스레드가 적고 CPU 사용률이 100%일 때는 시분할이 일부 작업의 지연을 줄여 주지만, 가상 스레드가 백만 개일 때도 그만큼 효과가 있을지는 분명하지 않다는 것이 이유입니다. 그래서 계산만 오래 하는 가상 스레드는 그동안 캐리어 스레드를 내놓지 않습니다.
작업의 성격에 맞게 고르기
어떤 모델을 쓸지는 스레드가 주로 무슨 일을 하느냐에 달려 있습니다. 이미지 처리, 행렬 연산, 영상 인코딩처럼 CPU 계산이 대부분인 작업(CPU 바운드)은 여러 코어에서 정말로 동시에 실행해야 빨라지므로, 코어 수 정도의 커널 스레드를 쓰는 일대일 모델이 알맞습니다. 경량 스레드를 아무리 많이 만들어도 CPU 수보다 많이 동시에 실행되지는 않습니다. GIL이 있는 CPython에서 순수 Python 계산을 나누려면 3장에서 본 multiprocessing을 쓰고, GIL을 풀어 주는 확장 모듈이나 free-threaded 빌드라면 스레드도 후보가 됩니다.
웹 서버, 데이터베이스 연결, 외부 API 호출처럼 대부분의 시간을 기다리는 작업(I/O 바운드)은 기다리는 동안 다른 작업을 진행하는 것이 핵심이므로, 코루틴, 가상 스레드, 고루틴 같은 경량 실행 흐름이 알맞습니다. 그래서 요즘의 서버 프레임워크는 대부분 이런 모델을 지원합니다. Spring Boot는 3.2부터 가상 스레드를 쓰는 설정을 제공하고, Go의 net/http 서버는 연결마다 고루틴을 만듭니다. FastAPI는 async def로 쓴 경로 함수를 이벤트 루프에서, 일반 def로 쓴 경로 함수를 스레드 풀에서 실행하므로, 어떤 형태로 쓰고 안에서 어떤 API를 부르는지 함께 봐야 합니다.
결국 모델의 이름만으로 결정할 수는 없습니다. 실행할 수 있는 CPU의 수, 쓰는 API가 블로킹하는지, 데이터베이스 연결 수 같은 외부 자원의 한도를 함께 확인해야 합니다.
정리
- 사용자 수준 스레드는 런타임이 관리해 만들고 전환하는 비용이 작지만, 커널이 존재를 모르므로 올라탄 커널 스레드가 잠들면 함께 멈춥니다. 커널 수준 스레드는 커널이 하나하나 스케줄링하므로 따로 기다리고 여러 코어에서 실행됩니다.
- 다대일 모델은 한 스레드의 블로킹이 전체를 멈추고 코어 하나만 씁니다. 일대일 모델은 이 문제가 없지만 스레드마다 커널 자원이 듭니다. 다대다 모델은 둘을 절충하지만 운영체제 수준에서는 구현이 어려워 주류 운영체제는 일대일을 택했습니다.
- 동시에 실행되는 사용자 실행 흐름의 수는 실행할 수 있는 흐름, 쓸 수 있는 커널 스레드, 쓸 수 있는 CPU 가운데 가장 작은 수를 넘지 못합니다.
- 언어 런타임은 코루틴, 가상 스레드, 고루틴으로 다대다를 되살렸습니다. 기다리는 실행 흐름을 커널 스레드에서 떼어 낼 수 있을 때만 효과가 있으므로, 블로킹 호출을 직접 부르면 이점이 사라집니다.
- CPU 계산이 많은 작업은 코어 수 정도의 커널 스레드로, 기다리는 시간이 많은 작업은 경량 실행 흐름으로 처리하는 것이 기본입니다.
연습 문제
1. 동시에 실행될 수 있는 수의 상한
CPU가 8개이고 쓸 수 있는 커널 스레드가 3개인 런타임이 있습니다. 실행할 수 있는 사용자 실행 흐름이 2개일 때, 5개로 늘었을 때, 그 뒤 커널 스레드 하나가 블로킹 시스템 콜에 묶였을 때, 동시에 실행될 수 있는 수의 상한을 각각 구하세요. 또 런타임이 기다리는 사용자 실행 흐름만 커널 스레드에서 떼어 낸 경우라면 커널 스레드 수를 줄여 세야 할까요?
해설 보기
상한은 실행할 수 있는 흐름, 쓸 수 있는 커널 스레드, CPU 가운데 가장 작은 값입니다. 흐름이 2개면 min(2, 3, 8) = 2이고, 5개로 늘면 min(5, 3, 8) = 3입니다. 커널 스레드 하나가 묶여 쓸 수 있는 것이 2개로 줄면 min(5, 2, 8) = 2가 됩니다. 런타임이 기다리는 흐름을 커널 스레드에서 떼어 냈다면 커널 스레드 자체는 묶이지 않았으므로 줄여 세지 않고, 그 커널 스레드에는 다른 흐름을 올릴 수 있습니다. 기다리는 작업이 많을 수 있다는 것과 CPU에서 동시에 실행되는 계산이 많다는 것은 서로 다른 이야기입니다.
2. 고쳐야 할 async 함수
앞의 asyncio 예제에서 blocking_fetch가 실제로는 블로킹 방식의 데이터베이스 드라이버를 부르는 함수이고, 이 드라이버에는 비동기 버전이 없다고 합시다. 세 호출을 gather로 함께 실행해도 대기 시간이 겹치지 않는 이유를 설명하고, 드라이버를 바꾸지 않고 대기를 겹치게 하는 방법을 제시하세요.
해설 보기
이벤트 루프는 스레드 하나에서 돌고, 코루틴은 await에서 제어를 돌려줄 때만 전환됩니다. 블로킹 드라이버는 응답이 올 때까지 스레드 자체를 잠재우므로, 그동안 이벤트 루프가 다른 코루틴을 실행할 기회가 없어 세 호출이 차례로 실행됩니다. 드라이버를 바꿀 수 없다면 블로킹 호출을 별도의 스레드에서 실행하게 합니다. 예를 들어 await asyncio.to_thread(blocking_call, user_id)로 부르면, 호출은 스레드 풀의 스레드에서 블로킹하고 이벤트 루프의 스레드는 바로 제어를 돌려받아 다른 코루틴을 진행합니다. 이 경우 동시에 진행할 수 있는 호출 수는 스레드 풀의 크기와 데이터베이스의 연결 수에 따라 제한됩니다.
3. 가상 스레드로 바꾸면 빨라질까
두 서비스를 Java 플랫폼 스레드 풀에서 가상 스레드로 바꾸려 합니다. 서비스 A는 요청마다 외부 API 응답을 200ms 기다리고 계산은 거의 하지 않습니다. 서비스 B는 요청마다 이미지를 변환하느라 CPU를 200ms 씁니다. 두 서비스에서 가상 스레드로 바꾼 효과를 각각 예상하고 이유를 설명하세요.
해설 보기
서비스 A는 시간 대부분을 기다리는 데 씁니다. 플랫폼 스레드 풀에서는 기다리는 요청마다 운영체제 스레드가 하나씩 묶이므로 풀의 크기가 동시 처리 수를 제한하지만, 가상 스레드는 기다리는 동안 캐리어 스레드에서 떼어 내지므로 많은 요청이 함께 기다릴 수 있어 처리량이 늘 수 있습니다. 다만 외부 API가 받아 줄 수 있는 동시 요청 수나, 고정을 일으키는 코드가 있는지도 확인해야 합니다. 서비스 B는 CPU 계산이 대부분이므로, 동시에 실행되는 수는 CPU 코어 수를 넘을 수 없습니다. 가상 스레드를 아무리 많이 만들어도 계산이 빨라지지는 않으므로, 코어 수 정도의 스레드로 처리하는 지금 구조와 비교해 큰 이득을 기대하기 어렵습니다.
다음 글 스레드 프로그래밍에서는 POSIX 스레드와 Python의 threading으로 스레드를 만들고, 기다리고, 스레드 풀로 작업을 나누는 방법을 코드로 익힙니다.