아는척 하기 시리즈 - async / await, 코루틴, 그리고 이벤트 루프의 진실
“asyncio? 멀티스레드랑 비슷한 거 아냐?”
“await 하면 함수가 블로킹된다던데….”
“이벤트 루프가 신호를 받아서 뭘 한다는데, 정확히 뭘 받는다는 건데?”
비동기 프로그래밍을 처음 접하면 이런 물음표가 끊임없이 튀어나온다.
이번 글은 Python asyncio 를 기준으로, async def, await, 코루틴, 이벤트 루프, 그리고 운영체제(OS)와의 상호작용까지 한 번에 정리해 준다. 다 읽고 나면 “아, 그래서 await가 ‘양보’ 라는 말이었구나” 하고 고개를 끄덕일 수 있을 것이다.
예의상 공식문서는 읽어보도록 하자:
https://docs.python.org/3/library/asyncio.html
asyncio — Asynchronous I/O
Hello World!: asyncio is a library to write concurrent code using the async/await syntax. asyncio is used as a foundation for multiple Python asynchronous frameworks that provide high-performance n...
docs.python.org
1. 비동기 프로그래밍이 필요한 이유
컴퓨터 프로그램은 종종 기다려야 하는 작업을 만난다.
- 데이터베이스 쿼리
- 외부 API 호출
- 디스크 읽기/쓰기
전통적인(동기) 코드는 이때 CPU가 멍하니 대기한다.
비동기 코드는 “기다릴 동안 다른 일부터 처리하자”는 방식을 택한다.
덕분에 동일 스레드로도 처리량(throughput)을 크게 끌어올릴 수 있다.
2. async def : 실행이 아니라 “예약”이다
async def greet():
print("안녕!")
coro = greet() # 아직 “안녕!” 안 찍힘
print(coro) # <coroutine object greet at 0x...>
- async def로 선언된 함수는 호출 즉시 코루틴 객체를 반환한다.
- 실행은 안 된다. 단지 “언제든 실행할 수 있는 스크립트”가 손에 들린 셈이다.
3. await : 지금 멈추고, 제어권을 이벤트 루프에 양보
await greet() # 여기서 비로소 “안녕!” 출력
- await는 “이 코루틴이 끝날 때까지 내 실행을 잠시 중단하고,
- 이벤트 루프야 다른 일 먼저 처리해”라는 의미다.
- 전체 프로그램이 멈추는 것이 아니라,“현재 코루틴만” 일시 정지한다.
4. 이벤트 루프(event loop) : 한 줄짜리 while 문
asyncio의 이벤트 루프는 단순하다.
한마디로 “어떤 코루틴을 지금 실행할지” 를 무한히 결정하는 while 문이다.
while True:
(1) 끝난 Task / Future 확인 → 콜백 실행
(2) 타이머 만료 확인
(3) OS에서 I/O 완료 이벤트 수신
(4) 실행 준비된 코루틴 하나 꺼내 실행
- 싱글 스레드다.
- 코루틴이 await 를 만나면 (1)~(3)을 돌며 다른 작업을 수행한 뒤,
- 다시 (4)에서 해당 코루틴을 이어서 실행한다.
5. “OS가 신호를 보낸다”는 말의 정체
네트워크 요청을 예로 들자.
- 코루틴이 await socket_recv() 를 호출한다.
- asyncio는 non-blocking 소켓을 OS에 등록한다
- (Linux : epoll, macOS : kqueue, Windows : IOCP).
- 코루틴은 일시 중단되고, 이벤트 루프는 다른 작업을 실행한다.
- 패킷이 도착하면 커널이 “읽을 수 있다”는 이벤트(신호)를 발생시킨다.
- 이벤트 루프가 이 신호를 감지하고, 중단된 코루틴을 재개한다.
“신호” = 커널 I/O 완료 알림 이다.
await는 결국 OS에게 의뢰 → 이벤트 루프가 알림을 대기 → 완료되면 다시 실행
이 과정을 코드 한 줄로 표현한 셈이다.
6. 병렬(parallel) vs 병행(concurrent)
구분 | 설명 |
병렬 (parallel) | 여러 CPU / 스레드가 동시에 서로 다른 코드를 실행 |
병행 (concurrent) | 하나의 CPU가 빠르게 번갈아 작업을 실행해 “동시에” 보이게 함 |
asyncio는 병행 모델이다.
같은 스레드에서 코루틴이 양보와 재개를 반복하며 CPU를 공유한다.
멀티코어를 쓰고 싶으면 multiprocessing이나 loop.run_in_executor(ThreadPool/ProcessPool) 같은 별도 방식을 써야 한다.
7. 블로킹 함수가 문제를 일으키는 이유
import requests
async def bad():
r = requests.get("<https://example.com>") # 블로킹
print(r.text)
- requests.get()은 내부에서 time.sleep()류 블로킹 I/O를 사용한다.
- 코루틴이 중단되지 않으므로 이벤트 루프가 멈춘다.
- 비동기의 장점이 0%.
해결책 → aiohttp, aiomysql, aioredis처럼 awaitable 라이브러리를 써야 한다.
8. 전체 흐름 한 눈에 보기
async def 작업():
await I/O ----+
|
[이벤트 루프] <----+(중단된 코루틴 기록)
|
| OS I/O 완료 이벤트 도착
v
(중단 지점부터 재개)
9. 흔한 오해 — 그리고 정확한 한 줄 대답
오해 | 올바른 설명 |
“asyncio는 멀티스레드다” | 아니다. 기본은 싱글 스레드다. |
“await는 블로킹이다” | 아니다. 현재 코루틴만 중단하고, 이벤트 루프는 계속 돈다. |
“신호를 보낸다 = 파이썬 내부 이벤트” | 아니다. OS가 보내는 I/O 완료 알림이다. |
10. 진짜 아는 척하려면 이렇게 말하자
“await는 OS에 I/O 맡기고, 커널의 epoll(kqueue·IOCP) 이벤트를 asyncio 루프가 받으면 그때 다시 코루틴을 재개하는 구조야.
싱글 스레드지만 I/O 대기를 100% 활용하니까 고성능이 나오는 거지.”
마무리
asyncio를 이해하려면 양보( await ), 감시( event loop ), 그리고 OS와의 약속( readiness event )
이 세 가지만 기억하면 된다.
이제 await를 만날 때마다 “지금 양보했고, 커널이 알림 주면 다시 달린다”고 자연스럽게 떠올린다면 된다.