async/await의 한계를 짚고, 호출 스택·백프레셔·디버깅/프로파일링 관점에서 문제점을 분석하며, 스레드(특히 가상 스레드)와 구조적 동시성, 채널 같은 원시 연산이 더 나은 동시성 모델임을 Scratch, Go, Java Loom, Python, C#, Rust 사례로 비교해 논한다.
놀이터의 지혜: 스레드는 async/await를 이긴다
작성일: 2024년 11월 18일
제가 async/await 기반 시스템에서 겪은 어려움과, 그것들이 백프레셔를 제대로 지원하지 못한다고 느꼈던 점을 쓴 지 몇 년이 지났습니다. 몇 년이 지난 지금도 이 문제가 크게 줄었다고는 생각하지 않지만, 제 생각과 이해는 조금은 진화했습니다. 이제 저는 async/await가 사실 대부분의 언어에 나쁜 추상화이며, 대신 더 나은 것을 목표로 해야 한다고 확신합니다. 그 “더 나은 것”은 스레드라고 믿습니다.
이 글에서 저는 저보다 앞서 있던 똑똑한 분들의 많은 주장도 다시 소개하려 합니다. 여기 새로운 것은 없습니다. 다만 새로운 독자층에 전하고 싶습니다. 특히 다음의 매우 영향력 있는 글/발표들을 꼭 고려해 보세요:
프로그래머로서 우리는 사물이 작동하는 방식에 너무 익숙해져서, 자유롭게 생각하는 능력을 흐리는 묵시적 가정을 하곤 합니다. 이를 보여 주는 코드 조각을 소개하겠습니다:
def move_mouse():
while mouse.x < 200:
mouse.x += 5
sleep(10)
def move_cat():
while cat.x < 200:
cat.x += 10
sleep(10)
move_mouse()
move_cat()
이 코드를 읽고 다음 질문에 답해 보세요: 쥐와 고양이는 동시에 움직일까요, 아니면 차례로 움직일까요? 프로그래머 10명 중 10명은 차례로 움직인다고 정확히 답할 겁니다. 파이썬과 스레드, 스케줄링 등의 개념을 알고 있으니 당연하게 느껴집니다. 하지만 스크래치에 익숙한 아이들에게 물어보면 쥐와 고양이가 동시에 움직인다고 결론 내릴 가능성이 큽니다.
그 이유는 스크래치로 프로그래밍을 접하면 원시적 형태의 액터 프로그래밍을 접하기 때문입니다. 고양이와 쥐는 둘 다 액터입니다. 사실 UI가 이 점을 아주 분명히 드러내는데, 액터를 “스프라이트”라고 부를 뿐입니다. 화면의 스프라이트에 로직을 붙이면, 그 모든 로직이 동시에 실행됩니다. 놀랍죠. 심지어 스프라이트끼리 메시지를 보낼 수도 있습니다.
이 점을 잠시 생각해 보길 바라는 건 꽤 심오하다고 보기 때문입니다. 스크래치는 매우 매우 단순한 시스템이고 어린이를 가르치기 위해 만들어졌습니다. 그런데도 그 모델은 액터 시스템을 선호합니다! 만약 파이썬, C#, 기타 전통적인 언어 책으로 프로그래밍에 입문한다면, 스레드를 책 맨 끝에서야 배울 가능성이 큽니다. 그뿐 아니라 매우 복잡하고 무섭게 들리게 만들 겁니다. 더 나쁜 건, 액터 패턴은 대규모 애플리케이션의 복잡성으로 당신을 폭격하는 고급서에서나 배우게 될 확률이 높다는 겁니다.
또 한 가지 기억해야 할 점이 있습니다. 스크래치는 스레드, 모나드, async/await, 스케줄러에 대해 말하지 않습니다. 프로그래머인 당신 입장에서 보면, 메시지 전달을 위한 기본적인 “문법” 지원이 있는 명령형(물론 컬러풀하고 시각적인) 언어일 뿐입니다. 동시성은 자연스럽습니다. 어린아이도 프로그래밍할 수 있습니다. 두려워할 것이 아닙니다.
두 번째로 가져가길 바라는 점은, 명령형 언어가 함수형보다 열등하지 않다는 것입니다.
아마 우리 대부분은 문제를 해결할 때 명령형 언어를 쓰지만, 명령형이 열등하고 그리 순수하지 않다는 관념에 노출되어 왔습니다. 모나드 같은 것이 있는 함수형 세계가 있죠. 그 세계는 합성, 논리, 수학, 멋져 보이는 정리들을 갖고 있습니다. 그런 것으로 프로그래밍하면 마치 한 단계 더 높은 차원으로 초월하여, if 문과 for 루프를 기워 붙이고, 도처에 부작용을 만들고, IO로 매우 부적절한 짓을 하는 사람들을 내려다보는 듯한 느낌이 들기도 합니다.
물론 제가 과장했을 수도 있겠지만, 그런 분위기가 완전히 틀린 건 아니라고 봅니다. 그리고 저도 압니다. 저 역시 Rust나 JavaScript에서 람다를 체이닝할 때 기분이 좋습니다. 하지만 이런 구성물들은 많은 언어에서 덧대어진 것입니다. 예컨대 Go는 이런 것 대부분 없이도 잘 굴러가며, 그렇다고 해서 열등한 언어가 아닙니다!
여기서 기억해야 할 점은 서로 다른 패러다임이 존재하며, 한동안은 함수형이 모든 걸 해결했고 명령형은 그렇지 못했다고 생각하는 마음을 잠시 내려놓으라는 것입니다.
대신, 함수형 언어와 명령형 언어가 “기다림”을 어떻게 다루는지 이야기해 보겠습니다.
먼저 위의 예로 돌아가 봅시다. 두 함수(고양이와 쥐)는 각각 독립된 실행 스레드로 볼 수 있습니다. 코드가 sleep(10)을 호출하면, 프로그래머는 컴퓨터가 일시적으로 실행을 멈추고 나중에 계속할 거라고 분명히 기대합니다. 모나드로 지루하게 만들고 싶지 않으니, “함수형” 언어 예로는 JavaScript와 프로미스를 쓰겠습니다. 대부분의 독자가 충분히 익숙한 추상화라고 생각합니다:
function moveMouseBlocking() {
while (mouse.x < 200) {
mouse.x += 5;
sleep(10); // 블로킹 sleep
}
}
function moveMouseAsync() {
return new Promise((resolve) => {
function iterate() {
if (mouse.x < 200) {
mouse.x += 5;
sleep(10).then(iterate); // 논블로킹 sleep
} else {
resolve();
}
}
iterate();
});
}
여기서 곧바로 드러나는 난점이 있습니다. 블로킹 예제를 논블로킹 예제로 바꾸기가 매우 어렵습니다. 갑자기 루프(혹은 어떤 제어 흐름이든)를 표현하는 방법을 새로 찾아야 하기 때문입니다. 재귀 함수 호출 형태로 수동 분해해야 하고, 기다림을 위해 스케줄러와 실행기의 도움이 필요합니다.
이 스타일은 결국 다루기 지겨워져서, 예전 코드의 이성을 대부분 되찾아주는 async/await가 도입되었습니다. 이제 다음처럼 보일 수 있죠:
async function moveMouseAsync() {
while (mouse.x < 200) {
mouse.x += 5;
await sleep(10);
}
}
하지만 내부적으로는 아무것도 본질적으로 변하지 않았습니다. 특히 그 함수를 호출하면 “계산의 합성”을 감싼 객체 하나를 받습니다. 그 객체는 나중에 결과 값을 담게 될 프로미스입니다. 사실 C# 같은 일부 언어에서는 컴파일러가 이것을 체인된 함수 호출로 트랜스파일하기도 합니다. 프로미스를 손에 쥐면, 결과를 await 하거나, then으로 콜백을 등록해 이 작업이 끝나면 호출되게 할 수 있습니다.
프로그래머 입장에서 async/await는 분명 프로미스와 콜백 위에 놓인 깔끔한 추상화로 이해됩니다. 하지만 엄밀히 말하면 출발점보다 나빠졌습니다. 표현력 측면에서 중요한 역량을 잃었기 때문입니다. 즉, 자유롭게 ‘중단(suspend)’할 수 없습니다.
원래 블로킹 코드에서는 sleep을 호출하면 10ms 동안 암묵적으로 중단했습니다. 하지만 비동기 호출에서는 그렇게 할 수 없습니다. 여기서는 그 슬립 연산을 “await”해야 합니다. 이것이 우리가 “색깔 있는 함수(colored functions)”를 갖게 된 핵심 이유입니다. 동기 함수 안에서는 await할 수 없으므로, 오직 async 함수만 다른 async 함수를 호출할 수 있습니다.
위의 예는 async/await가 유발하는 또 다른 문제를 보여줍니다. 만약 우리가 결코 resolve하지 않으면? 일반 함수 호출은 결국 반환하고, 스택이 풀리고, 결과를 받을 준비가 됩니다. 비동기 세계에서는 맨 마지막에 누군가 resolve를 호출해야 합니다. 만약 그게 결코 호출되지 않으면? 이론적으로는, 아주 오랜 시간 중단하도록 큰 값을 넣고 sleep()을 호출하거나, 결코 데이터가 들어오지 않는 파이프를 기다리는 것과 그다지 다르지 않아 보일 수 있습니다. 하지만 다릅니다! 한 경우에는 호출 스택과 그와 관련된 모든 것을 살아 있게 유지합니다. 다른 경우에는 단지 프로미스만 있고, 모든 것이 이미 풀린 상태에서 독립적인 가비지 컬렉션을 기다립니다.
계약 관점에서는, 끝에서 resolve를 반드시 호출해야 한다는 약속이 전혀 없습니다. 이론으로부터 우리는 정지 문제는 결정 불가능하다는 것을 알고 있으니, 누군가 resolve를 호출할지 아닐지를 아는 것은 불가능합니다.
까다롭게 들릴 수 있지만, 이것은 매우 중요합니다. 프로미스/퓨처와 async/await는 그것들이 없었을 때보다 엄밀히 더 나쁜 점을 만듭니다. 가장 전형적인 예로 JavaScript 프로미스를 보죠. 프로미스는 익명 함수에 의해 만들어지고, 그 함수가 나중에 resolve를 호출합니다. 다음 예를 보세요:
let neverSettle = new Promise((resolve) => {
// 이 함수는 끝나지만 resolve는 결코 호출되지 않습니다
});
이건 JavaScript 특유의 문제가 아니라는 점을 먼저 분명히 하겠습니다. 이렇게 보여주기 편해서 그렇습니다. 이것은 완전히 합법적입니다! 결코 resolve되지 않는 프로미스입니다. 버그가 아닙니다! 프로미스 안의 익명 함수 자체는 반환하고, 스택은 풀리고, 우리는 “대기 중(pending)”인 프로미스를 갖게 됩니다. 그것은 언젠가 가비지 컬렉션될 겁니다. 문제는, 결코 resolve되지 않으니 또한 결코 그것을 await할 수 없다는 것입니다.
이 문제를 조금 보여주는 다음 예를 생각해 보세요. 실제로는 동시에 일할 수 있는 것의 수를 줄이고 싶을 때가 있습니다. 동시에 최대 10개까지 처리할 수 있는 시스템을 상상해 봅시다. 그래서 토큰 10개를 나눠주는 세마포어를 써서 최대 10개까지만 동시에 실행되게 하고, 그 외에는 백프레셔를 걸고 싶을 수 있습니다. 코드는 대략 이렇게 생겼겠죠:
const semaphore = new Semaphore(10);
async function execute(f) {
let token = await semaphore.acquire();
try {
await f();
} finally {
await semaphore.release(token);
}
}
하지만 이제 문제가 생깁니다. execute에 전달한 함수가 neverSettle을 반환하면 어떻게 될까요? 분명히 우리는 결코 세마포어 토큰을 반환(release)하지 못합니다. 이것은 블로킹 함수보다 엄밀히 더 나쁩니다! 가장 가까운 동등물은 아주 긴 sleep을 호출하는 멍청한 함수일 겁니다. 하지만 다릅니다! 한 경우에는 호출 스택과 그와 관련된 모든 것을 살아 있게 유지합니다. 다른 경우에는 결국 가비지 컬렉션될 프로미스만 있을 뿐이며, 우리는 그것을 다시는 보지 못합니다. 프로미스의 경우, 우리는 효과적으로 스택을 쓸모없다고 결정해 버린 것입니다.
이 문제를 고치는 방법도 있습니다. 예를 들어 프로미스 파이널라이제이션을 제공해 프로미스가 가비지 컬렉션되면 통지받도록 하는 등입니다. 그러나 계약 상 이 프로미스의 동작은 완전히 허용되는 것이며, 그 결과 예전에는 없던 새로운 문제를 우리가 만든 것임을 강조하고 싶습니다.
그리고 파이썬에는 그런 문제가 없다고 생각한다면, 파이썬에도 있습니다. 그냥 await Future() 하세요. 그러면 우주의 열적 죽음이 올 때까지(혹은 인터프리터를 끌 때까지) 기다리게 됩니다.
resolve되지 않은 프로미스는 호출 스택이 없습니다. 하지만 이 문제는 올바르게 사용해도 다른 방식으로 돌아옵니다. 스케줄러를 경유한 분해된 함수-호출 흐름은 이제 이러한 비동기 호출을 완전한 호출 스택으로 이어 붙일 추가 역량을 요구합니다. 이 모든 것은 이전에는 없던 추가 문제를 만듭니다. 호출 스택은 정말, 정말 중요합니다. 디버깅에 도움이 되고, 프로파일링에도 매우 중요합니다.
좋습니다. 적어도 프로미스 모델에는 몇 가지 도전 과제가 있다는 걸 알았습니다. 다른 추상화는 무엇이 있을까요? 저는 함수가 실행 스레드를 “중단”할 수 있다는 능력과 추상화가 엄청나게 훌륭한 것이라고 주장하겠습니다. 잠시 생각해 보세요. 어디에서든, 무언가를 기다릴 필요가 있으면 “여기서 잠시 멈추고 나중에 이 자리에서 다시 계속”할 수 있습니다. 이것은 특히 나중에 필요하다고 결정되면 백프레셔를 적용하는 데 중요합니다. 파이썬 asyncio에서 가장 큰 발목잡이는 여전히 write가 논블로킹이라는 점입니다. 그 함수는 영원히 문제적일 것이며, 버퍼 팽창을 피하려면 반드시 await s.drain()을 따라붙여야 합니다.
특히 이것이 중요한 추상화인 이유는 현실 세계에서는 항상 모든 것이 비동기인 것이 아니며, 블록하지 않을 것 같던 것들이 실제로는 블록하곤 하기 때문입니다. 마치 파이썬이 설계될 때 write가 블록할 수 있어야 한다고 생각하지 않았던 것처럼요. 여기에 알록달록한 예를 하나 들겠습니다. 다음 코드는 왜 블로킹이고, 무엇이 블로킹일까요?
def decode_object(idx):
header = indexes[idx]
object_buf = buffer[header.start:header.start + header.size]
return brotli.decompress(object_buf)
약간 함정처럼 들리지만 사실 그렇지도 않습니다. 블로킹인 이유는 메모리 접근이 블로킹일 수 있기 때문입니다! 이런 식으로 생각하지 않을 수 있지만, 특정 메모리 영역을 건드리는 것만으로도 시간이 걸리는 이유는 많습니다. 가장 명백한 것은 메모리 맵 파일입니다. 아직 적재되지 않은 페이지를 건드리면, 운영체제가 그것을 메모리에 퍼올려야 당신에게 돌아옵니다. “이 메모리를 건드리는 것을 await하라”는 표현은 없습니다. 그런 것이 있다면 어디서든 await해야 할 테니까요. 사소해 보일지 몰라도, 블로킹 메모리 읽기는 Sentry에서 일련의 사고의 근원이었습니다.
오늘날 async/await가 감수하는 트레이드오프는 “모든 것이 블로킹되거나 중단될 필요는 없다”는 생각입니다. 하지만 현실은 훨씬 더 많은 것들이 정말로 중단되길 원한다는 것을 보여줍니다. 임의의 메모리 접근이 중단의 사례라면, 그 추상화는 과연 가치가 있을까요?
그래서 아마 어떤 함수 호출이든 블록하고 중단할 수 있게 하는 것이 애초에 옳은 추상화였을지도 모릅니다.
그렇다면 다음으로 스레드 생성에 대해 이야기해야 합니다. 단일 스레드는 그리 가치가 크지 않으니까요. async/await 시스템이 주는 한 가지 편의는, 두 일을 동시에 실행하라고 실제로 지시할 수 있다는 것입니다. 비동기 연산을 시작한 뒤, 나중으로 await를 미루는 방식으로 얻습니다. 여기서는 async/await에 무엇인가 장점이 있음을 인정해야 합니다. 동시 실행의 현실을 언어로 끌어들입니다. 스크래치 프로그래머에게 동시성이 자연스러운 이유는 언어에 바로 들어가 있기 때문이며, async/await도 매우 비슷한 역할을 합니다.
전통적인 스레드 기반 명령형 언어에서는 스레드 생성 행위가 (종종 뒤얽힌) 표준 라이브러리 함수 뒤에 숨겨져 있습니다. 더 성가신 것은 스레드가 덧댄 것처럼 느껴지고, 가장 기본적인 작업을 하기에도 턱없이 부적절하다는 점입니다. 우리는 스레드를 생성할 뿐 아니라, 그것을 조인하고, 스레드 경계를 넘어 값을(오류 포함!) 보내고 싶어 합니다. 작업이 끝나거나, 키보드 입력을 기다리거나, 메시지 전달 중 하나를 기다리고 싶어 하죠.
그러니 잠시 스레드에 집중해 봅시다. 앞서 말했듯이 우리가 원하는 것은 어느 함수에서나 양보/중단할 수 있는 능력입니다. 스레드가 바로 그걸 해줍니다!
여기서 제가 말하는 “스레드”는 특정 구현을 가리키지 않습니다. 위에서 프로미스 예를 잠시 떠올려 보세요. 우리는 “잠들기(sleep)” 개념을 가졌지만, 그것이 어떻게 구현되는지는 말하지 않았습니다. 분명 어떤 스케줄러가 그걸 가능하게 하지만, 그것이 어떻게 이루어지는지는 언어의 범위를 벗어납니다. 스레드도 그럴 수 있습니다. 실제 OS 스레드일 수도 있고, 가상일 수도 있으며, 파이버나 코루틴으로 구현될 수도 있습니다. 결국 언어가 이를 제대로 제공한다면, 개발자로서 그것이 무엇인지 반드시 신경 쓸 필요는 없습니다.
이 점이 중요한 이유는, “중단”이나 “다른 곳에서 계속”을 말하면 즉시 코루틴이나 파이버가 떠오르기 때문입니다. 많은 언어가 그것들을 지원하면 그러한 능력을 주기 때문이죠. 하지만 잠시 물러서서 우리가 원하는 전반적 역량에 대해 생각하고, 구현 방식에 집착하지 않는 것이 좋습니다.
우리는 “이걸 동시에 실행하지만, 지금은 반환을 기다리지 말고, 나중에(혹은 영원히) 기다리자”고 말할 방법이 필요합니다. 어떤 언어에서는 async 함수를 호출하되 await하지 않는 것과 같은 동등물입니다. 다시 말해, 함수 호출을 스케줄링하는 것입니다. 이것이 본질적으로 스레드를 생성(spawn)하는 일입니다. 스크래치를 생각해 보면, 동시성이 자연스러운 이유 중 하나는 그것이 정말 잘 통합되어 있으며 언어의 핵심 역량이기 때문입니다. 매우 비슷하게 작동하는 진짜 프로그래밍 언어가 있습니다. 바로 고루틴을 가진 Go입니다. 문법이 있습니다!
이제 우리는 생성할 수 있고, 그게 실행됩니다. 하지만 이제 더 많은 문제를 해결해야 합니다. 동기화, 기다림, 메시지 전달 등등이 해결되지 않았습니다. 스크래치에도 그에 대한 답이 있습니다! 그러니 이걸 작동시키려면 분명히 다른 무언가가 필요합니다. 그리고 그 spawn 호출은 대체 무엇을 반환해야 할까요?
async/await에는 아이러니가 있습니다. 여러 언어에 존재하고, 표면상으로는 완전히 똑같이 보이지만, 내부 작동은 완전히 다릅니다. 심지어 각 언어에서 async/await의 기원도 같지 않습니다.
앞서 임의로 블록할 수 있는 코드는 일종의 추상화라고 했습니다. 많은 애플리케이션에서 이 추상화가 의미를 갖는 건, 블록하는 동안 CPU 시간을 다른 유용한 일에 쓸 수 있을 때입니다. 한편으로는 컴퓨터가 순차적으로만 일한다면 심심할 것이고, 다른 한편으로는 병렬로 실행해야 할 때가 있기 때문입니다. 때로는 프로그래머로서 두 일을 동시에 진행해야만 계속할 수 있습니다. 스레드를 더 만드는 이유입니다. 하지만 스레드가 그렇게 좋은데, 왜 async/await의 바탕이 되는 코루틴과 프로미스가 그토록 많이 이야기될까요?
이 지점에서 이야기는 금세 혼란스러워집니다. 예컨대 JavaScript는 Python, C#, Rust와 전혀 다른 도전을 겪습니다. 그런데도 모두 어떤 형태의 async/await로 귀결되었습니다.
JavaScript부터 시작해 봅시다. JavaScript는 단일 스레드 언어이며, 함수 스코프는 양보(yield)할 수 없습니다. 언어에 그런 역량이 없고, 스레드도 없습니다. 그래서 async/await 이전에는 콜백 헬의 다양한 형태가 최선이었습니다. 경험을 개선하는 첫 단계는 프로미스를 추가하는 것이었습니다. 그 다음에 async/await가 주로 그것의 설탕(sugar)으로 추가됐습니다. JavaScript에 선택지가 별로 없었던 이유는 프로미스가 언어 변경 없이 이룰 수 있는 유일한 것이었고, async/await는 트랜스파일링으로 구현할 수 있었기 때문입니다. 즉 JavaScript에는 진짜 스레드가 없습니다. 하지만 흥미로운 일이 하나 있습니다. 언어 수준에서 동시성 개념이 있다는 점입니다. setTimeout을 호출하면, 런타임에 “나중에 이 함수를 호출하라”고 스케줄링을 지시합니다. 이것이 핵심입니다! 특히 프로미스가 생성되면 자동으로 스케줄됩니다. 잊어버려도 실행됩니다!
반면 Python의 기원은 완전히 다릅니다. async/await 이전에 파이썬에는 이미 스레드—실제 운영체제 수준 스레드—가 있었습니다. 다만 그 스레드 여러 개가 병렬로 달릴 수 없었죠. 그 유명한 GIL(Global Interpreter Lock) 때문입니다. 하지만 그것은 “단지” 한 코어 이상으로 스케일하지 못하게 만들 뿐이니 잠시 무시합시다. 스레드가 있었기에, 꽤 이른 시기부터 파이썬에서는 가상 스레드를 구현하려는 실험이 있었습니다. 그 당시(그리고 어느 정도는 지금도) OS 수준 스레드의 비용이 꽤 높았기 때문에, 가상 스레드는 더 많은 동시 작업을 빠르게 생성하는 방법으로 여겨졌습니다. 파이썬이 가상 스레드를 얻은 방식은 두 가지였습니다. 하나는 Stackless Python 프로젝트로, 파이썬의 대체 구현(정확히는 CPython에 대한 다수 패치)이었고, “스택 없는 VM”(즉 C 스택을 유지하지 않는 VM)을 구현했습니다. 간단히 말해, 그것이 가능하게 한 것은 “태스크렛”이라 불린 것으로, 일시 중단 및 재개할 수 있는 함수였습니다. 하지만 스택이 없다는 특성 때문에 Python -> C -> Python 호출이 뒤얽힌 상태에서는 중단할 수 없었고, Stackless는 밝은 미래를 갖지 못했습니다.
두 번째 시도는 “greenlet”이었습니다. greenlet은 커스텀 확장 모듈에서 코루틴을 구현했습니다. 구현은 꽤 괴랄하지만 협력적 멀티태스킹을 허용합니다. 그러나 Stackless처럼 이것도 승자가 되지는 못했습니다. 대신, 파이썬이 수년간 갖고 있던 제너레이터 시스템이 점진적으로 코루틴 시스템으로 업그레이드되고 문법 지원이 붙었으며, 그 위에 async 시스템이 구축되었습니다.
그 결과 중 하나는 코루틴에서 중단하려면 문법적 지원이 필요하다는 점입니다. 즉, 호출 시 스케줄러에 양보하는 sleep 같은 함수를 구현할 수 없습니다. 반드시 await해야 합니다(초기에는 yield from을 쓸 수 있었습니다). 파이썬에서 코루틴이 내부적으로 작동하는 방식 때문에 우리는 async/await를 갖게 되었습니다. 언제 중단되는지 알 수 있다는 점이 긍정적으로 여겨졌기 때문입니다.
파이썬 코루틴 모델의 흥미로운 결과 중 하나는 최소한 코루틴 수준에서는 OS 스레드를 초월할 수 있다는 점입니다. 한 스레드에서 코루틴을 만들고, 다른 스레드로 보내 거기서 이어서 실행할 수 있습니다. 실제로는 IO 시스템에 연결되는 순간, 다른 스레드의 다른 이벤트 루프로 이동할 수 없게 되므로 잘 되지 않습니다. 하지만 근본적으로 JavaScript와는 꽤 다르다는 점을 이미 볼 수 있습니다. 스레드 간에 이동할 수 있고(이론상), 스레드가 있으며, 양보를 위한 문법이 있습니다. 파이썬의 코루틴은 또한 JavaScript와 달리 기본적으로 시작하자마자 실행 스케줄링되는 것이 아니라, 처음에는 실행되지 않은 상태로 시작합니다. 이는 부분적으로 파이썬에서 스케줄러를 교체할 수 있고, 경쟁하는 호환 불가능한 구현들이 있기 때문이기도 합니다.
마지막으로 C#을 이야기합시다. 여기의 기원은 또 완전히 다릅니다. C#에는 진짜 스레드가 있습니다. 객체별 락도 있고, 병렬로 달리는 여러 스레드를 다루는 데 아무 문제도 없습니다. 하지만 그렇다고 해서 다른 문제가 없는 것은 아닙니다. 스레드만으로는 충분치 않습니다. 스레드 간 동기화와 통신이 자주 필요하고, 때로는 그냥 기다려야 합니다. 예컨대 사용자 입력을 기다려야 할 때가 있죠. 입력을 처리하느라 막혀 있어도 다른 일을 하고 싶습니다. 그래서 .NET은 시간이 지나며 비동기 연산에 대한 추상화인 “태스크”를 도입했습니다. 이것은 .NET 스레딩 시스템의 일부이며, 그 안에 코드를 쓰고 문법으로 태스크에서 중단할 수 있습니다. .NET은 태스크를 현재 스레드에서 실행하고, 진짜 블로킹을 하면 그대로 막힙니다. 이런 점에서, 새로운 “스레드”가 생기지는 않지만 스케줄러 안에서 실행을 대기 상태로 미루는 JavaScript와는 꽤 다릅니다. 이렇게 동작하는 이유는 이 시스템의 동기가, UI 트리거된 코드가 메인 UI 스레드에 접근하되 그것을 블록시키지 않도록 하려는 데 있었기 때문입니다. 하지만 그 결과로, 실제로 막히면 뭔가를 망친 셈이 됩니다. 그래서 적어도 한때 C#이 했던 일은 await를 만날 때마다 함수들을 클로저 체인으로 쪼개 붙이는 것이었습니다. 하나의 논리적 코드 덩어리를 많은 별개 함수로 분해하는 겁니다.
Rust에 대해서는 깊게 들어가고 싶지 않지만, Rust의 async 시스템은 아마 가장 기묘합니다. 폴링 기반이기 때문입니다. 요컨대 태스크가 완료되기를 적극적으로 “기다리지” 않으면 진행이 되지 않습니다. 스케줄러의 목적은 태스크가 실제로 진행할 수 있도록 해 주는 것입니다. Rust가 왜 async/await로 귀결됐는가? 주로 런타임과 스케줄러 없이도 작동하고, 빌림 검사기와 메모리 모델의 제약 안에서 일하기를 원했기 때문입니다.
이 언어들 중 async/await의 타당성이 가장 강한 곳은 Rust와 JavaScript라고 봅니다. Rust는 시스템 언어이고 제한된 런타임에서도 작동하는 디자인을 원했기 때문입니다. JavaScript도 이해가 됩니다. 언어에 진짜 스레드가 없으므로 async/await의 유일한 대안은 콜백뿐입니다. 하지만 C#에서는 그 논거가 훨씬 약해 보입니다. UI 스레드에서 코드 실행을 강제해야 하는 문제조차, 가상 스레드를 위한 스케줄링 정책으로 해결할 수 있습니다. 제게 가장 큰 문제는 파이썬입니다. async/await는 언어에 코루틴과 진짜 스레드, 각기 다른 동기화 프리미티브, 그리고 결국 하나의 OS 스레드에 고정(pinned)되는 async 태스크까지 도입해 매우 복잡한 시스템으로 귀결되었습니다. 심지어 표준 라이브러리에는 스레드용과 async 태스크용 서로 다른 future가 존재합니다!
이 모든 것을 이해하길 바랐던 이유는, 모든 언어가 같은 문법을 공유하지만, 그 문법으로 할 수 있는 일이 완전히 다르기 때문입니다. 공통점은, async 함수는 async 함수(또는 스케줄러)만 호출할 수 있다는 것입니다.
수년 동안 파이썬이 왜 async/await로 귀결됐는지에 관해 많은 주장을 들었는데, 그중 일부는 제 시각에서 보면 검증을 통과하지 못합니다. 반복해서 들었던 주장 하나는, 언제 중단할지 통제할 수 있다면 락이나 동기화를 다룰 필요가 없다는 것입니다. 약간은 사실입니다(임의로 중단하지 않으니까요). 하지만 결국 락을 잡아야 합니다. 여전히 동시성이 존재하므로, 여전히 모든 것을 보호해야 합니다. 특히 파이썬에서는 더욱 답답합니다. 색깔 있는 함수만 있는 게 아니라 색깔 있는 락도 있습니다. 스레드용 락과 async 코드용 락이 있으며, 서로 다릅니다.
위에서 세마포어 예를 보여준 데에는 매우 좋은 이유가 있습니다. 세마포어는 async 프로그래밍에서 실제로 쓰입니다. 시스템이 너무 많은 일을 떠맡지 않도록 보호하는 데 자주 필요합니다. 사실 많은 async/await 기반 프로그램이 겪는 핵심적인 문제 중 하나는, 백프레셔를 가하기 어려워 버퍼가 뚱뚱해진다는 점입니다(다시 한 번 그 글을 가리킵니다). 왜 그럴까요? API가 async가 아니라면, 버퍼링하거나 실패하는 수밖에 없기 때문입니다. 블록할 수는 없습니다.
Async는 또한 파이썬의 GIL 문제를 마법처럼 해결해 주지 않습니다. JavaScript에 진짜 스레드를 마법처럼 만들어 주지도 않습니다. 임의의 코드가 블록하기 시작할 때의 문제도 해결하지 못합니다(기억하세요, 메모리 접근조차 블록할 수 있습니다). 혹은 큰 피보나치 수를 아주 천천히 계산할 때도 마찬가지입니다.
위에서 몇 번 언급했듯이, 임의 지점에서 “중단”할 수 있음을 떠올리면 우리는 프로그래머로서 즉시 코루틴을 생각합니다. 이유가 있습니다. 코루틴은 놀랍고, 재미있으며, 모든 프로그래밍 언어가 가져야 합니다!
코루틴은 중요한 빌딩 블록입니다. 미래의 언어 설계자가 이 글을 본다면: 반드시 넣으세요.
하지만 코루틴은 매우 경량이어야 하며, 남용되면 무슨 일이 벌어지는지 따라가기 어렵게 만들 수 있습니다. 예컨대 Lua는 코루틴을 제공하지만, 그것으로 무언가를 하기 쉽게 만드는 구조를 제공하지 않습니다. 결국 자신만의 스케줄러, 자신만의 스레딩 시스템 등을 만들게 됩니다.
그래서 우리가 정말 원하는 것은 출발점으로 돌아가는 것입니다. 스레드! 옛날 좋은 스레드!
이 모든 것의 아이러니는, 제가 실제로 올바르게 했다고 생각하는 언어가 현대 Java라는 점입니다. Java의 Project Loom은 내부적으로 코루틴과 온갖 장치를 갖고 있지만, 개발자에게 노출하는 것은 옛날 좋은 스레드입니다. 가상 스레드가 있고, 이것이 캐리어 OS 스레드 위에 장착되며, 가상 스레드는 스레드 간을 이동할 수 있습니다. 가상 스레드에서 블로킹 호출을 하면, 스케줄러에 양보합니다.
이제 저는 스레드만으로는 충분하지 않다고 생각합니다! 스레드는 동기화가 필요하고, 통신 프리미티브가 필요합니다. 스크래치에는 메시지 전달이 있습니다! 그러니 이것들이 잘 작동하려면 더 많은 것이 구축되어야 합니다.
async/await가 분명 혁신한 점은, 이러한 핵심 역량을 언어 사용자에게 더 가깝게 가져왔다는 것입니다. 그리고 종종 현대 async/await 코드는 전통적 스레드 기반 코드보다 읽기 쉬워 보입니다. 스레드를 더 쉽게 다루려면 무엇이 필요한지에 관해, 이 주제는 다른 글로 이어가겠습니다.
마지막으로 async/await에 대해 좋은 말을 하고, 그것이 가져온 혁신을 기념하고 싶습니다. 저는 이 언어 기능이 동시성 프로그래밍에 중요한 혁신을 널리 퍼뜨리는 데 단독으로 기여했다고 믿습니다. 특히, 많은 개발자를 기본적인 “요청당 단일 스레드” 모델에서 작업을 더 작은 조각으로 나누는 방향으로 움직이게 했습니다. 심지어 파이썬 같은 언어에서도요. 개인적으로 가장 큰 혁신은 Trio라고 봅니다. Trio는 너서리(nursery)를 통해 구조적 동시성 개념을 도입했습니다. 그 개념은 결국 asyncio에서도 TaskGroup API라는 형태로 자리를 잡았고, Java에도 도입되고 있습니다.
더 나은 소개는 Nathaniel J. Smith의 Notes on structured concurrency, or: Go statement considered harmful을 읽어 보라고 권합니다. 하지만 익숙하지 않다면, 여기 제 설명을 시도해 보겠습니다:
저는 구조적 동시성이 스레드 세계에서도 반드시 필요하다고 믿습니다. 스레드는 부모와 자식을 알아야 합니다. 스레드는 또한 성공 값을 전달하는 쉬운 방법을 가져야 합니다. 마지막으로 컨텍스트는 컨텍스트 로컬을 통해 스레드 간에 암묵적으로 흐를 수 있어야 합니다.
두 번째는 async/await가 태스크/스레드끼리 서로 대화해야 함을 훨씬 더 분명하게 만들었다는 점입니다. 특히 채널과 채널에 대한 선택(select) 개념이 더 널리 퍼졌습니다. 이것은 필수적인 빌딩 블록이며, 더 개선될 수 있다고 봅니다. 생각거리로: 구조적 동시성이 있다면, 원칙적으로 각 스레드의 반환 값은 스레드에 붙은 버퍼드 채널(성공 반환 값 또는 오류라는 최대 한 개의 값만 담는)로 표현될 수 있고, 거기에 대해 select할 수 있습니다.
오늘날 비록 이 모델을 완벽히 구현한 언어는 없지만, 수년의 실험 덕분에 해법은 그 어느 때보다 명확해졌습니다. 구조적 동시성이 그 핵심입니다.
async/await가 명암이 엇갈렸다는 것을 설득할 수 있었길 바랍니다. 콜백 헬에서 어느 정도 해방시켜 주었지만, 색깔 있는 함수, 새로운 백프레셔 도전 과제, 영영 resolve되지 않은 채 방치될 수 있는 프로미스 같은 새로운 문제를 떠안겼습니다. 특히 디버깅과 프로파일링에서 호출 스택이 주던 많은 유틸리티도 가져가 버렸습니다. 이것들은 사소한 삐걱거림이 아니라, 우리가 지향해야 할 직관적이고 단순한 동시성을 가로막는 실제 장애물입니다.
한 걸음 물러서서 보면, 진짜 스레드가 있는 언어에서 async/await를 채택한 것은 우리가 경로를 이탈한 것처럼 보입니다. Java의 Project Loom 같은 혁신이 여기에 잘 맞습니다. 가상 스레드는 필요할 때 양보하고, 블록되면 컨텍스트를 전환하며, 동시성이 자연스럽게 느껴지는 메시지 전달 시스템과도 잘 작동합니다. 함수형·프로미스 시스템이 모든 문제를 해결했다고 믿는 생각에서 벗어난다면, 우리는 스레드를 제대로 다시 바라볼 수 있습니다.
하지만 동시에 async/await는 동시성 프로그래밍을 전면에 끌어올렸고, 실제 혁신을 낳았습니다. 문법을 통해서라도 동시성을 언어의 핵심 기능으로 만드는 것은 좋은 일입니다. 채택이 늘고 사람들이 고생한 덕분에, 파이썬 async/await 세계에서 구조적 동시성이 실체가 되었는지도 모릅니다.
미래의 언어 설계는 동시성을 다시 생각해야 합니다. async/await를 채택하기보다, Java의 Project Loom을 본받되 더 사용자 친화적인 프리미티브를 제공해야 합니다. 하지만 스크래치처럼, 동시성을 자연스럽게 만드는 훌륭한 API를 제공해야 합니다. 저는 액터 프레임워크가 정답이라고는 보지 않지만, 구조적 동시성, 채널, spawn/join/select에 대한 문법 지원의 조합만으로도 큰 진전을 이룰 수 있다고 봅니다. 무엇이 더 잘 작동했는지에 관한 제 다음 글을 기대해 주세요.
이 글의 태그는 async, javascript, python, rust, thoughts 입니다