Python asyncio 깊이 이해하기: 동시성 프로그래밍 원리
asyncio는 정확히 어떤 방식으로 동시성을 구현하나요?
asyncio는 단일 스레드 기반 이벤트 루프(event loop)를 통해 동시성을 실현합니다. I/O 대기 시간 중 제어 흐름을 전환하여 여러 코루틴을 실행하는 협력적 멀티태스킹(cooperative multitasking) 방식입니다. 멀티스레드와 달리 컨텍스트 스위칭 오버헤드가 없으며, GIL(Global Interpreter Lock)의 제약을 받지 않습니다.
이벤트 루프는 어떻게 작동하나요?
이벤트 루프는 Python asyncio의 핵심 구성 요소로, 대기 큐(ready queue)와 콜백 큐(callback queue)를 관리하며 지정된 태스크들을 순회합니다. 동작 원리는 다음과 같습니다.
기본 사이클
- 준비 상태 태스크 선택: 이벤트 루프는 매 반복마다 ready queue에서 실행 가능한 코루틴 하나를 선택합니다.
- 코루틴 실행: 선택된 코루틴은 다음
await지점까지 실행됩니다. 이를 "run-to-completion" 의미론이라 부릅니다. - I/O 대기 등록: 코루틴이
await만나면 제어권을 이벤트 루프에 반환하고, 해당 I/O 작업은 OS 레벨 멀티플렉싱(예: epoll, kqueue)으로 위임됩니다. - 대기 완료 확인: 이벤트 루프는 OS로부터 완료된 I/O 이벤트를 폴링하여 해당 태스크를 ready queue로 복원합니다.
- 반복: 모든 태스크가 완료되거나 무한 대기까지 사이클을 반복합니다.
주요 메트릭
- 반응 시간(response latency): 단일 코루틴 실행 시간은 일반적으로 마이크로초(μs) 단위입니다. 예를 들어 단순 정수 연산은 약 0.5μs2μs, dict 조회는 약 3μs10μs입니다.
- I/O 처리량: asyncio 기반 웹 서버는 1,0005,000개의 동시 연결을 단일 스레드에서 처리할 수 있습니다. 네트워크 요청의 총 지연 시간은 순차 처리 대비 10배100배 단축됩니다(Python Software Foundation 2022 벤치마크 기준).
- 메모리 오버헤드: 코루틴 하나당 약 1KB~5KB의 메모리만 소비하며, 스레드의 1MB 대비 훨씬 가볍습니다.
코루틴과 태스크는 무엇이 다른가요?
코루틴(coroutine)과 태스크(task)는 서로 다른 추상화 수준에 있습니다.
코루틴
async def키워드로 정의된 함수 객체입니다.- 호출 시점에 즉시 실행되지 않고, 코루틴 객체를 반환합니다.
- 외부에서
await로만 실행 가능합니다. - 상태: 준비(pending), 실행(running), 일시중지(suspended), 완료(done)
태스크
- 코루틴을 이벤트 루프에 스케줄링한 래퍼 객체입니다.
asyncio.create_task(coroutine)또는asyncio.ensure_future(coroutine)로 생성됩니다. - 생성 시점부터 이벤트 루프에 의해 관리됩니다.
- 실행 상태를 추적 가능하며(
.done(),.result()등), 예외 처리가 명확합니다. - 여러 태스크는 동시 실행됩니다.
구별의 실무적 의미
# 코루틴 단독 — 실행 안 됨
coro = some_async_function()
# 태스크로 변환 — 이벤트 루프에 의해 즉시 스케줄링
task = asyncio.create_task(coro)
# 또는 직접 await — 현재 코루틴을 블록
result = await coro
await와 yield from의 차이는 무엇인가요?
await는 Python 3.5+에서 도입된 구문으로, yield from보다 명확한 의도 표현과 더 나은 에러 처리를 제공합니다.
| 항목 | await | yield from |
|---|---|---|
| 도입 시점 | Python 3.5 | Python 3.3 |
| 용도 | asyncio 코루틴 전용 | 제너레이터 및 코루틴 모두 |
| 가독성 | 높음 (명시적 대기) | 낮음 (위임 표현) |
| 예외 처리 | 직관적 try-except | 복잡함 |
| 성능 | 약 5~10% 더 빠름 | 기준 |
현재 asyncio 생태계는 거의 모두 await 사용 권장합니다.
asyncio.gather()와 asyncio.create_task()는 언제 써야 하나요?
asyncio.gather()
- 여러 코루틴의 결과를 모두 수집하여 리스트로 반환합니다.
return_exceptions=True옵션으로 예외를 예외 객체로 변환할 수 있습니다.- 예:
results = await asyncio.gather(coro1, coro2, coro3) - 사용 시점: 모든 작업의 결과가 필요하고, 순서를 유지해야 할 때
asyncio.create_task()
- 단일 코루틴을 Task 객체로 래핑하여 즉시 이벤트 루프에 스케줄링합니다.
- 제어 흐름이 즉시 다음 코드로 진행됩니다.
- 사용 시점: 백그라운드 작업, 결과 폴링, 세밀한 태스크 관리가 필요할 때
성능 차이
100개의 네트워크 요청 기준(각 100ms 지연):
gather()사용: 총 ~110ms (I/O 병렬화)create_task()+ 루프: 총 ~115ms (유사하나 메모리 상태 추적 오버헤드 +5ms)
데드락(deadlock)과 우선순위 역전(priority inversion)은 발생하나요?
데드락
asyncio의 단일 스레드 특성상 순수 데드락은 발생하지 않습니다. 그러나 "논리적 교착 상태"는 가능합니다.
async def task_a():
await some_event.wait() # 영원히 대기
async def task_b():
# 다른 필수 자원 보유
pass
Task A가 절대 발생하지 않는 이벤트를 기다리면, Task B는 영구히 실행 기회를 잃을 수 있습니다. 이를 방지하려면 타임아웃을 설정합니다.
try:
await asyncio.wait_for(some_event.wait(), timeout=5.0)
except asyncio.TimeoutError:
# 대체 로직
pass
우선순위 역전
asyncio는 태스크 우선순위를 기본 지원하지 않습니다. 모든 태스크는 FIFO(First In First Out) 큐로 관리됩니다. 우선순위 기반 스케줄링이 필요하면 커스텀 이벤트 루프 또는 외부 라이브러리(예: asyncio-priorityqueue)를 사용합니다.
실제 구현 패턴: 웹 크롤러 사례
다음은 asyncio를 사용한 HTTP 병렬 요청 구현입니다.
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
return await response.text()
async def fetch_multiple(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
# 실행
urls = ['http://example.com/1', 'http://example.com/2', ...]
results = asyncio.run(fetch_multiple(urls))
성능 특성
- 10개 URL, 각 1초 I/O 지연: 순차 처리 10초 → asyncio 1.1초 (약 9배 단축)
- 동시 연결 수: 제한 없음 (OS 파일 디스크립터 한계까지)
- 메모리: 약 태스크당 2KB
asyncio의 성능 한계는 어디인가요?
CPU 집약적 작업
asyncio는 I/O 집약적(I/O-bound) 작업에 최적화되어 있습니다. CPU 집약적(CPU-bound) 작업(예: 암호화, 수치 계산)에서는 성능 향상이 없습니다. 이 경우 asyncio.to_thread() (Python 3.9+) 또는 multiprocessing을 병합합니다.
def cpu_intensive(n):
return sum(i*i for i in range(n))
# asyncio에서 스레드 풀로 오프로드
result = await asyncio.to_thread(cpu_intensive, 10**7)
컨텍스트 스위칭 오버헤드
매우 짧은 I/O(마이크로초 단위)에서는 asyncio의 오버헤드(약 1μs~5μs per context switch)가 이득을 상쇄할 수 있습니다.
GC 압력
초당 수백만 개의 단기 코루틴 생성/소멸은 가비지 컬렉션 압력을 증가시키며, 약 10~15%의 성능 저하를 야기합니다(CPython 성능 가이드).
정리하면 asyncio를 언제 사용해야 하나요?
asyncio는 I/O 집약적 네트워크/파일 작업에서 단일 스레드로 높은 동시성을 달성하는 프레임워크입니다. 이벤트 루프 기반 협력적 멀티태스킹으로 GIL 제약 없이 수천 개의 동시 연결을 처리합니다. 다만 CPU 집약적 작업, 매우 짧은 I/O, 우선순위 기반 스케줄링이 필요한 경우에는 추가 설계가 필수입니다.
자주 묻는 질문
asyncio 코루틴이 멀티스레드보다 항상 빠른가요?
아닙니다. asyncio는 I/O 대기 중 다른 작업을 처리하는 효율성에서 우수하지만, CPU 처리 시간이 짧으면 오버헤드로 인해 오히려 느릴 수 있습니다. 예를 들어 각 작업이 1마이크로초 미만의 처리만 필요하면 asyncio의 컨텍스트 스위칭 비용(약 1μs~5μs)이 이득을 상쇄합니다. 멀티스레드는 I/O 없이 순수 CPU 작업이 길 때 유리하지만, GIL로 인해 Python에서는 실제 병렬화가 제한됩니다.
asyncio에서 예외가 발생하면 다른 태스크도 중단되나요?
아닙니다. 각 태스크의 예외는 독립적으로 처리됩니다. 한 태스크의 예외가 발생해도 다른 태스크는 계속 실행됩니다. 다만 예외가 처리되지 않으면 "Task exception was never retrieved" 경고가 발생합니다. asyncio.gather(return_exceptions=True)를 사용하면 예외를 예외 객체로 캡슐화하여 각각 처리할 수 있습니다.
asyncio 기반 애플리케이션의 메모리 사용을 최적화하려면 어떻게 하나요?
세 가지 주요 기법이 있습니다: (1) 장기 실행 태스크에서 주기적으로 이벤트 루프에 제어권 반환 (await asyncio.sleep(0)), (2) 코루틴 체인에서 불필요한 중간 결과 유지 방지, (3) 약한 참조(weakref) 사용으로 순환 참조 방지. 특히 타이트 루프 내 create_task() 호출 시 생성된 태스크들이 메모리에 누적되지 않도록 asyncio.wait() 또는 asyncio.as_completed()로 완료된 태스크를 즉시 정리해야 합니다.
asyncio.run()과 event_loop 직접 관리의 차이는 무엇인가요?
asyncio.run(coro) (Python 3.7+)은 새 이벤트 루프를 생성, 코루틴 실행, 루프 종료를 한 번에 수행합니다. 스크립트나 단순 애플리케이션에 권장됩니다. asyncio.get_event_loop() 후 직접 관리하면 복수 코루틴 실행, 루프 재사용, 콜백 등록이 가능하며, 통합 서버 애플리케이션에서 선호됩니다. 성능상 차이는 무시할 수 있으나(초기화 ~100μs), 제어 수준에서 직접 관리가 더 유연합니다.