728x90

브라운대 Cognitive Engineering Lab에서 나온 "A Design Space Exploration of Async/Await"가 HN에 올라왔다. 요지는 간단하다. 우리가 다 안다고 생각하는 async/await이, 언어·런타임마다 관측 가능한 의미론 자체가 다르다는 것. 논문 저자들은 7개 런타임을 비교했는데 아주 작은 프로그램 하나에서 답이 4가지로 갈렸다.

로그 한 줄 백그라운드로 던졌을 뿐인데

원문에 나오는 의사코드다. 하는 일이라곤 "느린 로그 쓰기를 백그라운드로 던지고 진행"이 전부다.

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")

뭐가 찍힐까? 원문에 따르면 Swift는 AC, Trio는 ABC, 나머지도 제각각이다. 심지어 이 프로그램의 변형 3개를 돌렸더니 두 런타임이 같은 출력을 내는 경우가 하나도 없었다고 한다.

운영하다 보면 이게 그냥 학술적 호기심이 아니다. "백그라운드 태스크가 조용히 사라져서 감사 로그가 안 남는다", "SIGTERM 받고 graceful shutdown 했는데 처리 중이던 작업이 절반만 커밋됐다", "타임아웃 걸었더니 커넥션이 반쯤 쓰다 만 상태로 풀에 돌아갔다" — 전부 이 설계 축의 차이에서 나오는 증상이다.

9개의 설계 축: 태스크의 일생을 쪼개보면

논문은 설계 차원(design dimension)을 9개로 정리하고, 태스크 생애주기에 맞춰 세 덩어리로 묶는다. 실무에서 특히 자주 얻어맞는 것만 추려본다.

Start of Life — Eagerness(게으름)

async fn f()를 호출한 순간 실행이 시작되는가? 원문 분류로 Python과 Rust는 Lazy(코루틴/Future 객체만 만들고 아무것도 안 함), C#과 JavaScript는 Eager(현재 스레드에서 바로 실행하다가 await 지점에서 스케줄). 그래서 JS 습관 가진 사람이 파이썬 코드에서 await를 빼먹으면 함수 본문이 통째로 실행이 안 된다.

End of Life — Extent(수명)와 Destruction(정리 방식)

spawn한 태스크가 부모 함수보다 오래 살 수 있는가. Indefinite(JS, C#, Tokio, Smol, asyncio)는 런타임이 끝날 때까지 살 수 있고, Dynamic(Swift, Trio)은 spawn한 스코프를 못 벗어난다. 이게 구조적 동시성(structured concurrency)이다.

그리고 수명이 끝날 때 뭘 하는가가 Destruction이다. Trio/JS는 Awaited(끝날 때까지 기다림), Swift/Tokio/asyncio는 Cancelled(취소), C#은 Terminated(그냥 프로세스 종료). 위 예제에서 Swift가 AC, Trio가 ABC인 이유가 정확히 여기다. 둘 다 Dynamic Extent인데 정리 방식이 다르다.

Reference Strength — 유령처럼 사라지는 태스크

Indefinite Extent인 런타임 중에서도 asyncio와 Smol은 런타임이 태스크를 약한 참조(Weak)로 들고 있다. JS·C#·Tokio는 강한 참조. 이 한 줄이 파이썬 실무자를 제일 많이 괴롭히는 지점인데, 아래에서 다시 다룬다.

Cancellation — Awareness / Direction / Persistence

Rust는 Unaware다. Future를 drop하면 그냥 사라지고, 태스크가 "나 취소됐어"를 관측할 방법이 없다. 반면 asyncio/Trio/Swift는 취소를 예외로 관측할 수 있다(Aware). 여기서 또 갈리는 게 Persistence인데, asyncio는 Transient다. 취소를 무시하고 그냥 계속 돌 수 있다. Trio와 Swift는 Persistent라 한 번 취소되면 취소 상태가 유지된다.

실무에서 얻어맞는 지점들

1) 파이썬: create_task 결과를 안 붙잡으면 태스크가 증발한다

위 의사코드를 asyncio로 그대로 옮겨보자.

# ff.py
import asyncio

async def write_to_log():
    print("A")
    await asyncio.sleep(2)
    print("B")

async def fire_and_forget():
    asyncio.create_task(write_to_log())   # 반환값 버림

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

asyncio.run(main())
$ python3 ff.py
A
C

B는 영영 안 찍힌다. asyncio.run()이 끝나면서 남은 태스크를 취소하기 때문(Cancelled Destruction). 게다가 asyncio는 약한 참조라, GC 타이밍이 나쁘면 실행 도중에도 이런 게 뜬다.

Task was destroyed but it is pending!
task: <Task pending name='Task-2' coro=<write_to_log() running at ff.py:5>
 wait_for=<Future pending cb=[Task.task_wakeup()]>>

이 메시지 검색해서 여기까지 온 사람이라면 답은 하나다. 태스크 참조를 어딘가에 붙잡아 두거나, 아예 TaskGroup으로 감싸라. 전통적인 임시방편은 이거고:

_bg: set[asyncio.Task] = set()

def spawn(coro):
    t = asyncio.create_task(coro)
    _bg.add(t)
    t.add_done_callback(_bg.discard)
    return t

Python 3.11 이상이면 asyncio.TaskGroup을 쓰는 게 낫다. Trio의 nursery와 같은 발상으로, 스코프를 벗어날 때까지 기다려주니 Extent를 Dynamic으로 바꾸는 효과가 난다.

2) Trio로 돌리면 출력이 바뀐다

# ff_trio.py
import trio

async def write_to_log():
    print("A"); await trio.sleep(2); print("B")

async def fire_and_forget():
    async with trio.open_nursery() as n:
        n.start_soon(write_to_log)   # 스코프 끝에서 완료까지 대기

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

trio.run(main)
$ python3 ff_trio.py
A
B
C

같은 언어, 같은 문법, 다른 런타임인데 출력이 다르다. "async/await을 안다"는 말이 얼마나 부실한지 보여주는 예다.

3) 흔한 함정: await 빼먹기

Lazy Eagerness 때문에 파이썬에서 제일 자주 보는 경고.

sys:1: RuntimeWarning: coroutine 'write_to_log' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

JS였다면 await를 빼도 함수 본문은 일단 돌았을 것이다(Eager). 파이썬은 한 줄도 안 돈다. 언어 간 이직·폴리글랏 팀에서 리뷰로 꼭 잡아야 하는 부분이다.

4) 흔한 함정: CancelledError를 삼키는 코드

파이썬에서 CancelledError는 3.8부터 BaseException 상속이라 except Exception에 안 잡힌다. 그런데 이런 코드는 여전히 흔하다.

try:
    await do_work()
except BaseException:      # 또는 except asyncio.CancelledError: pass
    logger.exception("실패")
    # re-raise 안 함 → 취소가 여기서 죽는다

asyncio는 Transient Cancellation이라 여기서 취소를 씹으면 태스크가 아무 일 없었다는 듯 계속 돈다. 그 결과 asyncio.wait_for 타임아웃이 안 먹고, shutdown 시퀀스가 영원히 안 끝난다. Trio/Swift였다면 취소 상태가 남아서(Persistent) 다음 체크포인트에서 다시 튄다. 정리 코드에서 await를 쓸 일이 있으면 asyncio.shield나 Trio의 CancelScope(shield=True)를 쓰고, 그 외에는 반드시 re-raise.

5) Rust: drop = 취소, 그것도 조용히

Tokio에서 select!는 선택되지 않은 브랜치의 Future를 drop한다. Rust는 Cancellation Unaware이므로 그 태스크는 "나 취소됐다"를 알지 못한 채 중간 상태로 사라진다. 소켓에 절반만 쓰고 drop되면 프로토콜이 깨진다. 그래서 Tokio 문서가 "cancel safety"를 그렇게 강조하는 것이다. 취소 안전하지 않은 작업은 select! 브랜치에 직접 넣지 말고 tokio::spawn으로 분리하고 JoinHandle/채널로 결과를 받는 쪽이 안전하다. (구체적인 cancel safety 보장 여부는 API별로 Tokio 공식 문서 확인 필요.)

6) 그래서 이벤트 루프 블로킹은?

Suspension 축과 맞닿는 문제다. 논문 분류상 JavaScript만 Static(await 지점이 반드시 서스펜드)이고 나머지는 Dynamic이다. 즉 await가 있다고 양보가 보장되지 않는다. 동기 I/O나 CPU 작업이 코루틴 안에 섞이면 루프가 통째로 멈춘다. 파이썬은 개발 환경에서 디버그 모드를 켜두면 잡힌다.

$ PYTHONASYNCIODEBUG=1 python3 app.py
WARNING:asyncio:Executing <Task ... coro=<handler() ...>> took 2.013 seconds

대안은 알려진 대로 loop.run_in_executor / asyncio.to_thread, Rust면 spawn_blocking. 근본적으로 함수 색깔(coloring) 문제가 싫으면 Go의 고루틴처럼 런타임이 스택을 통째로 스위칭하는 모델을 택하는 수밖에 없는데, 그 대가로 "이 함수가 블로킹인가"가 타입에서 사라진다. 공짜는 없다.

728x90

+ Recent posts