async/await 설계 공간 탐구
이 글은 async/await가 언어마다 얼마나 다르게 동작하는지 체계적으로 분석한 논문을 소개합니다. 동일한 비동기 프로그램이 7개 현대 언어 런타임에서 4가지 다른 결과를 낼 정도로 의미론이 다르며, 저자들은 '열성(Eagerness)', '작업 범위(Extent)', '취소(Cancellation)' 등 9가 설계 차원을 도출해 비교합니다. 비동기 코드를 작성하는 개발자에게 자신이 쓰는 언어의 정확한 비동기 의미론을 이해하는 것이 왜 중요한지 보여주는 연구입니다.
현재 많은 프로그래밍 언어가 동시성(concurrency)을 표현하기 위해 async/await 키워드를 제공합니다. 그 설계 이념은 상당히 일관적입니다. 바로 동시성 프로그램이 직선적(straight-line) 코드처럼 보이도록 만드는 것입니다(예: Python, Rust, Swift). 따라서 우리는 이벤트 루프나 콜백 방식과 대비하여, async/await를 포괄하는 패러다임을 '직선적 비동기(straight-line asynchrony)'라고 부릅니다. 직선적 비동기를 위한 언어 설계는 15년 넘게 발전해 왔습니다. 이 프로젝트에서 우리가 알고 싶었던 것은: 언어들 간의 async/await는 얼마나 비슷하고 얼마나 다른가? 그리고 짧은 답은, 예상보다 훨씬 더 다르다는 것이었습니다. 우리는 그 이유를 설명하기 위해 "A Design Space Exploration of Async/Await(async/await의 설계 공간 탐구)"라는 논문을 작성했습니다.
그래서 async/await를 잘 안다고 생각하시나요? 현대 언어들이 얼마나 다르게 동작할 수 있는지 보여주기 위해, 의사코드로 작성된 간단한 비동기 프로그램이 있습니다. 한 함수는 로그에 기록하고, 다른 함수는 로그 쓰기를 백그라운드 작업으로 실행한 뒤 계속 진행합니다.
async fn write_to_log(): print("A") await sleep(2) // 느린 로그 쓰기 시뮬레이션 print("B")
async fn fire_and_forget(): task = spawn write_to_log() // 작업을 기다리지(await) 않고 반환
async fn main(): await fire_and_forget() await sleep(1) print("C")
이 프로그램이 무엇을 출력할 것으로 예상하시나요? 정답을 확인해 보세요. 사실 정답은 없습니다. 왜냐하면 어떤 언어에서는 당신이 맞았을 가능성이 높기 때문입니다. 아래는 7개의 현대적인 비동기 런타임이 실제로 어떻게 동작하는지 보여줍니다(실제 출력 결과 보기). 단지 백그라운드에서 로그 한 줄을 쓰는 것이 전부인 프로그램에서 네 가지 다른 답이 나옵니다. 그리고 더 심각한 것은, 논문에서 우리가 보여주듯이 이 간단한 프로그램의 세 가지 변형에 대해 7개 런타임 중 어느 두 개도 같은 출력을 내지 않습니다! 당신은 정말로 자신이 쓰는 언어의 비동기 의미론을 알고 있나요?
왜 이런 불일치가 생길까요? 프로그래밍 인플루언서의 영상을 보다 보면 "콜드(cold)" 또는 "핫(hot)" 비동기 함수 호출이라는 용어를 들어봤을 것입니다. 여기서 "핫 스타트"는 런타임에서 즉시 실행 중인 태스크를 반환하는 것이고, "콜드 스타트"는 기다려지기(await) 전까지 아무것도 하지 않는 비활성 객체를 반환하는 것입니다.
핫 vs 콜드 함수가 우리가 '비동기 설계 차원(async design dimension)'이라고 부르는 것입니다. 이는 순수한 성능 문제가 아니라 프로그램 실행의 관찰 가능한 의미론에 영향을 주는 설계 결정입니다. 우리는 이 특정 차원을 '열성(Eagerness)'이라고 부르며, 논문에서 현대의 직선적 비동기 구현들로부터 아홉 가지 차원을 식별했습니다. 아래에서 이 아홉 가지 차원을 태스크의 생명주기에 대략적으로 대응하는 세 범주로 묶었습니다: 태스크의 시작(Start of Life), 종료(End of Life), 취소(Cancellation)입니다. 표에서 언어를 클릭하면 해당 언어의 설계 선택을 추적할 수 있습니다.
시작(Start of Life)
열성(Eagerness): 비동기 함수 적용을 어떻게 평가할 것인가.
- 지연(Lazy): 추가 실행 없이 코루틴으로 평가됨 — Python, Rust
- 즉시(Eager): 현재 스레드에서 평가되고, await 시 태스크로 스케줄됨 — C#, JavaScript
일시중지(Suspension): await 지점이 일시중지되는지에 대한 보장.
- 정적(Static): await 지점은 반드시 일시중지됨이 보장됨 — JavaScript
- 동적(Dynamic): 태스크를 기다리는 것에 대한 보장 없음 — C#, Swift, Tokio, Smol, Asyncio, Trio
종료(End of Life)
범위(Extent): 태스크가 존재할 수 있는 기본 시간 간격.
- 무기한(Indefinite): 태스크는 기본적으로 런타임 종료 시까지 존재할 수 있음 — JavaScript, C#, Tokio, Smol, Asyncio
- 동적(Dynamic): 태스크는 기본적으로 스폰된 스코프의 끝까지 존재할 수 있음 — Swift, Trio
참조 강도(Reference Strength) [무기한 범위의 경우]: 런타임이 태스크에 대해 유지하는 참조의 유형.
- 강함(Strong): 런타임이 강한 참조를 유지 — JavaScript, C#, Tokio
- 약함(Weak): 런타임이 약한 참조를 유지 — Asyncio, Smol
소멸(Destruction): 태스크가 범위의 끝에서 어떻게 정리되는가.
- 기다려짐(Awaited): 태스크가 완료될 때까지 기다려짐 — JavaScript, Trio
- 취소됨(Cancelled): 태스크가 취소된 후 기다려질 수 있음 — Swift, Tokio, Smol, Asyncio
- 종료됨(Terminated): 프로그램이 종료됨 — C#
전파(Propagation): 기다려지지 않은 태스크의 예외는 어떻게 되는가.
- 파괴적(Destructive): 예외가 의존하는 쪽에서 다시 발생함 — Trio
- 발생 안 함(Never): 예외가 태스크 내부에 유지됨 — JavaScript, C#, Tokio, Smol, Asyncio, Swift
취소(Cancellation) (원문에서 이 부분은 잘려 있음)