스크랩

같은 async/await 코드인데 결과가 4가지? 7개 런타임으로 본 비동기 설계의 9가지 갈림길

같은 async/await 코드인데 결과가 4가지? 7개 런타임으로 본 비동기 설계의 9가지 갈림길

들어가며

async/await는 복잡한 동시성 코드를 순차 코드처럼 읽히게 해 줍니다. Python, Rust, Swift, JavaScript, C# 등 여러 언어가 이 문법을 쓰다 보니 어느 언어에서나 똑같이 동작한다고 생각하기 쉽습니다.

그런데 실제로는 그렇지 않습니다. 연구 「A Design Space Exploration of Async/Await」에 따르면, 백그라운드 로그를 남기는 짧은 예제 하나가 7개 런타임에서 4가지 다른 출력을 냅니다. 이 글에서는 이런 차이가 왜 생기는지, 연구가 정리한 9가지 설계 차원을 따라가며 살펴보겠습니다.

핵심 요약

  • async/await는 언어마다 실행 의미가 다릅니다. 같은 예제가 7개 런타임에서 4가지 결과를 냅니다.
  • 이 차이는 단순한 성능 문제가 아닙니다. 관찰 가능한 동작 자체를 바꾸는 설계 선택 때문에 생깁니다.
  • 연구는 이 선택들을 태스크의 시작·종료·취소 단계에 걸친 9개 설계 차원으로 분류했습니다.
  • 설계마다 성능·메모리·사용 편의성·실행 의미를 맞바꾸기 때문에 유일한 정답은 없습니다.

순차형 비동기성(Straight-line Asynchrony)이란?

이벤트 루프를 직접 다루거나 콜백을 중첩하는 방식과 달리, async/await는 비동기 코드를 위에서 아래로 읽히는 순차 코드처럼 쓰게 해 줍니다. 연구는 이 패러다임을 **순차형 비동기성(straight-line asynchrony)**이라고 부릅니다. 관련 언어 설계는 15년 넘게 발전해 왔고, 그 사이 언어마다 서로 다른 선택을 했습니다.

백그라운드 로그 하나가 만드는 네 가지 결과

예제 프로그램을 의사 코드로 옮기면 다음과 같습니다.

async def write_to_log():
    print("A")
    await sleep(2)   # 느린 로그 쓰기를 흉내 냄
    print("B")

async def fire_and_forget():
    spawn write_to_log()   # 태스크를 만들고 기다리지 않은 채 반환

async def main():
    await fire_and_forget()
    await sleep(1)
    print("C")

간단해 보이지만 런타임별 출력은 이렇게 갈립니다.

| 런타임 | 출력 | |---|---| | Python asyncio, C#, Rust Tokio, Swift | AC | | JavaScript | ACB | | Rust Smol | C | | Python Trio | ABC |

세 가지 변형 프로그램까지 함께 비교하면, 7개 런타임 중 모든 출력이 일치하는 쌍은 하나도 없습니다. 짧은 프로그램 하나를 정확히 이해하려 해도 호출이 즉시 실행되는지, await가 반드시 중단되는지, 태스크 수명과 참조 강도, 예외 전파, 취소 방식까지 알아야 한다는 뜻입니다.

1단계: 태스크 시작

즉시성(Eagerness)

비동기 함수를 호출했을 때 어떻게 평가할지 정합니다. 흔히 'hot' 호출은 이미 실행 중인 태스크를 반환하고, 'cold' 호출은 await하기 전까지 아무 일도 하지 않는 객체를 반환합니다.

  • Lazy: 코루틴만 반환하고 실행은 진행하지 않습니다.
  • Eager: 현재 스레드에서 바로 실행하다가 await 시점에 태스크로 스케줄링합니다.

중단(Suspension)

await 지점에서 실행 중단을 보장하는지 정합니다.

  • Static: await 지점에서 반드시 중단합니다.
  • Dynamic: 태스크를 await할 때도 중단을 보장하지 않습니다.

2단계: 태스크 종료

수명 범위(Extent)

태스크가 기본적으로 얼마나 오래 존재할 수 있는지 정합니다.

  • Indefinite: 런타임이 끝날 때까지 존재할 수 있습니다.
  • Dynamic: 자신을 만든 스코프가 끝날 때까지만 존재합니다.

참조 강도(Reference Strength)

수명 범위가 Indefinite일 때 런타임이 태스크를 어떤 참조로 붙잡고 있는지 정합니다.

  • Strong: 강한 참조로 보유합니다.
  • Weak: 약한 참조로 보유합니다.

정리 방식(Destruction)

태스크가 수명 범위 끝에 도달했을 때 어떻게 처리할지 정합니다.

  • Awaited: 완료될 때까지 기다립니다.
  • Cancelled: 취소합니다. 경우에 따라 취소된 태스크를 기다리기도 합니다.
  • Terminated: 프로그램이 종료됩니다.

예외 전파(Propagation)

await하지 않은 태스크에서 발생한 예외를 어떻게 다룰지 정합니다.

  • Destructive: 그 태스크에 의존하는 태스크에서 예외가 다시 발생합니다.
  • Never: 예외가 해당 태스크 안에 머뭅니다.

💡 Swift와 Trio의 차이

Swift와 Python+Trio는 둘 다 Dynamic 수명 범위를 택했습니다. 그래서 write_to_log 태스크는 fire_and_forget 함수보다 오래 살 수 없습니다. 결과가 갈리는 지점은 정리 방식입니다.

  • Swift는 Cancelled 방식이라 함수 스코프가 끝날 때 태스크를 취소합니다. 따라서 B가 출력되지 않고 결과는 AC입니다.
  • Trio는 Awaited 방식이라 로그 쓰기가 끝날 때까지 기다립니다. 따라서 결과는 ABC입니다.

수명에 대한 철학은 같아도 스코프가 끝나는 순간의 처리 방식 하나 때문에 출력이 달라집니다.

3단계: 태스크 취소

취소 인지(Awareness)

  • Unaware: 태스크가 취소에 반응할 수 없습니다.
  • Aware: 태스크가 취소에 반응할 수 있습니다.

취소 방향(Direction)

태스크 그래프에서 취소를 어떤 방향으로 전달할지 정합니다.

  • Top-Down: 루트 태스크에서 시작해, 의존하는 쪽에서 의존 대상 쪽으로 전달합니다.
  • Bottom-Up: 루트의 의존 대상에서 시작해, 그 태스크에 의존하는 쪽으로 전달합니다.
  • Simultaneous: 전이적으로 연결된 모든 의존 대상에 한꺼번에 전달합니다.

취소 지속성(Persistence)

취소를 인지할 수 있는 태스크에서 취소 상태가 얼마나 유지되는지 정합니다.

  • Transient: 취소를 무시하고 정상적으로 진행할 수 있습니다.
  • Persistent: 취소를 무시할 수는 있지만, 취소된 상태는 계속 남습니다.

형식 의미론으로 분기 지점 추적하기

연구는 이 설계 공간을 비동기 프로그램의 핵심 계산 체계에 대한 **형식 의미론(formal semantics)**으로 옮기고, 실행 추적으로 예제 결과가 달라지는 이유를 보여 줍니다.

  • 실행 추적 도식에서 각 상자는 추상 기계의 상태를, 각 화살표는 적용된 규칙이 표시된 **작은 단계 축약(small-step reduction)**을 뜻합니다.
  • 대부분의 규칙은 모든 런타임에서 똑같이 동작합니다.
  • 강조 표시된 규칙이 결과를 갈라놓는 설계 선택입니다.
  • 도식은 함수 호출의 즉시(eager)·부분 즉시(semi-eager)·지연(lazy) 평가, 태스크 수명 범위, 취소, 대기, 런타임 종료에 따른 분기를 하나로 연결해 보여 줍니다.

마치며: 개발자가 챙겨 갈 점

9개 설계 차원에는 모두 성능·메모리 사용량·사용 편의성·실행 의미 사이의 절충이 있고, 언어마다 나름의 설계 근거가 있습니다. 어느 쪽이 옳다고 할 수는 없지만, 실무에서 다음 사항은 기억해 두면 좋습니다.

  1. fire-and-forget 패턴은 조심하세요. 기다리지 않은 태스크가 끝까지 실행된다는 보장은 언어마다 다릅니다.
  2. 언어를 옮길 때는 비동기 의미론을 다시 확인하세요. Python에서 Rust로, JavaScript에서 Swift로 코드를 옮길 때 문법이 비슷해도 동작은 다를 수 있습니다.
  3. 같은 언어 안에서도 런타임에 따라 다릅니다. 같은 Python이라도 asyncio와 Trio, 같은 Rust라도 Tokio와 Smol은 결과가 다릅니다.

async/await는 비동기 코드를 순차 코드처럼 보이게 해 주지만, 실제로 순차적으로 실행된다는 뜻은 아닙니다. 쓰고 있는 런타임이 태스크를 어떻게 시작하고, 언제 끝내고, 어떻게 취소하는지 알고 있어야 예상치 못한 버그를 막을 수 있습니다.


원문: https://news.hada.io/topic?id=33475