Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 525 – 비동기 제너레이터

Author:
Yury Selivanov <yury at edgedb.com>
Discussions-To:
Python-Dev list
Status:
Final
Type:
Standards Track
Created:
28-Jul-2016
Python-Version:
3.6
Post-History:
02-Aug-2016, 23-Aug-2016, 01-Sep-2016, 06-Sep-2016

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

PEP 492는 Python 3.5에 네이티브 코루틴과 async/await 문법 지원을 도입했습니다. 이 문서에서는 비동기 제너레이터 지원을 추가하여 Python의 비동기 기능을 확장할 것을 제안합니다.

근거와 목표

일반 제너레이터(PEP 255에서 도입)는 복잡한 데이터 생산자를 우아하게 작성하고 이를 이터레이터처럼 동작하게 하는 방법을 제공했습니다.

그러나 현재 비동기 이터레이션 프로토콜(async for)에는 이에 해당하는 개념이 없습니다. 이 때문에 비동기 데이터 생산자를 작성하는 일이 불필요하게 복잡해집니다. async for 문에서 사용하려면 __aiter__와 __anext__를 구현하는 클래스를 정의해야 하기 때문입니다.

본질적으로 PEP 255의 목표와 근거를 비동기 실행의 경우에 적용하면 이 제안에도 그대로 들어맞습니다.

성능은 이 제안의 또 다른 장점입니다. 참조 구현을 테스트한 결과, 비동기 제너레이터는 비동기 이터레이터로 구현한 동등한 코드보다 2배 빠릅니다.

코드 품질 개선을 보여 주는 예로, 이터레이션하면 주어진 지연 시간 간격으로 숫자를 출력하는 다음 클래스를 살펴보십시오:

class Ticker:
    """Yield numbers from 0 to `to` every `delay` seconds."""

    def __init__(self, delay, to):
        self.delay = delay
        self.i = 0
        self.to = to

    def __aiter__(self):
        return self

    async def __anext__(self):
        i = self.i
        if i >= self.to:
            raise StopAsyncIteration
        self.i += 1
        if i:
            await asyncio.sleep(self.delay)
        return i

같은 기능을 훨씬 간단한 비동기 제너레이터로 구현할 수 있습니다:

async def ticker(delay, to):
    """Yield numbers from 0 to `to` every `delay` seconds."""
    for i in range(to):
        yield i
        await asyncio.sleep(delay)

명세

이 제안은 Python에 비동기 제너레이터라는 개념을 도입합니다.

이 명세는 Python의 제너레이터와 코루틴 구현(PEP 342, PEP 380, PEP 492)에 대한 지식을 전제로 합니다.

비동기 제너레이터

Python 제너레이터는 하나 이상의 yield 표현식을 포함하는 모든 함수입니다:

def func():            # a function
    return

def genfunc():         # a generator function
    yield

저희는 같은 방식으로 비동기 제너레이터를 정의할 것을 제안합니다:

async def coro():      # a coroutine function
    await smth()

async def asyncgen():  # an asynchronous generator function
    await smth()
    yield 42

비동기 제너레이터 함수를 호출한 결과는 비동기 제너레이터 객체이며, 이 객체는 PEP 492에 정의된 비동기 이터레이션 프로토콜을 구현합니다.

비동기 제너레이터 안에 값을 반환하는 return 문을 두면 SyntaxError입니다.

비동기 이터레이션 프로토콜 지원

이 프로토콜은 두 가지 특수 메서드의 구현을 요구합니다:

  1. 비동기 이터레이터를 반환하는 __aiter__ 메서드.
  2. 어웨이터블 객체를 반환하는 __anext__ 메서드. 이 객체는 StopIteration 예외를 사용해 값을 “yield”하고, StopAsyncIteration 예외를 사용해 이터레이션의 끝을 알립니다.

비동기 제너레이터는 이 두 메서드를 모두 정의합니다. 간단한 비동기 제너레이터를 수동으로 이터레이션해 봅시다:

async def genfunc():
    yield 1
    yield 2

gen = genfunc()

assert gen.__aiter__() is gen

assert await gen.__anext__() == 1
assert await gen.__anext__() == 2

await gen.__anext__()  # This line will raise StopAsyncIteration.

종료 처리

PEP 492에서는 코루틴을 실행하려면 이벤트 루프나 스케줄러가 필요합니다. 비동기 제너레이터는 코루틴에서 사용하도록 만들어졌으므로, 이를 실행하고 종료 처리하는 데에도 이벤트 루프가 필요합니다.

비동기 제너레이터는 async with뿐만 아니라 try..finally 블록도 가질 수 있습니다. 제너레이터가 부분적으로만 이터레이션된 뒤 가비지 컬렉션되더라도 안전하게 종료 처리될 수 있음을 보장하는 것이 중요합니다. 예를 들면 다음과 같습니다:

async def square_series(con, to):
    async with con.transaction():
        cursor = con.cursor(
            'SELECT generate_series(0, $1) AS i', to)
        async for row in cursor:
            yield row['i'] ** 2

async for i in square_series(con, 1000):
    if i == 100:
        break

위 코드는 async with를 사용하여 트랜잭션 안에서 데이터베이스 커서를 이터레이션하는 비동기 제너레이터를 정의합니다. 그런 다음 이 제너레이터를 async for로 이터레이션하는데, 이 이터레이션은 어느 시점에서 중단됩니다.

그러면 square_series() 제너레이터는 가비지 컬렉션될 것이고, 제너레이터를 비동기적으로 닫는 메커니즘이 없으면 Python 인터프리터는 아무것도 할 수 없을 것입니다.

이 문제를 해결하기 위해 다음과 같이 할 것을 제안합니다:

  1. 특별한 awaitable을 반환하는 aclose 메서드를 비동기 제너레이터에 구현합니다. 이를 await하면 일시 중단된 제너레이터에 GeneratorExit을 던지고, GeneratorExit이나 StopAsyncIteration이 발생할 때까지 제너레이터를 이터레이션합니다.

    이는 close() 메서드가 일반 Python 제너레이터에 하는 일과 매우 비슷하지만, aclose()를 실행하려면 이벤트 루프가 필요하다는 점이 다릅니다.

  2. 비동기 제너레이터가 finally 블록에서 yield 표현식을 실행하면 RuntimeError를 발생시킵니다(단, await를 사용하는 것은 괜찮습니다).:
    async def gen():
        try:
            yield
        finally:
            await asyncio.sleep(1)   # Can use 'await'.
    
            yield                    # Cannot use 'yield',
                                     # this line will trigger a
                                     # RuntimeError.
    
  3. sys 모듈에 set_asyncgen_hooks()와 get_asyncgen_hooks()라는 두 개의 새 메서드를 추가합니다.

sys.set_asyncgen_hooks()의 기본 아이디어는 이벤트 루프가 비동기 제너레이터의 이터레이션과 종료 처리를 가로챌 수 있게 하여, 최종 사용자가 종료 처리 문제를 신경 쓸 필요 없이 모든 것이 그냥 동작하도록 하는 것입니다.

sys.set_asyncgen_hooks()는 두 개의 인자를 받습니다:

  • firstiter: 비동기 제너레이터가 처음 이터레이션될 때 호출될 호출 가능 객체입니다.
  • finalizer: 비동기 제너레이터가 GC되기 직전에 호출될 호출 가능 객체입니다.

비동기 제너레이터는 처음 이터레이션될 때 현재 finalizer에 대한 참조를 저장합니다.

비동기 제너레이터는 가비지 컬렉션되기 직전에 캐시해 둔 finalizer를 호출합니다. 이때 finalizer는 이터레이션이 시작될 때 활성 상태였던 루프에 aclose() 호출을 예약한다고 가정합니다.

예를 들어, 비동기 제너레이터의 안전한 종료 처리를 허용하기 위해 asyncio를 다음과 같이 수정합니다:

# asyncio/base_events.py

class BaseEventLoop:

    def run_forever(self):
        ...
        old_hooks = sys.get_asyncgen_hooks()
        sys.set_asyncgen_hooks(finalizer=self._finalize_asyncgen)
        try:
            ...
        finally:
            sys.set_asyncgen_hooks(*old_hooks)
            ...

    def _finalize_asyncgen(self, gen):
        self.create_task(gen.aclose())

두 번째 인자인 firstiter는 이벤트 루프가 자신의 제어하에 인스턴스화된 비동기 제너레이터들의 약한 집합(weak set)을 유지할 수 있게 합니다. 이를 통해 열려 있는 모든 제너레이터를 안전하게 파이널라이즈하고 이벤트 루프를 닫는 “종료(shutdown)” 메커니즘을 구현할 수 있습니다.

sys.set_asyncgen_hooks()는 스레드별로 동작하므로, 병렬 스레드에서 실행되는 여러 이벤트 루프가 안전하게 사용할 수 있습니다.

sys.get_asyncgen_hooks()는 firstiter와 finalizer 필드를 가진 namedtuple 형태의 구조체를 반환합니다.

asyncio

asyncio 이벤트 루프는 sys.set_asyncgen_hooks() API를 사용하여 예약된 모든 비동기 제너레이터의 약한 집합을 유지하고, 제너레이터가 GC될 때가 되면 그 제너레이터들의 aclose() 코루틴 메서드를 예약합니다.

asyncio 프로그램이 예약된 모든 비동기 제너레이터를 확실하게 파이널라이즈할 수 있도록, 새로운 이벤트 루프 코루틴 메서드인 loop.shutdown_asyncgens()를 추가할 것을 제안합니다. 이 메서드는 현재 열려 있는 모든 비동기 제너레이터가 aclose() 호출로 닫히도록 예약합니다.

loop.shutdown_asyncgens() 메서드를 호출한 후에는, 새로운 비동기 제너레이터가 처음 이터레이션될 때마다 이벤트 루프가 경고를 발생시킵니다. 이는 모든 비동기 제너레이터의 종료를 요청한 뒤에는 프로그램이 새로운 비동기 제너레이터를 이터레이션하는 코드를 실행해서는 안 된다는 취지입니다.

shutdown_asyncgens 코루틴을 사용하는 방법의 예:

try:
    loop.run_forever()
finally:
    loop.run_until_complete(loop.shutdown_asyncgens())
    loop.close()

비동기 제너레이터 객체

이 객체는 표준 Python 제너레이터 객체를 본떠 만들어졌습니다. 본질적으로 비동기 제너레이터의 동작은 동기 제너레이터의 동작을 그대로 재현하도록 설계되었으며, 유일한 차이점은 API가 비동기라는 것입니다.

다음 메서드와 프로퍼티가 정의됩니다:

  1. agen.__aiter__(): agen을 반환합니다.
  2. agen.__anext__(): await될 때 비동기 제너레이터 이터레이션을 한 번 수행하는 어웨이터블을 반환합니다.
  3. agen.asend(val): val 객체를 agen 제너레이터에 밀어 넣는 어웨이터블을 반환합니다. agen이 아직 이터레이션되지 않았다면 val은 None이어야 합니다.

    예시:

    async def gen():
        await asyncio.sleep(0.1)
        v = yield 42
        print(v)
        await asyncio.sleep(0.2)
    
    g = gen()
    
    await g.asend(None)      # Will return 42 after sleeping
                             # for 0.1 seconds.
    
    await g.asend('hello')   # Will print 'hello' and
                             # raise StopAsyncIteration
                             # (after sleeping for 0.2 seconds.)
    
  4. agen.athrow(typ, [val, [tb]]): agen 제너레이터에 예외를 던지는 어웨이터블을 반환합니다.

    예시:

    async def gen():
        try:
            await asyncio.sleep(0.1)
            yield 'hello'
        except ZeroDivisionError:
            await asyncio.sleep(0.2)
            yield 'world'
    
    g = gen()
    v = await g.asend(None)
    print(v)                # Will print 'hello' after
                            # sleeping for 0.1 seconds.
    
    v = await g.athrow(ZeroDivisionError)
    print(v)                # Will print 'world' after
                            # sleeping 0.2 seconds.
    
  5. agen.aclose(): 제너레이터에 GeneratorExit 예외를 던지는 어웨이터블을 반환합니다. agen이 예외를 처리했다면 어웨이터블은 산출된 값을 반환할 수 있고, 그렇지 않으면 agen이 닫히고 예외가 호출자에게 다시 전파됩니다.
  6. agen.__name__ 및 agen.__qualname__: 읽고 쓸 수 있는 이름 속성과 정규화된 이름 속성입니다.
  7. agen.ag_await: agen이 현재 await하고 있는 객체 또는 None입니다. 이는 현재 제너레이터에서 사용할 수 있는 gi_yieldfrom과 코루틴에서 사용할 수 있는 cr_await와 비슷합니다.
  8. agen.ag_frame, agen.ag_running, agen.ag_code: 표준 제너레이터의 비슷한 속성들과 같은 방식으로 정의됩니다.

StopIteration과 StopAsyncIteration은 비동기 제너레이터 밖으로 전파되지 않으며, RuntimeError로 대체됩니다.

구현 세부 사항

비동기 제너레이터 객체(PyAsyncGenObject)는 PyGenObject와 구조체 레이아웃을 공유합니다. 이에 더해 참조 구현은 세 가지 새로운 객체를 도입합니다:

  1. PyAsyncGenASend: __anext__ 메서드와 asend() 메서드를 구현하는 어웨이터블 객체입니다.
  2. PyAsyncGenAThrow: athrow() 메서드와 aclose() 메서드를 구현하는 어웨이터블 객체입니다.
  3. _PyAsyncGenWrappedValue: 비동기 제너레이터에서 직접 산출되는 모든 객체는 암묵적으로 이 구조체에 박싱됩니다. 이를 통해 제너레이터 구현은 일반 이터레이션 프로토콜로 산출된 객체와 비동기 이터레이션 프로토콜로 산출된 객체를 구분할 수 있습니다.

PyAsyncGenASend와 PyAsyncGenAThrow는 어웨이터블이며(self를 반환하는 __await__ 메서드를 가집니다), 코루틴과 유사한 객체입니다(__iter__, __next__, send(), throw() 메서드를 구현합니다). 본질적으로 이 객체들은 비동기 제너레이터가 이터레이션되는 방식을 제어합니다:

../_images/pep-0525-1.png

PyAsyncGenASend와 PyAsyncGenAThrow

PyAsyncGenASend는 __anext__ 메서드와 asend() 메서드를 구동하고 비동기 이터레이션 프로토콜을 구현하는 코루틴과 유사한 객체입니다.

agen.asend(val)과 agen.__anext__()는 PyAsyncGenASend의 인스턴스를 반환합니다(이 인스턴스는 부모 agen 객체에 대한 참조를 유지합니다).

데이터 흐름은 다음과 같이 정의됩니다:

  1. PyAsyncGenASend.send(val)이 처음 호출되면 val이 부모 agen 객체에 전달됩니다(PyGenObject의 기존 기능을 사용합니다).

    이후 PyAsyncGenASend 객체를 이터레이션하면 agen에 None이 전달됩니다.

    _PyAsyncGenWrappedValue 객체가 산출되면 이 객체는 언박싱되고, 언래핑된 값을 인자로 하는 StopIteration 예외가 발생합니다.

  2. PyAsyncGenASend.throw(*exc)가 처음 호출되면 *exc가 부모 agen 객체에 던져집니다.

    이후 PyAsyncGenASend 객체를 이터레이션하면 agen에 None이 전달됩니다.

    _PyAsyncGenWrappedValue 객체가 yield되면 언박싱되고, 언래핑된 값을 인자로 하는 StopIteration 예외가 발생합니다.

  3. 비동기 제너레이터의 return 문은 StopAsyncIteration 예외를 발생시키며, 이 예외는 PyAsyncGenASend.send() 및 PyAsyncGenASend.throw() 메서드를 통해 전파됩니다.

PyAsyncGenAThrow는 PyAsyncGenASend와 매우 비슷합니다. 유일한 차이점은 PyAsyncGenAThrow.send()가 처음 호출될 때 부모 agen 객체에 (값을 밀어 넣는 대신) 예외를 던진다는 것입니다.

새로운 표준 라이브러리 함수와 타입

  1. types.AsyncGeneratorType – 비동기 제너레이터 객체의 타입.
  2. 이벤트 루프에서 비동기 제너레이터 파이널라이저와 이터레이션 인터셉터를 설정하는 sys.set_asyncgen_hooks() 및 sys.get_asyncgen_hooks() 메서드.
  3. inspect.isasyncgen() 및 inspect.isasyncgenfunction() 인트로스펙션 함수.
  4. asyncio 이벤트 루프의 새 메서드: loop.shutdown_asyncgens().
  5. 새로운 collections.abc.AsyncGenerator 추상 베이스 클래스.

하위 호환성

이 제안은 하위 호환성을 완전히 유지합니다.

Python 3.5에서는 내부에 yield 표현식이 있는 async def 함수를 정의하면 SyntaxError가 발생하므로, 3.6에서 비동기 제너레이터를 도입해도 안전합니다.

성능

일반 제너레이터

일반 제너레이터의 성능은 저하되지 않습니다. 다음 마이크로 벤치마크는 비동기 제너레이터가 있는 CPython과 없는 CPython에서 같은 속도로 실행됩니다:

def gen():
    i = 0
    while i < 100000000:
        yield i
        i += 1

list(gen())

비동기 이터레이터 대비 개선 사항

다음 마이크로 벤치마크는 비동기 제너레이터가 순수 Python으로 구현된 비동기 이터레이터보다 약 2.3배 빠르다는 것을 보여 줍니다:

N = 10 ** 7

async def agen():
    for i in range(N):
        yield i

class AIter:
    def __init__(self):
        self.i = 0

    def __aiter__(self):
        return self

    async def __anext__(self):
        i = self.i
        if i >= N:
            raise StopAsyncIteration
        self.i += 1
        return i

설계 시 고려 사항

aiter() 및 anext() 내장 함수

원래 PEP 492는 __aiter__를 어웨이터블 객체를 반환해야 하는 메서드로 정의했으며, 그 결과로 비동기 이터레이터를 얻었습니다.

그러나 CPython 3.5.2에서 __aiter__는 비동기 이터레이터를 직접 반환하도록 재정의되었습니다. 하위 호환성을 깨뜨리지 않기 위해 Python 3.6은 두 방식을 모두 지원하기로 결정되었습니다. __aiter__는 여전히 어웨이터블을 반환할 수 있으며, 이때 DeprecationWarning이 발생합니다.

Python 3.6에서 __aiter__가 이처럼 이중적인 성격을 가지므로, aiter() 내장 함수의 동기 구현을 추가할 수 없습니다. 따라서 Python 3.7까지 기다릴 것을 제안합니다.

비동기 리스트/딕셔너리/집합 컴프리헨션

비동기 컴프리헨션 문법은 비동기 제너레이터 메커니즘과 관련이 없으므로 별도의 PEP에서 다루어야 합니다.

비동기 yield from

비동기 제너레이터에서 yield from을 지원하도록 구현하는 것은 이론적으로 가능하지만, 그러려면 제너레이터 구현을 대대적으로 재설계해야 합니다.

또한 코루틴 위에 또 다른 코루틴 프로토콜을 구현하는 메커니즘을 제공할 필요가 없으므로, 비동기 제너레이터에서는 yield from이 덜 중요합니다. 그리고 비동기 제너레이터를 조합할 때는 단순한 async for 루프를 사용하면 됩니다:

async def g1():
    yield 1
    yield 2

async def g2():
    async for v in g1():
        yield v

asend()와 athrow() 메서드가 필요한 이유

이 메서드들을 사용하면 비동기 제너레이터로 contextlib.contextmanager와 비슷한 개념을 구현할 수 있습니다. 예를 들어, 제안된 설계를 사용하면 다음과 같은 패턴을 구현할 수 있습니다:

@async_context_manager
async def ctx():
    await open()
    try:
        yield
    finally:
        await close()

async with ctx():
    await ...

또 다른 이유는 __anext__에서 반환된 객체를 사용하여 비동기 제너레이터에 데이터를 밀어 넣고 예외를 던질 수는 있지만, 이를 올바르게 수행하기가 어렵다는 점입니다. 명시적인 asend()와 athrow()를 추가하면 이를 안전하게 수행할 수 있는 길이 열립니다.

구현 측면에서 asend()는 __anext__를 조금 더 일반화한 버전이며, athrow()는 aclose()와 매우 비슷합니다. 따라서 비동기 제너레이터에 이 메서드들을 정의해도 복잡성이 추가로 늘어나지 않습니다.

예시

현재 참조 구현으로 동작하는 예제입니다(0부터 9까지의 숫자를 1초 간격으로 출력합니다):

async def ticker(delay, to):
    for i in range(to):
        yield i
        await asyncio.sleep(delay)


async def run():
    async for i in ticker(1, 10):
        print(i)


import asyncio
loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(run())
finally:
    loop.close()

수락

PEP 525는 2016년 9월 6일 Guido가 채택했습니다 [2].

구현

구현은 이슈 28003에서 추적하고 있습니다 [3]. 참조 구현 git 저장소는 [1]에서 확인할 수 있습니다.

참고 문헌

감사의 글

이 PEP에 대해 피드백과 코드 리뷰, 논의를 해 주신 Guido van Rossum, Victor Stinner, Elvis Pranskevichus, Nathaniel Smith, Łukasz Langa, Andrew Svetlov 등 많은 분께 감사드립니다.