3P by GN⁺ | ★ favorite | 댓글 1개
  • Python의 asyncio는 I/O-bound 프로그램을 다루는 도구이며, 글은 이를 제너레이터 위에 단순 구현으로 다시 만들어 작동 원리를 보여줌
  • 핵심은 여러 작업을 가진 이벤트 루프가 작업을 실행하다가 yield에서 제어권을 돌려받고, 다음 작업으로 넘어가는 구조임
  • sleep은 시간이 지날 때까지 yield하는 서브 제너레이터로 만들 수 있으며, yield from은 하위 제너레이터가 끝난 뒤 원래 작업을 이어가게 함
  • async/await로 바꾸면 Task.__await__()가 완료 전까지 이벤트 루프로 제어권을 넘기고, create_taskrun이 실제 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하는 동안 작업은 멈춘 상태가 됨
    • 시간이 충분히 지나면 sleepwhile 루프가 끝남
    • 더 이상 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.finishedFalse로 시작함
    • 제너레이터가 StopIteration을 발생시키면 완료된 것으로 처리함
    • done()은 완료 상태를 반환함
  • Task.__await__()는 작업이 끝나지 않은 동안 계속 yield self를 호출함
    • 이 동작이 이벤트 루프에 제어권을 돌려줌
  • create_task(generator)는 제너레이터를 Task로 감싸고 이벤트 루프 큐에 넣은 뒤 반환함
    • 작업을 이벤트 루프에 스케줄링하는 역할임
  • run(main)은 실제 asyncio.run()과 비슷한 모양으로 이벤트 루프를 시작함
    • 처음 받은 mainTask로 감싸 큐에 넣음
    • 큐가 비어 있지 않은 동안 다음 작업을 꺼냄
    • 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 taskTask.__await__()를 호출함
    • 작업이 끝나지 않았으면 yield로 이벤트 루프에 제어권을 넘김
  • 완성된 커스텀 파일 jacobio.py는 다음 요소를 포함함
    • 이벤트 루프 큐
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • 사용 예제에서는 기존 yield fromawait로 바꾸고, 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.purchasedo_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이나 소켓을 만질 필요도 없음. 완료될 때까지 실행하는 방식과 영원히 실행하는 방식의 차이임
      장난감 루프를 만든다면 영원히 실행되는 경우는 굳이 다루지 않는 편이 낫다고 봄
    • 이런 내용을 배우기에 가장 좋은 책이 뭘까?
  • David Beazley의 asyncio 강연이 훌륭함
    그걸 바탕으로 이산 사건 시뮬레이션 도구를 만들었음. asyncio를 직접 구현하고 시스템 시계를 시뮬레이션 시간으로 바꿀 수 있다는 점이 꽤 멋짐

  • 정말 훌륭한 글이고, 처음 읽는 사람이 지루해질 만한 부분을 잘 건너뛴 고수준 설명
    마지막에 실제 내부에서 어떻게 동작하는지 설명하는 다른 자료를 추가하면 더 좋겠음

  • 글이 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...
      단, 타입 시그니처는 학술적 관심으로 짚은 것이고, 실제 타입 표기에서는 더 단순한 IterableAwaitable을 선호함
    • 실제 문제가 뭔가? 아니면 그냥 언어 순수성에 대한 이야기인가?
    • 이게 비극이라고 볼 일은 아님
      개발자 경험의 의미론을 언어 순수성 같은 것보다 앞에 둔 것처럼 들림. async 함수가 비동기 함수라는 점 때문에 헷갈린 적은 한 번도 없었음
    • Python의 더 큰 문제는 애초에 함수에 def를 써야 해서, JS 화살표 함수나 C++ 람다처럼 인라인 함수를 만들 수 없다는 점임
      이건 언어 순수성 문제가 아니라 그냥 불편한 것임
    • 데코레이터 얘기인가? Python 맥락에서 무슨 말을 하는지 파악하기가 꽤 혼란스러움