Python asyncio의 작동 원리: 처음부터 재구성
(jacobpadilla.com)- Python의 asyncio는 I/O-bound 프로그램을 다루는 도구이며, 글은 이를 제너레이터 위에 단순 구현으로 다시 만들어 작동 원리를 보여줌
- 핵심은 여러 작업을 가진 이벤트 루프가 작업을 실행하다가
yield에서 제어권을 돌려받고, 다음 작업으로 넘어가는 구조임 sleep은 시간이 지날 때까지yield하는 서브 제너레이터로 만들 수 있으며,yield from은 하위 제너레이터가 끝난 뒤 원래 작업을 이어가게 함async/await로 바꾸면Task.__await__()가 완료 전까지 이벤트 루프로 제어권을 넘기고,create_task와run이 실제 asyncio API와 비슷한 형태를 만듦- 커스텀
jacobio호출을asyncio로 바꾸면 실제 패키지를 쓰는 코드가 되지만, 실제 asyncio는 예제보다 훨씬 복잡하고 내부 흐름도 일부 다름
제너레이터로 보는 asyncio의 기본 구조
- asyncio는 Python에서 I/O-bound 프로그램을 처리하는 데 쓰이며, 글은 이를 제너레이터 기반으로 단순화해 재구성함
- Python 3+의
range처럼 제너레이터는 전체 시퀀스를 메모리에 저장하지 않고 필요한 값을 하나씩 생성함range(100_000_000)을 리스트처럼 만들면 1억 개 요소를 저장해야 하므로 메모리 비효율이 큼- 제너레이터는 값이 필요할 때마다 생성하므로 전체 시퀀스를 보관하지 않음
- 제너레이터 함수는 일반 함수처럼 정의하지만
yield를 사용함- 함수 호출 시 본문을 즉시 실행하지 않고 제너레이터 객체를 반환함
next(iterator)를 호출하면 다음yield까지 실행됨- 더 이상
yield가 없으면StopIteration예외가 발생함
yield from은 제너레이터가 서브 제너레이터나 iterable 객체를 호출하게 해 제너레이터 체인을 만들 수 있음- 여기서 중요한 특성은 함수 실행을 멈췄다가 상태를 유지한 채 다시 시작할 수 있다는 점임
이벤트 루프를 리스트로 단순화하기
- 이벤트 루프는 현재 작업들을 실행하고 관리하는 asyncio의 핵심임
- 실제 asyncio 이벤트 루프는 C로 작성되어 있지만, 단순 모델에서는 현재 작업을 담는 리스트로 볼 수 있음
- 초기 예제에서 작업은 제너레이터 객체로 취급됨
- 이벤트 루프 관리자가 작업 리스트를 순회함
- 각 작업에
next(task)를 호출해 실행함 - 작업은 I/O-bound 작업처럼 기다려야 하는 시점에
yield로 멈추고 이벤트 루프에 제어권을 돌려줌
task1()과task2()가 각각 출력 후yield하는 예제에서는 출력이 번갈아 나타남- 두 함수가
while True루프를 가지므로 실행은 계속 이어짐 - 출력은
Task 1,Task 2,Task 1,Task 2처럼 반복됨
- 두 함수가
yield from으로 sleep 만들기
sleep(seconds)는 시작 시간을 기록한 뒤 지정한 시간이 지나기 전까지 계속yield하는 제너레이터로 구현됨- 작업 함수는
yield from sleep(1)또는yield from sleep(5)처럼 sleep 서브 제너레이터에 실행을 위임함sleep이 계속yield하는 동안 작업은 멈춘 상태가 됨- 시간이 충분히 지나면
sleep의while루프가 끝남 - 더 이상
yield가 없으므로StopIteration이 발생하고,yield from은 작업 함수의 다음 줄로 이어짐
- 예제에서는
task1이 1초마다 출력하고task2가 5초마다 출력함- 출력은
Task 1,Task 2이후Task 1이 여러 번 나오고 다시Task 2가 나오는 형태가 됨
- 출력은
yield에서 await로 바꾸기
await를 사용하려면 대상 객체가__await__메서드를 가지거나 coroutine이어야 함- asyncio에서는 보통
asyncio.create_task같은 함수로Task객체를 다룸Task객체는 asyncio의Future객체를 상속함Future객체에는__await__메서드가 있음
async키워드가 붙은 함수를 호출하면 coroutine 객체가 만들어짐- coroutine은 제너레이터 함수처럼 실행을 멈췄다가 다시 이어갈 수 있음
await는 추가 검증 규칙이 있는yield from과 비슷하게 볼 수 있음await object는 객체 인스턴스의__await__에서yield하거나, 다른 coroutine을 기다리는 형태가 됨
- asyncio의
Future소스 코드에서도Future또는Task가 완료되지 않았을 때__await__가 기본적으로yield를 호출하는 형태를 볼 수 있음
단순 Task, create_task, run 구현
- 커스텀 구현은 리스트 대신
Queue를 이벤트 루프로 사용함- 작업을 루프에 추가하고 삭제하는 동작을 상수 시간에 처리하려는 구조임
Task클래스는 제너레이터 객체와 완료 여부를 저장함self.iter에 제너레이터 객체를 저장함self.finished를False로 시작함- 제너레이터가
StopIteration을 발생시키면 완료된 것으로 처리함 done()은 완료 상태를 반환함
Task.__await__()는 작업이 끝나지 않은 동안 계속yield self를 호출함- 이 동작이 이벤트 루프에 제어권을 돌려줌
create_task(generator)는 제너레이터를Task로 감싸고 이벤트 루프 큐에 넣은 뒤 반환함- 작업을 이벤트 루프에 스케줄링하는 역할임
run(main)은 실제asyncio.run()과 비슷한 모양으로 이벤트 루프를 시작함- 처음 받은
main을Task로 감싸 큐에 넣음 - 큐가 비어 있지 않은 동안 다음 작업을 꺼냄
task.iter.send(None)으로 작업을 진행함StopIteration이 발생하면task.finished = True로 설정함- 예외가 없으면 작업을 다시 이벤트 루프 큐에 넣음
- 처음 받은
next(task.iter)대신task.iter.send(None)을 쓰는 것은async/await키워드와 함께 작업할 때의 특성으로, 여기서는 같은 역할을 함
async 호환 sleep과 jacobio 예제
- 기존
sleep은 제너레이터 함수였지만,await는 제너레이터 함수와 직접 조합할 수 없음await대상은__await__를 가진 객체이거나 coroutine 함수여야 함
- 실제 대기 로직은
_sleep(seconds)제너레이터로 옮김_sleep은 일정 시간이 지날 때까지yield함
async def sleep(seconds)는_sleep(seconds)로 작업을 만들고 그 작업을await함await task는Task.__await__()를 호출함- 작업이 끝나지 않았으면
yield로 이벤트 루프에 제어권을 넘김
- 완성된 커스텀 파일
jacobio.py는 다음 요소를 포함함- 이벤트 루프 큐
_sleepasync sleepTaskcreate_taskrun
- 사용 예제에서는 기존
yield from을await로 바꾸고,await를 쓰는 함수에async를 붙임task1은 두 번 출력하고 매번jacobio.sleep(1)을 기다림task2는 세 번 출력하고 매번jacobio.sleep(0)을 기다림main은 두 작업을 만들고 둘 다await한 뒤done을 출력함
- 예제 출력은
Task 1,Task 2,Task 2,Task 2,Task 1,done순서임
실제 asyncio로 바꾸기
- 커스텀
jacobio예제에서jacobio를 모두asyncio로 바꾸면 실제 asyncio 패키지를 사용하는 코드가 됨 - 대응되는 함수는 다음과 같음
jacobio.sleep()→asyncio.sleep()jacobio.create_task()→asyncio.create_task()jacobio.run()→asyncio.run()
- 실제 asyncio는 내부에서 훨씬 더 많은 일을 함
- 이 이벤트 루프 관리자는 가능한 단순하게 만든 구현이라 asyncio의 기본 아이디어는 보여주지만, 실제 패키지의 규모와 복잡성 때문에 실제 소스 코드 흐름과는 조금 다름
- 실제 asyncio를 쓰면 두 작업을 각각 만들어 둘 다
await하는 대신asyncio.gather()같은 함수로 여러 작업을 처리할 수 있음 - 관련 글로 handling asyncio tasks like a pro가 연결됨
댓글과 토론
Hacker News 의견들
-
Asyncio는 이벤트 루프를 직접 만든 구현으로 교체할 수 있음
Temporal Python에서는 워크플로를 커스텀 내구성 있는 asyncio 이벤트 루프로 표현해서,asyncio.sleep같은 것도 내구성 있는 타이머가 됨. 즉 코드가 다른 머신에서 재개될 수 있어서 몇 주 동안 sleep하는 것도 가능함
구현 방식은 이 글에 설명되어 있음: https://temporal.io/blog/durable-distributed-asyncio-event-l...
asyncio의 가장 큰 문제는 Python에서 동기 호출로 asyncio 스레드를 막아 전체 시스템을 쉽게 멈추게 만들 수 있다는 점임. Python에는 호출 그래프를 만들어async def안에서 스레드를 막는 호출이 직접 또는 간접적으로 호출되는지 탐지하는 정적 분석 도구가 절실함- 안 좋은 생각일 수도 있지만, 현재처럼 모든 호출이 기본적으로 동기인 방식 대신 반대로 하면 어떨까 싶음
산술 연산이나 자료구조 접근 같은 기본 동작은 빠르게 동기 호출로 허용 목록에 넣고, 이벤트 루프가 의심스러울 정도로 빠르게 돌면 동기여야 할 다른 작업도 감지할 수 있지 않을까 함 - Temporal은 정말 멋짐
asyncio 이벤트 루프를 어떻게 구현했는지 알았을 때 진짜 머리가 확 트이는 순간이었음 - 왜 스레드를 쓰지 않는지 궁금함
Python이 정말 동시성에 적합한 언어인지 아직도 이해하려는 중임. Asyncio는 늘 간신히 버티는 느낌이었고, 내 기준으로는 C#의 비동기 구현이 더 깔끔함 - 워크플로 예제에서
Purchaser.purchase는do_purchase여야 하는 것 아닌가? - 그걸 정적으로 잡아내기는 쉽지 않을 것임
단순한read()도 디스크립터가 무엇이고 어떻게 설정됐는지에 따라 막힐 수도 있고 아닐 수도 있음. 이걸 어떻게 정적 분석으로 감지하겠나?
- 안 좋은 생각일 수도 있지만, 현재처럼 모든 호출이 기본적으로 동기인 방식 대신 반대로 하면 어떨까 싶음
-
이 구현은 sleep할 때 바쁜 대기를 하는 것처럼 보임. 즉 현재 실행 가능한 작업이 없어도 이벤트 루프가 계속 돎
예전에 다른 장난감 구현을 본 기억이 있는데, 거기서는 작업의 다음 실행 가능 시간을 정렬된 순서로 추적해서 현재 실행 가능한 작업이 없으면 이벤트 루프 자체를 sleep시켰음. 실제 asyncio도 이런 식으로 동작한다고 이해하고 있음
이후에는 다음 실행 가능 조건이 단순한 벽시계 시간이 아니라 소켓 같은 것에 대한 의존성도 가질 수 있게 확장돼서,select를 시간 제한과 함께 쓸 수 있게 됨- asyncio 자체에서도 기본 제공 구현 외에 커스텀 이벤트 루프 구현을 쓸 수 있음
유명한 구현 중 하나가 uvloop(https://github.com/MagicStack/uvloop)인데, 기본적으로 libuv를 사용해 루프를 구현하고, 설명한select같은 작업을 libuv가 처리함 - 이건 SimPy [1] / Simpy.io [2]와 꽤 비슷하게 들리지만, SimPy는 asyncio보다 여러 해 앞서 나왔음
[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io - 한 관점에서는 이 방식에 딱히 문제가 없음
이벤트 루프가 꼭 반복할 필요도 없고,main을 실행해서 시작한 뒤 그게 끝나면 같이 끝나도 됨. 소켓을 기다리는while true루프가 있는 서버를 시작하고, 종료 조건이나 인터럽트에서 끝나 프로그램도 종료되는 상황을 떠올리면 됨
이벤트 루프 관점에서는 바쁜 대기도 없고 sleep이나 소켓을 만질 필요도 없음. 완료될 때까지 실행하는 방식과 영원히 실행하는 방식의 차이임
장난감 루프를 만든다면 영원히 실행되는 경우는 굳이 다루지 않는 편이 낫다고 봄 - 이런 내용을 배우기에 가장 좋은 책이 뭘까?
- asyncio 자체에서도 기본 제공 구현 외에 커스텀 이벤트 루프 구현을 쓸 수 있음
-
David Beazley의 asyncio 강연이 훌륭함
그걸 바탕으로 이산 사건 시뮬레이션 도구를 만들었음. asyncio를 직접 구현하고 시스템 시계를 시뮬레이션 시간으로 바꿀 수 있다는 점이 꽤 멋짐- David Beazley의 asyncio 강연 링크: https://youtube.com/watch?v=Y4Gt3Xjd7G8
-
정말 훌륭한 글이고, 처음 읽는 사람이 지루해질 만한 부분을 잘 건너뛴 고수준 설명임
마지막에 실제 내부에서 어떻게 동작하는지 설명하는 다른 자료를 추가하면 더 좋겠음- 비슷한 글인데 꽤 좋다고 생각했음: https://tenthousandmeters.com/blog/python-behind-the-scenes-...
-
글이
yield를 전혀 쓰지 않고 같은 내용을 보여줬다면 더 좋았을 것 같음. 솔직히 진짜 마법처럼 느껴지는 부분이 거기임
Python 코루틴에 대한 훨씬 더 깊은 설명은 여기 있음: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro... -
poll()언급이 전혀 없나? 그렇다면 asyncio가 동작하는 방식과는 전혀 다름 -
Python이
def를 가로채서, 실제로는 함수가 아닌 객체를 만드는 데 쓰게 한 건 완전히 이상함
최소한 다른 키워드라도 만들 수 있었을 텐데- 그건 함수가 맞고, 다만 반환 타입이 다른 함수로 변환된 것임
생성기나 코루틴을 반환하는 형태이고, 타입 시그니처는 여기서 볼 수 있음 [1]. 언어 차원의 문법 설탕이 아니라도 데코레이터 같은 걸 써서 순수 Python에서도 비슷하게 할 수 있음
[1] https://docs.python.org/3/library/typing.html#typing.Generat...
단, 타입 시그니처는 학술적 관심으로 짚은 것이고, 실제 타입 표기에서는 더 단순한Iterable과Awaitable을 선호함 - 실제 문제가 뭔가? 아니면 그냥 언어 순수성에 대한 이야기인가?
- 이게 비극이라고 볼 일은 아님
개발자 경험의 의미론을 언어 순수성 같은 것보다 앞에 둔 것처럼 들림.async함수가 비동기 함수라는 점 때문에 헷갈린 적은 한 번도 없었음 - Python의 더 큰 문제는 애초에 함수에
def를 써야 해서, JS 화살표 함수나 C++ 람다처럼 인라인 함수를 만들 수 없다는 점임
이건 언어 순수성 문제가 아니라 그냥 불편한 것임 - 데코레이터 얘기인가? Python 맥락에서 무슨 말을 하는지 파악하기가 꽤 혼란스러움
- 그건 함수가 맞고, 다만 반환 타입이 다른 함수로 변환된 것임