동시에 여러 작업 실행하기 — create_task와 gather
await를 순서대로 쓰면 동시성이 아니다
3장에서 본 것처럼 await 는 "여기서 멈추고 완료되면 재개해달라"는 뜻입니다. 그런데 다음 코드를 보면 착각하기 쉬운 지점이 있습니다.
import asyncio
import time
async def fetch(name: str, delay: float):
await asyncio.sleep(delay)
return f"{name} 완료"
async def main():
start = time.perf_counter()
r1 = await fetch("A", 1)
r2 = await fetch("B", 1)
r3 = await fetch("C", 1)
print(time.perf_counter() - start) # 약 3.0초
asyncio.run(main())세 개를 await 로 이어 썼다고 해서 동시에 실행되지 않습니다. fetch("A", 1) 이 끝나야 그 다음 줄로 넘어가므로, 총 실행 시간은 1+1+1 = 3초입니다. asyncio를 처음 쓰는 사람이 가장 자주 하는 실수가 이겁니다 — "async를 썼으니까 알아서 동시에 돈다"는 기대입니다. 코루틴 여러 개를 동시에 실행하려면 명시적으로 스케줄링해야 합니다.
create_task — 코루틴을 이벤트 루프에 예약하기
async def main():
start = time.perf_counter()
t1 = asyncio.create_task(fetch("A", 1))
t2 = asyncio.create_task(fetch("B", 1))
t3 = asyncio.create_task(fetch("C", 1))
r1 = await t1
r2 = await t2
r3 = await t3
print(time.perf_counter() - start) # 약 1.0초asyncio.create_task() 는 코루틴을 즉시 이벤트 루프의 실행 큐에 등록합니다. 세 Task 가 모두 등록된 뒤에야 await 로 결과를 기다리기 때문에, 세 작업이 겹쳐서 실행되고 총 시간은 가장 느린 것(1초)에 수렴합니다. 여기서 중요한 점: create_task 를 호출하는 순간 작업이 "예약"되지, await 하는 순간에 시작되는 게 아닙니다. t1 = asyncio.create_task(...) 줄이 실행되자마자 fetch("A", 1) 은 백그라운드에서 이미 돌기 시작합니다.
gather로 여러 결과를 한 번에 모으기
세 개 정도면 위 코드로도 괜찮지만, 목록이 동적이라면 asyncio.gather 가 더 자연스럽습니다.
async def main():
results = await asyncio.gather(
fetch("A", 1),
fetch("B", 2),
fetch("C", 1.5),
)
print(results) # ['A 완료', 'B 완료', 'C 완료'] — 순서 보장됨gather 는 넘겨받은 코루틴들을 내부적으로 Task 로 감싸 동시에 실행하고, 결과를 입력 순서 그대로 리스트로 돌려줍니다. 실행이 끝나는 순서가 아니라 넘겨준 순서라는 점이 중요합니다 — B 가 가장 오래 걸려도 결과 리스트에서는 여전히 두 번째입니다.
gather의 숨겨진 문제 — 하나가 실패하면?
async def flaky():
await asyncio.sleep(0.5)
raise ValueError("네트워크 오류")
async def slow():
await asyncio.sleep(3)
return "느리지만 성공"
async def main():
results = await asyncio.gather(flaky(), slow())이 코드를 실행하면 flaky() 가 0.5초 뒤 예외를 던지고, gather 는 그 예외를 즉시 위로 전파합니다. 문제는 slow() 입니다 — 기본 동작(return_exceptions=False)에서 gather 는 예외가 발생해도 나머지 태스크를 취소하지 않습니다. slow() 는 백그라운드에서 계속 돌다가 3초 뒤 조용히 끝나는데, 그 결과는 어디에서도 받을 방법이 없습니다. 프로그램이 이미 예외를 던지고 종료 절차에 들어갔기 때문입니다. 좀비처럼 남아 도는 태스크가 생기는 셈이죠.
return_exceptions=True 를 주면 예외를 전파하는 대신 결과 리스트에 예외 객체를 담아주긴 하지만, 이번엔 반대로 "다른 태스크를 자동으로 취소해주면 좋겠다"는 요구는 여전히 해결되지 않습니다. 실패 하나 때문에 나머지를 확실히 취소하고 싶다면 gather 만으로는 부족합니다 — 다음 장에서 다룰 TaskGroup 이 정확히 이 문제를 풀기 위해 나온 기능입니다.