메뉴
HN
Hacker News • 16일 전

async/await 설계 공간 탐구

IMP
7/10
핵심 요약

이 글은 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) (원문에서 이 부분은 잘려 있음)

원문 보기
원문 보기 (영어)
Many programming languages now provide the async / await keywords for expressing concurrency. The design rationale is pretty consistent: to make concurrent programs look more like straight-line code (see: Python , Rust , or Swift ). We therefore describe the paradigm which encompasses async/await as straight-line asynchrony , as opposed to using event loops or callbacks. Language designs for straight-line asynchrony have been brewing for over 15 years. In this project, we wanted to understand: how similar or different is async/await between languages? The short answer is a lot more different than we expected. We wrote a paper, “A Design Space Exploration of Async/Await” , to explain how. So you think you know async/await? To demonstrate how much modern languages can diverge, here’s a small async program written in pseudocode. One function writes to a log, and another fires off the log write as a background task and moves on. async fn write_to_log () : print ( "A" ) // simulate a slow log write await sleep ( 2 ) print ( "B" ) async fn fire_and_forget () : task = spawn write_to_log () // return without awaiting the task async fn main () : await fire_and_forget () await sleep ( 1 ) print ( "C" ) What would you expect this program to print? Check my answer There isn’t really a right answer, because you were probably right for some language . Below is how seven modern async runtimes actually behave: Show me the outputs Four different answers, for a program whose entire job is to write a log line in the background. And it gets worse. In the paper , we show that across the seven runtimes, no two produce the same output for three variations of this simple program! Do you actually know your language’s async semantics? Why the disagreement? While watching videos from a programming influencer you may have have heard the terms “cold” or “hot” async function calls. The idea is that “hot starts” return a task that is immediately running in the runtime, while “cold starts” return an inert object that does nothing until awaited. Hot vs. cold functions is what we call an async design dimension: a design decision that affects the observable semantics of program execution (as opposed to matters of pure performance). We call this particular dimension “Eagerness”, and in the paper we identify nine such dimensions from modern implementations of straight-line asynchrony. Below we’ve grouped these nine dimension into three categories that roughly correspond to the lifetime of a task: Start of Life, End of Life, and Cancellation. Click a language to trace its design choices through the table. Start of Life Eagerness How to evaluate an async function application. Lazy Evaluate to a coroutine without executing further. Python · Rust Eager Evaluate in current thread, and schedule as task on await. C# · JavaScript Suspension Guarantees on whether await points suspend. Static Await points guaranteed to suspend. JavaScript Dynamic No guarantees on awaiting tasks. C# · Swift · Tokio · Smol · Asyncio · Trio End of Life Extent The default interval of time during which a task may exist. Indefinite Tasks by default may exist until the end of the runtime. JavaScript · C# · Tokio · Smol · Asyncio Dynamic Tasks by default may exist until the end of their spawning scope. Swift · Trio Reference Strength [ For Indefinite Extent ] The type of reference to a task held by the runtime. Strong The runtime holds a strong reference. JavaScript · C# · Tokio Weak The runtime holds a weak reference. Asyncio · Smol Destruction How a task is cleaned up at the end of its extent. Awaited The task is awaited to completion. JavaScript · Trio Cancelled The task is cancelled, and then possibly awaited. Swift · Tokio · Smol · Asyncio Terminated The program exits. C# Propagation What happens to exceptions in unawaited tasks. Destructive Exceptions are reraised by dependents. Trio Never The exception is kept within the task. JavaScript · C# · Tokio · Smol · Asyncio · Swift Cancellation Awareness Whether a task is able to respond to being cancelled. Unaware The task cannot respond to being cancelled. Rust Aware The task can respond to being cancelled. Asyncio · Trio · Swift Direction How cancellation is communicated through the task graph. Top-Down Starting from the root task, and communicated from dependents to dependencies. Rust Bottom-Up Starting from the root’s dependencies and communicated to dependents. Asyncio · Trio Simultaneous To all transitive dependencies at once. Swift Persistence [ For Aware Cancellation ] How long a cancellation of a task lasts. Transient A task can ignore cancellation and proceed as normal. Asyncio Persistent A task can ignore cancellation but remains cancelled. Trio · Swift Two of these axes are particularly relevant for our example program. Languages with Dynamic Extent do not allow tasks to outlive the functions in which they were spawned. Unlike the other languages, Swift and Python+Trio chose Dynamic Extent. This means that within the function fire_and_forget , the task associated with write_to_log cannot outlive the function fire_and_forget . Although Swift and Trio both chose Dynamic Extent, they differ in choice of Destruction. At the end of the fire_and_forget function scope, Swift uses Cancelled Destruction, and cancels task while Trio uses Awaited Destruction and politely waits for write_to_log to finish. The choices of Extent and Destruction explain why Swift prints “AC” and Trio prints “ABC”. Each design dimension has trade-offs of performance, memory usage, ergonomics, semantics, etc. There’s no right or wrong answers, and each language has its own design rationale. But with so many decisions, explaining the output for even small programs becomes quite involved! To make our design space more precise, we translated it into a formal semantics on a core calculus of asynchronous programs. This model lets us explain exactly why the sample program diverges by tracing its execution. The figure below provides a glimpse of how this formal model can be used to explain different execution outcomes. The figure shows the trace of the model, highlighting the semantic decisions that lead to different outcomes. Each box is an abstract-machine state. Each arrow is a small-step reduction, labeled with the rules that fire. Most rules behave identically in every runtime; the highlighted ones are the design decisions, and each highlight is a fork in the road. async fn write_to_log (): print “A” ; await sleep ( 2 ); print “B" async fn fire_and_forget (): task = spawn write_to_log () async fn main (): await fire_and_forget (); await sleep ( 1 ); print “C" block_on ( main ()) out: ε acjkmrs C[ spawn write_to_log () ] out: ε acjkmrs C[ task ] T: ( 2 , write_to_log, print “B” ) out: A cj C[ task; cancel task; try await task catch e -> () ] Q: print “A” ; await sleep ( 2 ); print “B" out: ε s C[ spawn coro ] out: ε akmr C[ task ] Q: print “A” ; await sleep ( 2 ); print “B" out: ε akm C[ task; await task ] Q: print “A” ; await sleep ( 2 ); print “B" out: ε r block_on (()) T: ( 2 , write_to_log, print “B” ) out: AC acjk C[ throw “cancelled” ] out: A s (); await sleep ( 1 ); print “C" out: A s (); await sleep ( 1 ); print “C" out: ε m (); await sleep ( 1 ); print “C" out: AB r out: ACB j out: C m out: AC acks out: ABC r Block-Wait Async-App Await Async-App eager OS-IO Async-App semi-eager Async-App lazy Spawn dynamic Spawn indefinite OS-IO Signal Schedule Schedule OS-IO Signal Cancel-Unstarted OS-IO Signal Schedule Block-Done Schedule OS-IO Signal Await-Task OS-IO Signal Schedule Block-Done Schedule OS-IO Await Catch-Exn OS-IO Signal Schedule Block-Done Block-Done terminated Signal Schedule Block-Done awaited languages a = Asyncio c = C# j = JavaScript k = Tokio m = Smol r = Trio s = Swift To understand this diagram, and the decisions that went into designing your favorite language’s async/await system, read our new paper “A Design Space Exploration of Async/Await” !