asyncio 는 cooperative 지 preemptive 아냐
async 함수는 await 지점에서만 컨트롤 양보. await 사이엔 CPU 가짐. 즉 async 는 I/O bound 작업 (API 호출, DB 쿼리, 파일 읽기) 에 좋고 CPU bound 작업 (loop 막음) 에 나쁨.
어디든 등장하는 세 패턴
gather: 여러 awaitable 을 동시에 돌리고, 전부 끝날 때까지 기다려. cwkPippa 는 "전부 다 받고 나서 움직인다" 가 진짜 모양인 자리에서 써. 종료할 때 떼어낸 chat 스트림들이 끝나길 최대 30초 기다렸다가 남은 걸 취소하는 자리, 그리고 영혼이 댓글에 답하러 깨기 전에 서로 독립인 읽기 넷을 한 번에 가져오는 자리. Family Council 의 fan out 은 gather 할 일처럼 보이는데 일부러 안 써. gather 는 제일 느린 두뇌를 기다리지만, 카운슬은 각 두뇌의 답이 도착하는 순간 흘려보내거든. 그래서 두뇌마다 create_task 로 task 를 하나씩 띄우고, 출력은 큐 하나로 모아.
create_task: awaitable 백그라운드로 시작, handle 받음. 작업 시작하되 아직 block 하기 싫을 때.
async 제너레이터: async def + yield 함수. yield 가 내놓는 값은 평범한 값이야. await 할 수 있는 건 한 걸음 한 걸음, 그러니까 async for 가 다음 값을 받으려고 await 하는 __anext__() 호출이야. SSE event stream 이 정확히 이 패턴 — async for chunk in adapter.stream(...).
__anext__() 를 asyncio.wait_for 로 감싼 거. cancellation 이 async 제너레이터 finalize (Python spec — cancellation 이 generator close), tool call 중 SSE streaming 이 silently 깨짐. fix: __anext__ 를 wait_for 로 감싸지 마. 전체 episode 가 docs/COMMON-GOTCHAS.md #1 에 있어.
세 도구를 고르는 기준도 다르지. 결과가 전부 필요하면 gather, 지금 시작하되 lifecycle을 따로 관리할 이유가 있으면 create_task, 결과가 생기는 순서대로 흘려보내야 하면 async generator야. 특히 create_task는 fire-and-forget 주문이 아니야. handle을 잃으면 error도 cancellation도 누가 거둘지 사라져.
사실 concurrency의 어려움은 동시에 시작하는 데 있지 않아. 하나가 실패하거나 caller가 떠났을 때 나머지를 어떻게 정리할지에 있어. task를 만든 코드가 종료·취소·예외 수거까지 소유해야 해. 그 lifecycle을 설명할 수 없으면 빠른 코드가 아니라 떠돌이 작업을 만든 거야.