비동기 코드의 목적, 보장하는 것과 보장하지 않는 것, 그리고 스레드·프로세스·청크화·async/await·M:N 스케줄러 사이의 차이를 살펴본다.
최근에 비동기 코드에 대해 몇 번 이야기를 나눴고(그리고 그보다 조금 더 예전에도), 비동기에 대해 잘못된 가정을 하는 것처럼 보이는 코드도 좀 보게 되어서, 이제는 비동기, 그것이 무엇을 위한 것인지, 무엇을 보장하는지와 무엇을 보장하지 않는지에 대해 진지하게 이야기할 때가 되었다고 생각했다.
이 글에 나오는 코드의 대부분은 Python 문법(그리고 종종 Python 라이브러리)으로 작성하겠지만, 몇 가지 사소한 예외를 빼면 여기서 다룰 개념은 언어를 가로질러 유효하다.
우리 모두 성능이 무엇인지는 안다, 그렇지 않은가? 이를테면 우리가 코드로 수행하고 싶은 어떤 작업이 있고(화면에 무언가를 표시하거나, 데이터베이스에 데이터를 요청하거나, 파일을 내려받거나, 수학 문제를 푸는 등), 그 작업은 가능한 한 빨리 끝나야 한다. 덤으로 메모리도 가능한 한 적게 써야 한다.
그렇지?
음… 꼭 그렇지는 않다.
이런 종류의 성능은 _처리량_이라고 부른다. 그리고 높은 처리량은 거의 언제나 좋은 것이다. 하지만 21세기에는 일반적으로 처리량에만 집중하는 것이 잘못된 목표다.
요즘 대부분의 애플리케이션1은 대략 이런 모습이다.
while True:
while (event := get_next_event()):
on_event(event)
wait_until_there_is_an_event()
어쩌면 여러분이 이 루프를 직접 작성하고 있을 수도 있지만, 보통은 아니다. asyncio.run이나 #[tokio::main]이나 Eio_main.run을 쓰고 있을 수도 있고, 브라우저나 Node.js나 BEAM에서 코드를 실행하고 있을 수도 있으며, 다른 여러 방식이 있을 수도 있지만, _이벤트 루프_에서 벗어나고 있는 것은 아니다.
비디오 게임을 만들고 있다면 사용자가 키를 누를 때마다 이벤트를 받고, 화면을 다시 그릴 때가 되어도 이벤트를 받는다. GUI 도구를 만들고 있다면 사용자가 버튼을 클릭할 때마다 이벤트를 받는다. 웹 서버를 만들고 있다면 연결이 들어오거나 데이터를 받을 때마다 이벤트를 받는다.
그리고 그렇다, 작업을 빨리 끝내고 싶다. 사실 사용자와 직접 상호작용하는 애플리케이션이라면, 그것이 워드 프로세서든 비디오 게임이든, 작업을 끝내는 데 대략 16ms 정도가 있다. 16ms 안에 할 수 있는 일은 많다. 하지만 할 수 없는 일도 꽤 많고, 그 안에는 파일 열기 2나 웹 서버의 응답 받기 3도 포함된다.
웹 서버를 만들고 있다면 여유는 좀 더 있다. 대부분의 경우 1초 정도는 기다릴 수 있고, 어쩌면 2초도 가능하다. 하지만 웹 서버가 완료해야 할 작업 중에는 2초를 넘는 것이 많다. 예를 들어 바쁜 데이터베이스에서 많은 데이터를 추출하는 일이나, LLM으로부터 조금이라도 말이 되는 응답을 얻어내는 일 같은 것이다.
그렇다면… 이제 어떻게 할까?
이제 우리는 성능을 다시 정의해야 한다. 사실 성능에는 여러 정의가 있다. 이번 논의에서 집중할 것은 _지연 시간_이다. 즉, _무언가_가 일어나기까지 얼마나 걸리는가 하는 것이다. 파일 열기를 끝내지 못했을 수도 있고, 웹 서버 응답을 받지 못했을 수도 있고, Claude로부터 말이 되는 것과 비슷한 무언가도 아직 받지 못했을 수 있지만, 그래도 여러분은 _무언가_를 빠르게 응답해야 한다.
참고로 Response Time Limits라는 개념에 관심이 있다면, 이는 1993년까지 거슬러 올라간다: https://www.nngroup.com/articles/response-times-3-important-limits/. 기억이 맞다면 Microsoft도 원래의 Surface 테이블(태블릿 이전의 그 테이블)을 개발할 때 추가 연구를 진행했다. DoubleClick/Google도 웹 애플리케이션과 모바일 웹 애플리케이션의 경우에 대한 추가 정교화를 발표한 바 있다. 아쉽게도 링크는 찾지 못했다.
간단한 예부터 시작해 보자.
class ComputeFibonacciEvent:
arg: int
def fibonacci(n: int) -> int:
if n <= 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
def on_event(event):
if isinstance(event, ComputeFibonacciEvent):
result = fibonacci(event.arg)
print(f"fibonacci({event.arg})={result}")
else:
...
그렇다, Fibonacci를 더 효율적으로 다시 쓸 수 있다는 건 아주 잘 알고 있다. 하지만 이 끔찍하게 비효율적인 구현을 그대로 두자. 느리게 실행되는 다른 어떤 작업으로 바꿔 생각해도 좋다.
이제, 우리 이벤트 루프는 16ms 예산 안에 들어올까? arg 값이 충분히 크다면 아닐 수도 있다. 그런데 16ms 예산을 넘기면, 애플리케이션이 화면을 다시 그리거나 새 HTTP 요청을 받는 등의 일을 하지 못하도록 막아버리게 되고, 그건 좋지 않다.
그렇다면 계산이 이 제한 안에 들어오게 하려면 어떻게 해야 할까?
해법은 많지만, 모두 다음 아이디어의 변형이다.
fibonacci를 논블로킹으로 만들어라.
논블로킹은 웹에서 가장 흔히 보게 되는 단어는 아니다. 대신 asynchronous, concurrent, parallel 같은 말을 자주 보게 된다. 이 네 가지는 서로 다른 개념인데, 사람들은 절망적일 정도로 자주 헷갈린다.
코드가 어떤 중요한 스레드도 절대 막지 않는다면 그 코드는 논블로킹이다.
이번 이야기에서 우리가 관심 있는 스레드는 이벤트 루프를 담고 있는 스레드다. 논블로킹은 목표다. 또한 이는 라이브러리나 운영체제가 제공하는 일부 함수가 줄 수 있는 보장이기도 하다.
그럼 이것을 어떻게 달성할까? 음, 바로 이 글 전체가 그 이야기다.
코드가 실행들 사이의 명시적인 의존성을 드러내도록 _구조화_되어 있다면 그 코드는 비동기적이다.
비동기는 코드 구조에 관한 것이다. 보통은 콜백이나 이벤트, 또는 그 위의 추상화가 여기에 들어간다.
비동기라고 해서 여러분의 코드가 논블로킹이라고 보장하지는 않는다. 사실 유일한 보장은, 코드를 논블로킹으로 리팩터링하더라도 모든 것이 다 망가지지는 않는다는 정도다.
서로 독립적인 작업을 실행하도록 스케줄할 수 있다면 그 코드는 동시적이다.
동시성 역시 프로그래밍 스타일이다. 동시성은 작업이 언제 실행되는지 보장하지 않는다. 어떤 동시성 툴킷은 단순히 한 작업이 끝날 때까지 기다린 뒤 다음 작업을 실행한다. 또 어떤 동시성 프리미티브는 두 동시 작업의 실행을 서로 끼워 넣어, 각각이 꾸준히 진전되도록 시도한다. 이것이 자동으로 이루어지면 선점형 멀티태스킹이라 하고, 코드에 특정한 표시가 필요하면 협력형 멀티태스킹이라 한다. 대부분의 개발자는 “동시적”이라는 말을 어떤 종류의 멀티태스킹이 포함될 때만 쓴다.
동시성 역시 어떤 연산이 논블로킹임을 보장하지 않는다.
동시적과 비동기적은 자주 혼동되지만 다른 개념이다.
par가 없으면 작업을 시작할 수 없다.두 작업이 같은 물리적 순간에 실행될 수 있다면 그 코드는 병렬적이다.
병렬성은 언어, 운영체제, 하드웨어, 시스템 부하의 성질이다. 어떤 실행에서는 병렬로 실행되던 코드가 다른 실행에서는 순차적으로 실행될 수 있다.
병렬성이 보장된다면, 그것을 이용해 어떤 연산이 논블로킹임을 보장할 수 있다.
동시성과 병렬성도 자주 혼동되지만 다르다.
우리는 21세기에 살고 있으므로(대부분의 플랫폼에서는) 스레드를 사용할 수 있다. 스레드는 언제나 동시성을 달성하는 수단이며, 자원 제약과 프로그래밍 언어에 따라 병렬성을 달성하는 수단이 될 수도 있다.
한번 써 보자.
import threading
def on_event(event):
if isinstance(event, ComputeFibonacciEvent):
def background():
result = fibonacci(event.arg)
print(f"fibonacci({event.arg})={result}")
thread = threading.Thread(target=background)
thread.start()
else:
...
간단히 벤치마크해 보니 내 컴퓨터에서 Python에서 각 스레드를 만들고 시작하는 데 약 6µs가 걸린다. 그러니 예산 안에는 충분히 들어간다. 그렇다, Fibonacci를 백그라운드에서 실행하는 데는 여전히 임의의 시간이 걸릴 수 있다. 하지만 그건 예상된 일이다. 그럼… 임무 완수인가?
음… 그렇기도 하고 아니기도 하다.
여러분이 좋아하는 웹 백엔드나 비디오 게임이나 데스크톱 애플리케이션을 보면, 개발자들이 이 해법을 선택하지 않았음을 알 수 있다.
이유의 일부는 난이도다. 스레드로 프로그래밍하는 일은 오랫동안 필멸자에게는 너무 어렵다고 여겨져 왔고, thread-safety, mutexes, atomic operations, 그리고 때로는 thread-local storage를 사용하고(그리고 이해해야!) 하기 때문이다. 예시의 구체적인 경우는 자명하게 thread-safe해 보이지만, 겉으로는 thread-safe처럼 보이면서도 어딘가 숨겨진 전역 상태에 의존하는 코드를 쓰기는 정말 쉽다(예를 들어 Python 라이브러리에서 매우 흔하다). 또 나는 mutex를 잘못 사용하는 코드(보통은 잘못된 변수를 보호하거나 잘못된 시점에 보호하거나, 때로는 mutex로 메인 스레드를 막아 버려서 전체 시도를 무의미하게 만드는 경우)나 atomicity를 잘못 사용하는 코드(메모리 모델을 오해해서)를 꽤 여러 번 봤다. 그러니 스레드를 조심스럽게 대하는 것은 아주 타당하다.
실제로 오늘날까지도 나는 멀티스레딩 안전 GUI 툴킷을 알지 못한다. 그리고 Go처럼 멀티스레드 코드를 쉽게 쓰게 해주는 언어조차도 올바른 멀티스레드 코드를 쉽게 쓰게 해주지는 않는다 4. Rust stdlib조차도 오랫동안 한 함수를 잘못 두고 있었다.
혹은 David Baron의 말을 빌리자면:
멀티스레드 코드를 쓰려면 이 정도 키는 되어야 한다(약 2.5m)
참고로 위 코드 조각이 자명하게 안전하다고 썼지만, 사실 그것조차 확실하지 않다. 여러 스레드가 동시에 print를 호출하면 어떻게 될까? 이런 경우 C의 원래 printf 구현은 온갖 메모리 손상을 일으키곤 했다. 요즘은 아마 안전할 것이다… 하지만 그걸 어떻게 확인할까? Rust는 stdout과 stderr 주위에 명시적으로 락을 사용해 스레딩 문제를 피하지만, 대부분의 다른 언어나 프레임워크는 그렇지 않다.
또 다른 이유는 자원 제한이다. 각 프로세스는 유한한 수의 스레드만 시작할 수 있다. 각 사용자는 유한한 수의 스레드만 시작할 수 있다. 각 커널도 유한한 수의 스레드만 시작할 수 있다. 그리고 애플리케이션을 작성하고 배포할 때, 우리는 그 수를 모르는 경우가 많다. 즉, 그것에 의존할 수 없다는 뜻이다. 아는 바로는 여러분의 애플리케이션이 스레드 지원 없이 실행될 수도 있다(나는 Docker 배포에서 한 번 이 일을 본 적이 있다).
아, 그리고 스레드는 보통 메모리도 좀 먹는다(마지막으로 확인했을 때 Linux에서 물리 메모리 8kb, 가상 메모리 8Mb). 이것도 스레드를 복잡한 제안으로 만드는 데 조금 기여한다. 어떤 연산을 논블로킹으로 만들기 위해 스레드를 시작하는데, 스레드 시작이 실패할 수도 있다면(때로는 잡기 어려운 방식으로), 그 실패를 어떻게 처리해야 할까? 아예 처리할 수는 있을까?
이런 자원 제한 때문에 웹 백엔드는 스레드에만 의존할 수 없다. 사용자(악의적일 수도 있고, 버그 있는 클라이언트를 쓸 수도 있고, Hacker News에 글이 올라간 뒤 한꺼번에 몰려들 수도 있다)가 여러분이 얼마나 많은 자원을 쓰는지를 제어하게 두는 것은 대개 좋은 생각이 아니기 때문이다.
물론 이런 자원 제한은 수십 년 전부터 알려져 있었고, thread pool을 사용해 우회해 왔다.
from concurrent.futures import ThreadPoolExecutor
thread_pool = ThreadPoolExecutor() # 사용할 스레드 수에 제한을 둔다.
def on_event(event):
if isinstance(event, ComputeFibonacciEvent):
def background():
result = fibonacci(event.arg)
print(f"fibonacci({event.arg})={result}")
thread_pool.submit(background)
else:
...
그리고 많은 경우에(다시 말하지만 코드가 thread-safe하다는 가정 아래) 이것은 잘 작동한다.
언제 작동하지 않을까?
첫째, 라이브러리를 작성 중이라면 대개 작동하지 않는다. 호출자의 스레드 정책을 모르기 때문에, thread pool을 사용함으로써 호출자 코드에 스레드 불안정을 우연히 도입할 수 있고, thread pool 수를 늘려 제약 한도를 깨뜨릴 수도 있다. 그러지 말자. thread pool을 쓰고 싶다면, 클라이언트 코드가 그것을 제공하게 하라.
두 번째 문제는 스레드가 어떤 이유로 막혀 있을 때 생긴다. 예를 들어 어떤 스레드가 데이터베이스나 원격 서버에 접근해야 하거나, sleep을 호출하거나, 혹은 어떤 이유로 print가 느린 파일로 리디렉션되어 있을 수 있다. 이런 경우 아무 일도 하지 않는 스레드(정확히는 여러분 코드가 제어하지 않는 어떤 활동의 완료를 기다리는 스레드)로 thread pool이 금방 포화될 수 있다. 이후의 작업은 기다리고 있던 스레드 중 하나가 일을 마칠 때까지 그냥 기다려야 한다.
즉, 처리량을 완전히 잃어버린 것이다. 웹 서버를 만들고 있다면, 이는 갑자기 모든 사용자를 서비스하기 위해 웹 서버를 더 많이 띄워야 함을 의미하고, 그만큼 클라우드 비용과 지구가 치르는 에너지 비용이 증가한다. 비디오 게임을 만들고 있다면 프레임레이트는 좋아도 PC와 NPC의 행동이 굼뜨게 느껴진다. 데스크톱 앱을 만들고 있다면 UI는 반응성을 유지하지만 사용자는 영원히 기다리게 된다.
먼저 불쾌한 사실 하나를 치우고 가자. Python이나 Ruby(또는 꽤 오래된 버전의 OCaml)를 쓰고 있다면, 여러분의 스레드는 절대로 병렬로 실행되지 않는다. 이 언어들은 Global Interpreter Lock에 의존하는데, 이것은 특정 시점에 오직 하나의 스레드만 실행되도록 특별히 설계된 것이다.
왜일까? 그러면 언어 구현의 나머지 부분이 훨씬, 훨씬 단순해지기 때문이다. 특히 refcounting/garbage-collection이 그렇다. 또한 이것은 여러분의 삶을 비참하게 만들 많은 함정을 피하게 해준다. 또 코드가 진짜 병렬로 실행된다면 매우 불안전해질 수 있는 여러 최적화(VM/인터프리터 내부와 사용자 수준 모두)를 가능하게 해준다.
참고로(적어도 Python에서는) 네이티브 코드(예: PyTorch, NumPy, PyO3로 작성된 코드 등)는 _GIL을 해제_할 수 있다. 즉 그 내부는 다른 스레드를 막지 않고 백그라운드 스레드에서 실행될 수 있다. 대부분의 경우 좋은 일이지만, 네이티브 코드 개발자가 자신이 뭘 하는지 모르면 메모리 손상으로 금방 이어질 수 있다.
이게 성능에 무엇을 의미할까? 여러분의 코드가 GIL을 해제하는 코드를 호출하지 않는다면, 멀티스레드 모드가 싱글스레드 모드보다 언제나 훨씬 느리다는 뜻이다. 어떤 코드가 GIL을 해제하는지 어떻게 알까? 슬프게도 거의 문서화되어 있지 않으므로, 직접 실험해 봐야 한다.
또한 OCaml의 경우는 생태계를 GIL 기반에서 완전한 멀티코어(OCaml ≥ 5)로 옮길 수는 있음을 보여주지만, 꽤 오랜 시간이 걸릴 수 있음을 시사한다. Python도 천천히 그 방향으로 가는 듯하지만, 2030년 이전에 쓸 만한 것을 보게 될 것 같지는 않다5.
운영체제 스레드는 선점형 멀티태스킹을 제공하기 위해 _문맥 전환_이 필요하다. 즉 어떤 스레드가 CPU/코어에서 실행되다가, 그 스레드를 멈추고 다른 코드가 그 CPU/코어에서 실행되도록 하는 것이다. 사용자에게는 투명하지만 비용이 있다.
대략적으로 말해 두 작업 사이를 문맥 전환하려면 OS 스케줄러는 다음을 해야 한다.
이 모든 것에는 비용이 든다.
벤치마크하기 귀찮아서 직접 하지는 않았지만, 내 컴퓨터보다 최신인 머신에서 각 문맥 전환마다 2-5µs가 든다는 벤치마크를 본 적이 있다. 이것은 여러분에게 필요한 일을 하지 않는 코드를 실행하느라 쓰는 시간이다. 만약 여러분 코드가 코어당 초당 5000번 문맥 전환해야 한다면(임의의 숫자다), 문맥 전환만으로 예산 10-25ms를 먹어치운다. 그러니 이는 대략 초당 한 프레임 정도에 해당하고, 어딘가에서 보상해야 할 수도 있다 6.
물론 스레드에는 다른 비용도 있다. 획득/해제해야 하는 모든 락에는 순수 동기화 비용이 있고, 여기에 경쟁 비용이 더해진다. 특히 메인 스레드에서 락을 잡는 것은 매우 피하고 싶다. 그러면 스레드 동기화가 블로킹 연산이 되기 때문이다. 수행해야 하는 atomic operation조차 성능 비용을 가질 수 있고, 특히 캐시에 영향을 준다. 등등.
대부분의 경우 여러분은 이런 것을 신경 쓰지 않을 것이다. 특히 Python을 쓰고 있다면 더 큰 성능 문제가 있다. 하지만 성능 민감한 코드(예: 비디오 게임, 비디오 플레이어, 브라우저)를 작성한다면, 스레드는 해법의 일부일 뿐 아니라 때로는 여러분이 다뤄야 할 성능 문제의 일부이기도 하다.
green thread는 순수하게 사용자 영역에서 구현된 스레드다. 즉 스레드처럼 동작하지만 OS 수준 스케줄링을 거치지 않는다.
green thread 사이의 스케줄링은 OS 스레드 스케줄링과 매우 비슷하지만, 두 가지 때문에 더 빠르다.
마찬가지로 락 동기화도 더 빠를 수 있다.
반면 순수 green thread는 여러 코어나 CPU의 이점을 누릴 수 없으므로, 이런 스레드의 유용성이 크게 떨어진다.
오늘날 순수 green thread를 사용하는 일은 꽤 드물다. 하지만 몇몇 언어는 green thread와 OS 스레드를 결합한 이른바 M:N 스케줄러를 제공한다. Go를 다루는 절에서 이 이야기를 더 하겠다.
즉, 스레드는 어떤 해법이든 필요한 구성 요소이긴 하지만, 우리가 기대할 수 있는 마법의 총알은 아니다.
오랫동안 Linux는 스레드를 지원하지 않았다. 그렇다고 개발자들이 동시적/병렬 코드를 작성하지 못한 것은 아니다. 스레드 부재를 우회하는 한 방법은 여러 프로세스를 사용하는 것이었다. 마찬가지로 GIL 기반 언어에서 여러 CPU나 코어에 걸쳐 코드를 실행하는 전통적인 해법도 여러 프로세스를 사용하는 것이었다. 90년대의 OCaml에는 JoCaml이라는 방언까지 있었는데, 병렬성과 분산을 위한 꽤 훌륭한 패러다임을 제공했다.
고수준 API가 있으면 프로세스에서 실행을 시작하는 일은 꽤 단순하다.
from concurrent.futures import ProcessPoolExecutor
process_pool = ProcessPoolExecutor()
def on_event(event):
if isinstance(event, ComputeFibonacciEvent):
future = process_pool.submit(fibonacci, event.arg)
future.add_done_callback(lambda result: print(f"fibonacci({event.arg})={result}"))
else:
...
이 방식은 즉각적인 장점이 있다. 프로세스는 GIL의 제한을 받지 않으므로, 코드는 보통 병렬로 실행된다. 또한 가비지 컬렉터가 프로세스마다 독립적이므로, 한 프로세스의 느린 가비지 컬렉션이 일반적으로 다른 프로세스를 막지 않는다.
다만 이 접근에도 몇 가지 단점이 있다.
각 프로세스는 Python(혹은 Ruby, JavaScript 등)의 자체 복사본에서 돌아간다. 여기에는 표준 라이브러리뿐 아니라 모든 의존성, 가비지 컬렉터 등이 메모리에 복사되어 들어간다. 또 언어가 JIT 기반이라면 각 프로세스가 자체 JIT를 돌리므로, 모든 프로파일링 데이터와 최적화된 네이티브 코드의 자체 복사본도 가진다. 메모리 비용은 금방 불어난다.
사실 시스템 언어가 아닌 언어에서 여러 프로세스를 띄우는 것이 RAM이 공짜이고 무한하지 않다면 말이 되지 않는다고 주장할 수도 있다.
그리고 스레드 수에 제한이 있는 것처럼, 프로세스 수에도 제한이 있다.
스레드 간 통신은 간단하다. 참조를 넘겨 데이터만 보내면 된다.
하지만 프로세스 간 통신(즉 IPC)은? 이야기가 다르다. 방법은 몇 가지 있다.
shared memory를 쓸 수 있다.
그렇다. 이것은 복잡할 뿐 아니라(그 부분은 멋진 IPC 라이브러리가 사용자에게 숨겨 주지만), 비싸다.
shared memory 대신 socket이나 pipe를 사용하면 상당 부분 단순화할 수 있지만, 대신 시스템 콜을 더 많이 하게 되고, pipe I/O를 somehow 논블로킹으로 만들어야 한다. 그러면 다시 원래 문제로 돌아온다.
스레드가 (어느 정도) 느리다는 데 대해 위에서 쓴 모든 내용은 프로세스에도 해당한다. 다만 프로세스는 Process Control Block에 저장/복원해야 할 데이터가 훨씬 더 많다. 메모리 매핑, file descriptor 등이 그렇다.
또한 프로세스 간 락(다행히 프로세스 내부 락보다 훨씬 덜 필요하다)은 보통 파일 시스템을 거치므로, 스레드 간 락보다 조금 더 비싸다.
프로세스가 때때로 작업에 맞는 도구인 것은 맞지만, 그 비용은 가파르다. 그러니 문제를 해결하기 위해 프로세스를 선택할 때는 매우 신중해야 한다. 물론 프로세스밖에 쓸 수 없다면… 뭐, 선택의 여지가 없지 않은가?
스레드와 프로세스는 꽤 고수준이고 비용도 큰 구성물이다. 어쩌면 우리가 잘못된 출발을 했고, 문제를 반대쪽에서 접근하는 것이 맞을지도 모른다. fibonacci 함수를 다시 써서, 스스로 이벤트 루프를 절대 막지 않도록 수동으로 보장하면서 동시적으로 계산하게 만들면 어떨까?
물론 구현이 (꼬리재귀가 아닌) 재귀가 아니라면 더 쉬웠겠지만, 그래도 할 수는 있다.
@dataclass
class ComputeFibonacciEvent(BaseEvent):
"""
Event: We'd like to compute `fibonacci(arg)`.
"""
arg: int
"""
The value for which we wish to compute fibonacci.
"""
id: UUID
"""
A unique id for this event.
"""
parent_id: UUID | None
"""
If `None`, this is a toplevel request. Otherwise, the `id` of another
`ComputeFibonacciEvent` on behalf of which we're performing this
computation.
"""
@dataclass
class CompletedFibonacciEvent(BaseEvent):
"""
Event: We have finished computing `fibonacci(arg)`.
"""
arg: int
"""
The value for which we requested to compute fibonacci.
"""
parent_id: UUID | None
"""
If `None`, this was a toplevel request. Otherwise, the `id` of another
`ComputeFibonacciEvent` on behalf of which we're performing this
computation.
"""
result: int
"""
The value of `fibonacci(arg)`.
"""
@dataclass
class PendingFibonacci:
"""
Rendez-vous mechanism, holding the pending or partial state
of computing `fibonacci(arg)`.
"""
arg: int
"""
The value for which we requested to compute fibonacci.
"""
parent_id: UUID | None
"""
If `None`, this was a toplevel request. Otherwise, the `id` of another
`ComputeFibonacciEvent` on behalf of which we're performing this
computation.
"""
first: int | None = None
"""
If `None`, we haven't computed `fibonacci(arg - 1)` yet. Otherwise,
the value of `fibonacci(arg - 1)`.
"""
pending_fibonaccis: dict[UUID, PendingFibonacci] = dict()
"""
A mapping of event id => PendingFibonacci.
"""
def handle_event(event: BaseEvent):
if isinstance(event, ComputeFibonacciEvent):
if event.arg <= 1:
event_queue.put(CompletedFibonacciEvent(
parent_id=event.parent_id,
result=1,
arg=event.arg,
))
else:
# Enqueue the left and right computations.
event_queue.put(ComputeFibonacciEvent(
id = uuid4(),
parent_id=event.id,
arg = event.arg - 1,
))
event_queue.put(ComputeFibonacciEvent(
id = uuid4(),
parent_id=event.id,
arg = event.arg - 2,
))
# Store what we need to propagate the result.
pending_fibonaccis[event.id] = PendingFibonacci(
parent_id=event.parent_id,
arg=event.arg,
)
elif isinstance(event, CompletedFibonacciEvent):
pending = pending_fibonaccis[event.parent_id]
if pending.first is None:
pending.first = event.result
# We still need to wait for the second computation.
else:
# We have obtained both computations.
result = pending.first = event.result
if pending.parent_id is None:
#... and we're done!
print(f"fibonacci({event.arg}) = {result}")
else:
#...continue popping!
event_queue.put(CompletedFibonacciEvent(
parent_id=pending.parent,
result=result,
arg=event.arg
))
윽. 이건… 꽤 큰 재작성이다. 고작 네 줄짜리 자명한 함수를, 180줄짜리의 디버그 불가능한 괴물로 바꿔 버렸다.
하지만 메커니즘으로서는 이게 작동한다. 이벤트 루프의 각 단계에서 계산은 자명하고 논블로킹이다. 좋아, 조금 속이고 있긴 하다. print 호출은 여전히 블로킹이다. 하지만 그것도 논블로킹 호출들의 연쇄로 다시 쓸 수 있다. 실제로 Firefox나 nginx 같은 것을 보면, 이런 식으로 작성된 코드를 많이 볼 수 있다. 보통 긴 외부 연산(예를 들어 데이터베이스나 네트워크에 쓰기)에 대한 요청을 걸고, 요청이 다음 단계까지 진전되면 깨어나서, 작업의 다음 단계를 새 이벤트로 큐에 넣는 식이다.
물론 위 코드는 thread-safe와는 거리가 멀다. 원한다면 thread-safe하게 만들 수 있고, 처리량 측면에서 일정한 비용을 치르는 대신 병렬성도 활용할 수 있을 것이다.
하지만 병렬성을 고민하기 전에, 이 괴물을 어떻게 개선할 수 있는지 보자.
continuation-passing style은 함수가 절대 결과를 반환하지 않는 프로그래밍 스타일이다. 대신 각 함수는 결과로 무엇을 할지에 대한 지시를 담은 클로저(“continuation”)를 인수로(보통 마지막 인수로) 받는다.
옛날 스타일의 Node를 써 본 적이 있다면, 정확히 저렇게 동작했다. monad로 프로그래밍해 본 적이 있다면, 같은 아이디어가 들어 있다.
그럼 코드를 CPS로 다시 써 보자.
def fibonacci_cps(n: int, then: Callable[[int]]):
"""
Compute `fibonacci(n)`, then call `then`.
"""
if n <= 1:
return then(1)
# Once we have the result of `fibonacci(n - 1)`, compute
# `fibonacci(n - 2)`, then sum both.
def with_left(left: int):
fibonacci_cps(
n=n - 2,
then=lambda right: then(left + right)
)
# Compute `fibonacci(n- 1)`.
fibonacci_cps(
n=n - 1,
then=with_left)
def handle_event(event: BaseEvent):
if isinstance(event, ComputeFibonacciEvent):
fibonacci_cps(event.arg, lambda result: print(f"fibonacci({event.arg})={result}"))
elif isinstance(event, SleepEvent):
event.thunk()
이쪽이 더 낫다. 아직은 블로킹이지만 더 낫다.
이제 CPS로 바꾸면서 반환의 필요를 없앴다. 즉 계산을 지연시킬 수 있다는 뜻이다. 예를 들어 나머지 코드를 망가뜨리지 않고도 fibonacci_cps 중간에 wait 명령을 넣을 수 있다.
먼저 범용 지원 코드를 추가하자.
@dataclass
class SleepEvent(BaseEvent):
"""
Wait until the next tick of the event loop before running `thunk`.
"""
thunk: Callable[[]]
def wait[T](continuation: Callable[[T]]) -> Callable[[T]]:
"""
Wait until the next tick of the event loop before running `continuation`.
"""
def result(arg: T):
def thunk() -> None:
return continuation(arg)
event_queue.put(SleepEvent(
thunk=thunk
))
return result
def handle_event(event: BaseEvent):
if isinstance(event, SleepEvent):
event.thunk()
else
# ... As previously
이게 준비되면 fibonacci_cps를 다음과 같이 다시 쓸 수 있다.
def fibonacci_cps(n: int, then: Callable[[int]]):
"""
Compute `fibonacci(n)`, then call `then`.
"""
if n <= 1:
return wait(then)(1)
# Once we have the result of `fibonacci(n - 1)`, compute
# `fibonacci(n - 2)`, then sum both.
def with_left(left: int):
fibonacci_cps(
n=n - 2,
then=lambda right: wait(then)(left + right)
)
# Compute `fibonacci(n- 1)`.
def compute_left():
fibonacci_cps(
n=n - 1,
then=with_left)
wait(compute_left)
…그리고 이렇게 해서 코드를 논블로킹으로 만들었다. 물론 wait와 SleepEvent는 다른 모든 CPS 함수에도 재사용할 수 있다. 더 나아가 몇 tick을 기다릴지 커스터마이즈하거나, 원한다면 다양한 CPU로 작업을 분배할 수도 있다.
앞서 정의를 기억한다면, CPS로 코드를 작성하는 것은 코드를 _비동기적_으로 만든다. 그리고 우리는 방금 이 비동기 코드를 논블로킹으로 리팩터링할 수 있음을 보였다.
CPS 호출은 Node.js가 처음 “빠르다”고 칭송받았던 진짜 이유다. CPU 속도 때문이 아니라, CPS 덕분에 네트워크나 데이터베이스 read/write를 기다리며 대부분의 시간을 보내는 작업들까지 포함해 수백만 개의 동시 작업을 CPU에 큰 부담 없이 실행할 수 있었기 때문이다.
지금까지는 좋다. 물론 fibonacci의 코드 크기는 4줄에서 18줄로 늘었고 읽기도 더 어려워졌다. 또 성능에 관심 있다면, 우리는 많은 클로저를 할당했고, 그 비용은 가비지 컬렉션 시간으로 돌아올 것이다.
더 나아질 수 있을까?
generator는 반복 호출되어 일련의 값을 차례대로 반환하는 함수 비슷한 객체다. CPS를 본래 지원하는 언어의 관점에서 보면(더 정확히는 call/cc나 delimcc를 지원하는 언어), generator는 continuation의 간단한 사용 사례를 위한 단순한 추상화다.
사실 generator는 여러 언어와 프레임워크에서 동시성을 달성하는 데 사용되어 왔다.
먼저 지원 코드부터 보자.
@dataclass
class ContinueEvent(BaseEvent):
"""
Schedule an execution for the next tick of the event loop.
"""
generator: Generator[None, None, None]
def handle_event(event: BaseEvent):
if isinstance(event, ComputeFibonacciEvent):
# Start computation.
def generator() -> Generator[None, None, None]:
fibo = fibonacci(event.n)
try:
while True:
next(fibo)
# Not ready yet.
yield None
except StopIteration as e:
result: int = e.value
print(f"fibonacci{event.n}={result}")
event_queue.put(ContinueEvent(generator()))
elif isinstance(event, ContinueEvent):
# Continue computation
try:
# Are we done yet?
next(event.generator)
# Continue next tick.
event_queue.put(event)
except StopIteration:
# Done
pass
else:
raise NotImplementedError
전체 아이디어는 handle_event가 ContinueEvent 인스턴스를 받아서 계속 next(event.generator)를 호출하는 것이다. next(event.generator)가 예외 없이 반환되면, 코드는 일시정지를 요청한 것이다. Fibonacci 함수의 구현에서는 함수 호출을 여러 개의 논블로킹 구간으로 나누기로 했기 때문에 이런 일이 일어나지만, 네트워크 호출 같은 것에서는 네트워크 호출이 아직 끝나지 않았음을 의미하게 된다.
실제로 새로운 fibonacci 버전은 이렇다.
def fibonacci(n: int) -> Generator[None, None, int]:
"""
An implementation of fibonacci.
Yields None to reschedule the computation to the next tick of the event loop.
After that, returns `int` with the result of `fibonacci(n)`.
"""
if n <= 1:
return 1
yield None # Take a break.
waiting_left = fibonacci(n - 1)
try:
while True:
next(waiting_left)
# Not `StopIteration` raised, which means we need to take a break.
yield None
except StopIteration as e:
left: int = e.value
waiting_right = fibonacci(n - 2)
try:
while True:
next(waiting_right)
# Not `StopIteration` raised, which means we need to take a break.
yield None
except StopIteration as e:
right:int = e.value
return left + right
좋다. 여전히 꽤 길긴 하지만, 읽기는 꽤 쉽다. Go 스타일처럼, 건너뛰기 쉬운 복붙 코드가 많다. 사실 문법 지원만 있다면 이 함수 전체를 다음처럼 다시 쓸 수도 있을 것이다.
def fibonacci(n: int) -> Generator[None, None, int]:
"""
An implementation of fibonacci.
Yields None to reschedule the computation to the next tick of the event loop.
After that, returns `int` with the result of `fibonacci(n)`.
"""
if n <= 1:
return 1
yield None # Take a break.
left = await fibonacci(n - 1) # Pseudo-syntax.
right = await fibonacci(n - 2) # Pseudo-syntax.
return left + right
…하지만 너무 앞서가지는 말자.
장점은 무엇일까?
fibonacci에서 이 형태로의 변환은 자명하고, 실제로 대부분 자동화할 수 있다.yield None를 추가하거나 제거하기 쉽다. 즉 문맥 전환을 제어할 수 있다는 뜻이고, 이는 성능과 멀티스레딩 함정 회피 모두에 좋다. 안타깝게도 대부분의 언어에서는 인지 비용이 적지 않다. 내가 아는 언어 중에서는 Rust만이 경쟁 상태에 주의를 기울이는 인지 비용의 대부분을 컴파일러에게 떠넘기는 데 성공했다.ContinueEvent 구현을 작성하기도 꽤 쉽다.단점은 무엇일까?
yield None 호출을 잊어버리면, 이 모든 변환은 무의미해진다.이 generator 재작성 역시 다시 한 번 동시성 재작성이지, 논블로킹을 보장하는 재작성이 아님에 주목하자.
더 나아질 수 있을까?
요즘 JavaScript/TypeScript에서는 동시성을 달성하기 위해 CPS나 generator 대신 Promise를 쓰는 경향이 있다. JavaScript는 generator를 지원하지만, JavaScript 코드를 논블로킹으로 만들어야 할 필요성은(특히 역사적으로 Firefox 사용자 인터페이스를 구동하던 JavaScript 코드) 언어에 generator가 구현되기보다 앞섰다.
문법 지원이 없다면 Fibonacci 구현은 다음과 비슷할 것이다.
/**
* Sleep a few milliseconds. Non-blocking.
*/
function sleep(ms: number): Promise<void> {
return new Promise((then) => setTimeout(then, ms));
}
function fibonacci(n: number): Promise<number> {
if (n <= 1) {
return Promise.resolve(1);
}
return sleep(0).then(() => {
return fibonacci(n - 1).then((left) => {
return fibonacci(n - 2).then((right) => {
return left + right
})
})
});
}
이것은 본질적으로 CPS 위에 올린 더 고수준 API다.
실제로 JavaScript는 이중 이벤트 루프를 가진다. Promise.then()은 내부 이벤트 루프(마이크로 tick)에서 해결되고, 이벤트(setTimeout 포함)는 외부 이벤트 루프(tick)에서 해결된다. 이 때문에 일부 사용자 대면 API는 훨씬 단순해지지만, 여기서 세부 사항까지 들어갈 필요는 없다.
이 표현은 CPS보다 조금 읽기 쉽고, 오류 처리(여기서는 보여주지 않았다)도 더 잘 할 수 있지만, 여전히 눈에 조금 거슬리고 할당도 많이 필요하다. 또 성능 측면에서 보면 멀티스레딩에는 그리 친화적이지 않다. JavaScript는 우리가 보통 말하는 의미의 멀티스레딩을 지원하지 않으므로 7, JavaScript에서는 괜찮지만, 왜 같은 해법이 다른 언어에서 밀리지 않는지를 설명해 준다.
그래서 질문은 여전히 남는다. 더 나아질 수 있을까?
그럴 수 있다. 사실 Python이나 Rust에서 위와 같은 스타일로 사람이 직접 쓴 애플리케이션 코드를 본 적은 거의 없다(JavaScript 애플리케이션에서는 많이 봤고, Python이나 Rust 라이브러리와 프레임워크의 일부에서는 좀 보긴 했다). 대신 우리는 문법 설탕을 도입해서 이렇게 쓴다.
async def fibonacci(n: int) -> int:
"""
An implementation of fibonacci.
Yields back time to the scheduler at each non-trivial recursive call.
"""
if n <= 1:
return 1
await asyncio.sleep(0) # Take a break.
left = await fibonacci(n - 1)
right = await fibonacci(n - 2)
return left + right
이것은 매우 근접한 근사로, 우리가 위에서 작성한 코드에 대한 문법 설탕이다. async/await의 사실상 표준 실행기인 asyncio.run으로 실행할 수 있다.
Rust에서도 코드는 꽤 비슷하며, Python과 마찬가지로 본질적으로 yield를 사용하는 같은 루프로 컴파일된다.
async fn fibonacci(n: u32) -> u64 {
if n <= 1 {
return 1
}
tokio::time::sleep(Duration::new(0, 0)).await; // Take a break.
let left = Box::pin(fibonacci(n - 1)).await; // We can't store recursive calls on the fixed-size pseudo-stack, so we need to allocate memory.
let right = Box::pin(fibonacci(n - 2)).await; // Since we'll rewrite it, we also want it to remain in place (hence the `pin`).
left + right
}
흥미롭게도 이 글을 쓰는 시점에서 yield는 Rust의 표면 언어에는 없다. 하지만 내부적으로는 이 목적으로 사용된다. 그리고 generator는 훨씬 더 복잡한 유한 상태 기계로 컴파일되는데, 여기서는 자세히 다루지 않겠다(직접 보고 싶다면 이 playground로 가서 “Build” 대신 “MIR”를 클릭하라). 물론 이 Rust 코드는 thread-safe하며, non-embedded 플랫폼에서 async/await의 사실상 표준 실행기인 tokio로 실행하면 실제로 사용 가능한 CPU들에 분배된다.
두 언어 모두 비슷해 보이는 generator로 컴파일되지만, 스레드와 메모리 내 표현 외에도 하부 메커니즘에는 차이가 꽤 많다는 점에 주의하자. 특히 Python 실행기는 generator(Awaitable)를 반복 호출해서 결국 진행될 때까지 poll해야 하는 반면, Rust 실행기는 generator(Future)가 다시 poll할 준비가 되었을 때 실행기에게 알려 주기를 기대한다.
Rust는 future 취소도 지원한다.
JavaScript/TypeScript의 표면 문법 역시 다시 매우 비슷하며, 위의 Promise 기반 코드로 컴파일된다.
/**
* Sleep a few milliseconds. Non-blocking.
*/
function sleep(ms: number): Promise<void> {
return new Promise((then) => setTimeout(then, ms));
}
async function fibonacci(n: number): Promise<number> {
if (n <= 1) {
return 1;
}
await sleep(0);
let left = await fibonacci(n - 1);
let right = await fibonacci(n - 2);
return left + right;
}
이 코드는 브라우저나 Node의 내장 실행기로 실행된다.
표면 문법은 비슷하지만, 실제 동작은 Rust나 Python과 꽤 다르다. Promise는 실행기에 의해 poll되거나, 잠들거나, 깨워지지 않는다. 오히려 Promise는 본질적으로 스스로 스케줄링한다.
실행기가 무엇인지 궁금하다면, 음, 바로 이 글 전반에서 이야기해 온 이벤트 루프다. 여기에 async/await를 처리하는 데 필요한 기본 이벤트들과, 필요하다면 그 위에 여러분만의 이벤트 루프를 만들 수 있도록 하는 여러 요소가 더해진 것이다.
그렇다면 async/await에는 단점이 있을까? 그렇다, 몇 가지가 있고, 구현마다 다르다.
첫 번째 단점은 일부 언어에서 스택이다. Python에서 async 코드를 디버그하거나 스택 트레이스를 읽다가 고생해 본 적이 있다면 알 것이다. JavaScript도 예전에는 그랬지만, 개선이 있었다. 다만 이 문제는 보편적이지 않다. 예를 들어 Rust, C#, F#은 제대로 된 async backtrace를 가진다.
두 번째 단점은 async/await에 성능 비용이 있다는 점이다. async/await로 작성한 CPU 바운드 코드는 같은 동기 코드만큼 빠르거나 메모리 효율적일 수가 없다. 비용은 Python(여러 번의 polling과 많은 할당이 필요하다)이나 JavaScript(wakeup은 지원하지만 더 많은 할당이 필요하다)에서 Rust(wakeup을 지원하고 종종 할당을 피할 수 있다)보다 더 크지만, 어쨌든 존재한다. 물론 I/O 바운드 코드를 쓴다면 I/O 비용이 보통 async/await 오버헤드보다 몇 자릿수는 더 크므로, 아마 차이를 관찰하지 못할 것이다.
하지만 내가 지금까지 본 가장 큰 문제는 단연코 _대부분의 개발자가 async/await를 이해하지 못한다는 것_이다.
그래서 강조하겠다. async/await는 코드를 쉽게 _비동기적_으로 만드는 메커니즘이다. 여러분의 코드를 동시적으로 만들어 줄 것이다. 또한 코드를 논블로킹으로 만드는 데 사용할 수 있는 도구이기도 하다. 하지만 그 자체로 마법처럼 코드를 논블로킹으로 만들지는 않는다. 특히 async 코드에서 블로킹 코드를 호출하면, 여전히 막힌다.
또한 await를 호출할 때마다, _다른 스케줄된 동시 작업으로의 문맥 전환_이 숨어 있을 수도 있기 때문에, 멀티스레드 코드와 같은 문제를 대부분 만나게 된다. 데이터 경쟁을 겪게 되고, 락과 경우에 따라 task-local storage가 필요하며, 더 나쁜 것은 평소의 락이 통하지 않는다는 점이다. 예를 들어 Rust의 tokio는 Mutex, RwLock 등의 자체 async 구현을 제공한다. 사실 이런 구현은 보통 스레드 버전보다 더 느리다.
마지막으로 async/await는 함수 coloring의 한 형태다. async 함수가 기다리는 것을 기다리려면 await 없이 할 수 없고, async 함수가 아닌 곳에서는 await를 사용할 수 없다. 즉 동기 함수를 기대하는 함수에 async 함수를 콜백으로 넘길 수 없다. map, filter, fold, list comprehension도 마찬가지다. 이것은 코드 재사용을 제한하고, 가끔은 API를 아예 사용할 수 없게 만든다. 나는 최근 Python에서 이 문제를 겪었는데, scikit 최적화 함수가 동기 콜백을 요구했지만, 그 함수는 추가적인 async 함수들에 의존하므로 async로만 구현할 수 있었다. 드문 문제이긴 하지만, 일단 걸리면 해법이 없다.
앞서 잠깐 언급했듯이, async/await는 F#, C#8, Haskell, Swift, Kotlin, 그리고 어느 정도는 C++ 등 다른 여러 언어에도 있다. 내가 살펴본 언어들 중 JavaScript를 제외하면, 모두 Python이나 Rust와 같은 방식으로 async/await를 컴파일한다.
async/await만으로 코드를 논블로킹으로 만들 수 있을까? 음, 위에서 이미 답했지만, 한번 예를 들어 설명해 보자.
위의 설탕이 제거된 Python 코드를 다시 보면, while True와 yield None가 가득하다. await는 실행기에게 제어권을 넘기지 않는다는 것을 알 수 있다. 실제로 await로 가득한 코드를 실행하면서도 다른 작업으로 문맥 전환이 전혀 일어나지 않을 수 있다.
import asyncio
async def noop():
# Do nothing.
return
async def foo():
for i in range(100):
await noop() # This await will not make your foo() and bar() interleave.
print("foo")
async def bar():
for i in range(100):
await noop() # This await will not make your foo() and bar() interleave.
print("bar")
class Main:
def __init__(self):
self.task_1 = None
self.task_2 = None
async def run(self):
# Note: We need to prevent tasks from being garbage-collected.
self.task_1 = asyncio.create_task(foo())
self.task_2 = asyncio.create_task(bar())
main = Main()
asyncio.run(main.run())
# Prints 100x foo() then 100x bar()
즉, async/await만으로는 실행을 _논블로킹_으로 만들지 못한다.
그럼 Rust의 async/await는 마법처럼 논블로킹으로 만들어 줄까?
async fn noop() {
// Do nothing
}
async fn foo() {
for _ in 0..100 {
noop().await;
println!("foo")
}
}
async fn bar() {
for _ in 0..100 {
noop().await;
println!("bar")
}
}
#[tokio::main]
async fn main() {
let foo = tokio::task::spawn(foo());
let bar = tokio::task::spawn(bar());
let _ = foo.await;
let _ = bar.await;
}
// Prints `foo` and `bar` randomly interleaved.
승리인가? 음, 꼭 그렇지는 않다. Tokio는 컴퓨터에 여러 코어가 있다는 것을 감지하고 멀티스레딩을 사용하고 있다. 그렇다면 멀티스레딩 지원을 제거하면 어떻게 될까? main을 다시 써 보자.
fn main() {
// Force the configuration to use a single thread.
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
runtime.block_on(async {
let foo = tokio::task::spawn(foo());
let bar = tokio::task::spawn(bar());
let _ = foo.await;
let _ = bar.await;
})
}
// Prints 100x `foo` then 100x `bar`.
그렇다. 멀티스레딩 지원을 제거하면, 실행은 다시 순차적이 된다. 그러므로 Rust에서도 async/await는 코드를 마법처럼 논블로킹으로 만들지 않는다. 다만 tokio::task::spawn은 그럴 수도 있다.
JavaScript는 어떨까?
async function noop() {
// Do nothing
}
async function foo() {
for(let i = 0; i < 100; ++i) {
await noop();
console.debug("foo");
}
}
async function bar() {
for(let i = 0; i < 100; ++i) {
await noop();
console.debug("bar");
}
}
function main() {
Promise.race([foo(), bar()])
}
// Prints `foo` `bar` `foo` `bar` `foo` `bar` ... 100x each.
잠깐, 뭐라고? JavaScript에서는 async/await가 가능한 한 기대할 수 있는 만큼 동시적으로 코드를 실행하게 해 주는가?
안타깝게도 아니다.
기억하겠지만, JavaScript에는 micro-tick과 tick이 있는 이중 이벤트 루프가 있다고 했다. foo와 bar는 Promise 인스턴스를 반환하고, 각 await 호출은 Promise.then()으로 설탕 제거된다. 초기 Promise 프로토타입 중 일부는 Promise.then이 즉시 코드를 실행했지만, 개발자들은 놀랐다. await가, 말하자면, 잠들기를 기대했기 때문이다. 또 다른 초기 프로토타입은 setTimeout을 호출했지만, 그러면 Promise와 async/await를 IndexedDB나 Fetch 같은 API와 자연스럽게 함께 쓸 수 없었다. 이 API들은 현재 이벤트가 끝날 때 작업을 commit하는데, 이것 역시 개발자들에게 꽤 놀라운 일이었다. 그래서 결국 표준화된 Promise는 _micro-tick_을 도입했고, Promise.then()은 클로저를 다음 micro-tick에서, 하지만 여전히 같은 이벤트 안에서 실행되도록 자동으로 큐에 넣는다. 이 덕분에 (개발자가 setTimeout을 호출하거나 I/O를 기다리지 않는 한) Promise는 작업을 자동으로 청크화하는 데는 쓸 수 없지만, Promise.then() 실행은 훨씬 빨라졌고(아마 가비지 컬렉터에도 더 부담이 적을 것이다. 직접 벤치마크하지는 않았다).
즉 JavaScript에서 async/await는 여러분이 코드를 마치 논블로킹인 것처럼 생각하도록 유도하지만, 역시 그것만으로는 코드를 논블로킹으로 만들기에 충분하지 않다.
앞서 말했듯이 I/O는 매우 느릴 수 있다. HTTP 호출이나 데이터베이스 호출은 무한정 시간이 걸릴 수 있고, 디스크 I/O조차도 특히 네트워크 공유라면 상당히 느려질 수 있다.
지금까지는 순수한 수학 함수에만 집중함으로써 편법을 썼다. 하지만 실제 애플리케이션에서는 I/O와 다른 블로킹 호출을 다뤄야 한다. 결국 앞서 말했듯, 비동기 코드에서 블로킹 코드를 호출하면, 여전히 막힌다.
다행히 여러분이 좋아하는 async 프레임워크는 보통 이런 작업을 위한, 바로 쓸 수 있는 논블로킹 비동기 연산을 제공한다. 이런 연산이 어떻게 논블로킹으로 만들어지는지 보자.
보통 두 가지 경우가 있다. 첫 번째 경우, 운영체제나 더 낮은 계층의 라이브러리가 이미 그런 연산에 대한 논블로킹 호출을 제공할 수 있다. 예를 들어 epoll, io_uring, kqueue, I/O Completion Ports 같은 것이다. 일반적으로 이런 프리미티브는 애플리케이션이나 라이브러리가 다음을 할 수 있게 해 준다.
세부 사항은 프리미티브마다 다르지만, 전체 아이디어는 우리가 예를 들어 앞서 Fibonacci를 청크로 나눴을 때 보여 준 것과 크게 다르지 않다. 실제로 Rust의 async/await 구현은 이런 wakeup 메커니즘에 최적화되어 있다.
실제로는 사정이 조금 더 복잡하다. 사실 어떤 언어에서도 io_uring 위에 얹힌 async/await 임베딩은 아직 알지 못한다. 이 모델과 완전히 들어맞지 않기 때문이다. 그래도 대체로 아이디어는 그렇다.
두 번째 경우는 그런 연산에 대한 논블로킹 호출이 아예 없는 경우다. 그러면 스레드에 의존할 수밖에 없다. 이런 경우 프레임워크는 다음을 수행한다.
Python에서는 이런 모습일 수 있다.
class NonBlockingIOManager:
def __init__(self):
self.pool = ThreadPoolExecutor()
async def file_read(self, path: str) -> str:
"""
Non-blocking file read.
"""
def task():
with open(path) as file:
return file.read()
return await asyncio.get_event_loop().run_in_executor(
self.pool,
task)
이상적이진 않지만, 더 나은 해법이 없다면 작동한다.
그리고 실제로 JavaScript에서 Promise는 이런 방식으로 여러 스레드/프로세스에서 온 결과를 통합하는 일을 돕도록 설계되었다(다만 스레드 자체를 다루지는 않는다).
Go는 조금 특이한 경우이며, 지금까지 살펴본 어떤 방법도, async/await도 제공하지 않는 몇 안 되는 프로그래밍 언어 중 하나다. 그럼에도 이 언어는 동시성 프로그래밍과 웹 서버 분야에서 최고 수준으로 여겨진다(일부 기준에서는).
Python, C#, Rust, JavaScript, 그리고 (지금까지의) Java가 async/await나 더 저수준 구성을 통해 사용자 수준 동시성을 _명시적_으로 만들기로 한 반면, Go 설계자들은 투명한 M:N 스케줄러를 제공함으로써 그것을 _암묵적_으로 만들기로 했다.
func fibonacci(n uint32) uint64 {
if n <= 1 {
return 1
}
// (probably) no need to sleep manually
return fibonacci(n-1) + fibonacci(n-2)
}
type FibonacciEvent struct {
Arg uint32
}
func main() {
for {
e <- nextEvent;
switch event := e.(type) {
case FibonacciEvent:
go fibonacci(event.Arg)
}
}
}
어떻게 동작할까?
go로 goroutine을 시작하면 그 goroutine을 위한 새 스택이 동적으로 할당된다.즉각적인 장점은 자원 사용량과 인지 부하 측면에서 온다. async/await를 다루거나 메모리 사용량을 신경 쓰지 않고도 원하는 만큼 작업("goroutine")을 시작할 수 있고, 이 작업들은 동시적으로, 운이 좋으면 병렬로 실행된다. 더 나아가 Go 함수는 동기성에 따라 색칠되지 않는다. 즉 인터페이스나 콜백은 sync 구현을 받는지 async 구현을 받는지 신경 쓸 필요가 없다. 차이가 없기 때문이다.
즉각적인 단점은 동시성이 상당 부분 암묵적이기 때문에, 코드를 추론하기가 더 어렵다는 점이다. 나는 이것이 실제 문제라고 생각하지는 않지만, 개발자가 동시성에 안전하지 않은 코드를 쓰고도 주의 없이 동시 실행하게 되는 경우는 봤다. 언어의 문제라기보다 교육의 문제라고 생각한다.
암묵적 동시성이 코드를 최적화하기 어렵게 만든다고 주장할 수도 있겠지만, 실제로 그런 수준의 코드 최적화에 관심 있는 개발자는 거의 없고, Go는 가장 빠른 언어 중 하나이므로, 나는 이것을 작은 장애라고 본다. 물론 더 높은 성능이 필요하다면 Rust를 쓰면 된다.
goroutine에는 실제 문제도 있다. 예를 들어 참조에 의한 암묵적 캡처나, 락을 공유해야 하는데 실수로 복사해 버리기 쉬운 점 같은 것이다. 하지만 이것은 동시성 모델이나 구현 때문이 아니다.
이제 Go가 특이하다고 했지만, M:N 스케줄링을 가진 언어가 Go만 있는 것은 결코 아니다. Erlang은 90년대부터 M:N 스케줄링의 대표격이었고, Haskell은 2000년대 초부터 이를 지원했으며, Rust도 한때 지원했지만 1.0 전에 제거했고, Java도 그 방향으로 가고 있다. OCaml도 이를 지원하는 것으로 알고 있지만, 아직 조사 중이다.
Rust가 이 기능을 제거한 것은 왜 M:N 스케줄링이 항상 해법은 아닌지를 보여 주는 흥미로운 사례다.
이 일은 여러 이유로 벌어졌다. Rust가 어떤 종류의 언어가 될지 결정하는 데는 시간이 좀 걸렸다. 초기 버전의 Rust는 garbage-collection, M:N 스케줄러, 실시간 프로그래밍에 대한 내장 지원을 제공했다. 각각은 편리했지만, Rust를 유지보수하거나 새로운 아키텍처로 포팅하기 더 어렵게 만들었다.
특히 Rust가 펌웨어, 메모리 할당기, embedded 코드, OS 코드 등을 포함한 매우 저수준 코드를 쓰기에 정말 좋은 언어라는 점이 분명해지면서, 이런 기능들은 애초에 운영체제와 할당기가 있어야만 동작하는 것들이었기 때문에 장애물이 되었다. Rust를 진정한 시스템 언어로 만들려면 이런 기능들은 빠져야 했고(그리고 라이브러리가 되어야 했다).
게다가 Rust 철학은 암묵적 비용보다 명시적 비용을 선호한다. 많은 언어가 그렇게 주장하지만(Go와 Python 포함), 실제로 이 설계 원칙을 끝까지 따르는 언어는 많지 않다(내가 떠올릴 수 있는 것은 Rust, Zig, 물론 C와 C++ 정도다). M:N 스케줄러를 가지려면 스택을 암묵적으로 할당하고 키워야 하는데, 이는 이 철학에 어긋난다. 메모리 할당에는 비용이 있고, 사용자에게 특정 할당기를 강요하며(그러면 Rust는 예를 들어 브라우저 엔진이나 게임 엔진에서 훨씬 덜 쓰기 좋아질 것이다), 실패할 수도 있다.
그뿐 아니라 Rust는 C를 투명하게, 암묵적 비용과 암묵적 위험 없이 호출하고, 반대로 C에서 Rust를 호출할 수도 있도록 설계되었다. 그런데 C를 막지 않고 호출하려면 CGo가 제공하는 것과 비슷한 메커니즘이 필요하다. 이 메커니즘은 다시 암묵적 할당을 요구하므로, 숨겨진 비용과 숨겨진 실패 지점을 낳는다.
또한 어떤 아키텍처에서는 M:N 스케줄링이 애초에 말이 되지 않았다. 예를 들어 동적 메모리 할당을 지원하지 않는 아키텍처나 네이티브 스레드를 지원하지 않는 (embedded) 운영체제에서는 M:N이 성립할 수 없다.
마지막으로, M:N 스케줄링은 복잡했고 언어의 발목을 잡고 있었다. 그래서 이 기능은 libgreen이라는 crate로 옮기기로 결정되었다. 이후 Future, 그리고 async/await 작업이 진전되면서, Rust 커뮤니티는 M:N 스케줄링보다 async/await가 명시적 비용이라는 Rust 철학에 훨씬 잘 맞는다고 판단했고, 결국 이 기능을 버리기로 했다.
물론 그렇다고 해서 M:N 스케줄링이 나쁘다는 뜻은 전혀 아니다. 다만 그것이 언제나 원하는 기능은 아니라는 점을 보여 줄 뿐이다.
OCaml은 매우 흥미로운 사례다. 1990년대 후반부터 Go의 channel에 해당하는 것을 지원해 왔고, 5.0 버전에서 마침내 멀티코어를 지원하게 되었다. 최근 벤치마크를 보면 일부 작업에서는 실제로 Go보다 훨씬 빠를 수도 있다고 한다(벤치마크를 직접 확인하진 않았으니 믿지는 말라).
OCaml에서 Eio 실행기를 사용하면 우리의 Fibonacci는 다음과 비슷하다.
let rec fibonacci (n:int) =
if n <= 1 then (
1
) else (
Fiber.yield(); (* take a break *)
fibonacci (n - 1) + fibonacci (n - 2)
)
이 구현은 Go와 Python/Rust/JavaScript의 중간쯤에 위치한다. Go처럼 async나 await가 필요 없고, Python/Rust/JavaScript/C++처럼 협력적 문맥 전환을 허용하는 명시적 연산(여기서는 Fiber.yield())을 기대한다.
하지만 사실 OCaml은 자기만의 별개 세계다. 무엇보다 내가 이해한 바로는, OCaml 컴파일러는 멀티태스킹을 위한 특별한 컴파일 단계를 거치지 않는다. 즉 async/await를 yield나 Promise 같은 것으로 컴파일하거나, 암묵적 협력형 문맥 전환을 넣지 않는다. 실제로 위 코드는 변경이나 재컴파일 없이도, 문맥에 따라 백그라운드의 논블로킹 작업으로도, 블로킹 작업으로도 실행될 수 있다.
어떻게 가능한가?
음, Fiber.yield()의 구현은 사실 _effect_를 발생시킨다. effect는 예외와 거의 동일하지만, 한 가지 핵심 차이가 있다. 예외 핸들러는 예외를 처리하거나 전파할 수만 있지만, effect 핸들러는 effect가 발생한 지점에서 작업을 _재개_할 수도 있다. 어떤 의미에서 effect는 예외(재개 능력을 추가한다)와 generator(임의의 스택 깊이를 가로지를 수 있게 한다)를 모두 일반화한 것이다.
이것은 문맥에 따라 커스터마이즈 가능한 재시도, 로깅, mock 가능한 I/O, 그리고 물론 동시성을 제공하는 데도 쓸 수 있는 아주 강력한 메커니즘이다. 예를 들어 이렇게 쓸 수 있다.
try some_function() with
| effect Yield continuation -> (
(* Proceed in the next tick *)
enqueue_event (Continue continuation)
)
혹은 동시성을 원하지 않는다면
try some_function() with
| effect Yield continuation -> (
(* Proceed immediately *)
continuation ()
)
…그리고 같은 스택 안에서 둘 다 사용할 수도 있다. 예를 들어 성능상의 이유로, 동시적이거나 심지어 병렬적인 실행기 안에서 돌고 있더라도 어떤 함수를 지역적으로는 동시성 없이 실행되게 강제할 수 있다.
정말 흥미로운 메커니즘이라 지금도 시험 운전 중이다. continuation의 성능 영향이 어떨지는 잘 모르겠다. 내가 이해한 바로는 두 fiber 사이의 문맥 전환은 Go만큼 빠를 수 있다. 하지만 스택 관리는 더 복잡하다. try 안에서 실행되는 코드는 자기 스택이 필요하고, effect 식 안에서 실행되는 코드는 자기 스택이 필요하며, 이 두 스택은 try ... with ... 전체 식을 호출하는 코드 위에 또 쌓이고, continuation()도 자기 스택이 필요하다.
또한 예외와 마찬가지로 effect가 함수 타입에 나타나지 않는다는 문제도 있어, 처리되지 않은 effect를 실수로 남길 수 있다. 업데이트된 ocamlexn 같은 것이 이 문제를 해결해 줄 수 있을까?
참고: Reddit의 /u/phischu가 지적했듯이, 물론 이 기능을 더 확장한 실험적 언어도 몇 가지 있다. Lexa와 Effekt 등이 그렇다.
async/await를 쓰면 된다6.async/await는 쓸모가 없다.async/await를 사용해 그것을 깔끔하게 논블로킹 코드로 청크화할 수 있다.async/await는 I/O 제약 작업에서 CPU를 효율적으로 사용하는 데 빛난다.async/await는 함수 coloring이므로, 사용을 가로막을 수도 있다.이 몇 페이지의 코드가 async/await가 왜 설계되었는지, 무엇에 좋은지, 무엇은 해주지 않는지, 그리고 몇 가지 대안까지 이해하는 데 도움이 되었기를 바란다.
기회가 된다면, 다음에는 벤치마크를 곁들인 후속 글을 써 보겠다.
Unix 명령줄 도구를 제외하면 말이다.↩︎
물론 대체로는 <16ms 안에 열린다. 하지만 가끔은 그렇지 않다. 특히 네트워크 마운트라면(몇 초가 걸릴 수 있다), 혹은 파일 시스템이 이미 꽤 바쁘다면 그렇다.↩︎
이 글을 쓰는 지금, 내 블로그의 index.html을 내려받는 데(다른 리소스 없이) 약 30ms가 걸린다. 대략 두 프레임 길이다.↩︎
무엇보다도, Go는 데이터 경쟁 상태가 존재하면 type-safe하지 않게 된다.↩︎
그런데 이 말을 쓰자마자 Python 3.14의 ChangeLog를 보게 되었는데, free threading이 Phase II로 넘어가고 있음을 시사한다. 누가 아는가, 더 빨라질지도?↩︎
Reddit에서 /u/asb가 스케줄링 비용을 줄이려는 노력이 있다고 친절하게 알려 주었다: https://lkml.org/lkml/2020/7/22/1202 .↩︎↩︎
멀티프로세스 패러다임은 지원하며, 그것이 스레드로 구현될 수도 있지만, 그것은 조금 다르다.↩︎
사실 내 기억이 맞다면, async/await를 처음 도입한 주류 언어는 F#이었고, 그 다음이 C#이었다.↩︎