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
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 492는 Python 3.5에 네이티브 코루틴과 async/await 구문에 대한 지원을 도입했습니다. 여기서는 비동기 제너레이터에 대한 지원을 추가하여 Python의 비동기 기능을 확장할 것을 제안합니다.
근거 및 목표
일반 제너레이터는 (PEP 255에서 도입되어) 복잡한 데이터 생산자를 작성하고 이를 이터레이터처럼 동작하게 하는 우아한 방법을 가능하게 했습니다.
그러나 현재 비동기 반복 프로토콜(async for)에 해당하는 개념은 없습니다. 따라서 비동기 데이터 생산자를 작성하는 일이 불필요하게 복잡해집니다. async for 문에서 사용할 수 있으려면 __aiter__ 및 __anext__를 구현하는 클래스를 정의해야 하기 때문입니다.
본질적으로 PEP 255의 목표와 근거를 비동기 실행 상황에 적용해도 이 제안에 그대로 유효합니다.
성능도 이 제안의 추가적인 고려 사항입니다. 참조 구현을 테스트한 결과, 비동기 제너레이터가 비동기 이터레이터로 구현한 동등한 방식보다 2x 빠릅니다.
코드 품질 향상을 보여 주는 예로, 순회할 때마다 지정된 지연 시간 후 숫자를 출력하는 다음 클래스를 살펴보십시오.:
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입니다.
비동기 반복 프로토콜 지원
이 프로토콜에서는 두 개의 특수 메서드를 구현해야 합니다.
- 비동기 이터레이터를 반환하는
__aiter__메서드입니다. StopIteration예외를 사용하여 값을 “yield”하고StopAsyncIteration예외를 사용하여 반복의 끝을 알리는 어웨이터블 객체를 반환하는__anext__메서드입니다.
비동기 제너레이터는 이 두 메서드를 모두 정의합니다. 간단한 비동기 제너레이터를 수동으로 순회해 봅시다.:
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는 코루틴을 실행하려면 이벤트 루프 또는 스케줄러가 필요하다고 규정합니다. 비동기 제너레이터는 코루틴에서 사용하도록 설계되었으므로, 이를 실행하고 종료 처리하려면 이벤트 루프도 필요합니다.
비동기 제너레이터에는 try..finally 블록과 async with를 사용할 수 있습니다. 제너레이터가 부분적으로 순회된 후 가비지 컬렉션되더라도 안전하게 종료 처리될 수 있도록 보장하는 것이 중요합니다. 예를 들어,:
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() 제너레이터가 가비지 컬렉션되며, 제너레이터를 비동기적으로 닫는 메커니즘이 없으면 파이썬 인터프리터는 아무 작업도 수행할 수 없습니다.
이 문제를 해결하기 위해 다음과 같이 할 것을 제안합니다:
- 비동기 제너레이터에 특수한 awaitable을 반환하는
aclose메서드를 구현합니다. 이를 await하면 일시 중단된 제너레이터에GeneratorExit를 던지고,GeneratorExit또는StopAsyncIteration중 하나가 발생할 때까지 제너레이터를 순회합니다.이는 일반 파이썬 제너레이터에서
close()메서드가 수행하는 작업과 매우 유사하지만,aclose()를 실행하려면 이벤트 루프가 필요합니다. - 비동기 제너레이터가
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.
sys모듈에set_asyncgen_hooks()와get_asyncgen_hooks()라는 두 가지 메서드를 추가합니다.
sys.set_asyncgen_hooks()의 목적은 이벤트 루프가 비동기 제너레이터의 순회와 종료 처리를 가로챌 수 있도록 하여, 최종 사용자가 종료 처리 문제를 신경 쓰지 않아도 모든 것이 정상적으로 작동하게 하는 것입니다.
sys.set_asyncgen_hooks()는 두 개의 인자를 받습니다:
firstiter: 비동기 제너레이터가 처음 순회될 때 호출되는 호출 가능 객체입니다.finalizer: 비동기 제너레이터가 가비지 컬렉션되기 직전에 호출되는 호출 가능 객체입니다.
비동기 제너레이터가 처음 순회될 때 현재 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는 이벤트 루프가 해당 이벤트 루프의 제어하에 생성된 비동기 제너레이터의 약한 집합을 유지할 수 있도록 합니다. 이를 통해 모든 열린 제너레이터를 안전하게 종료 처리하고 이벤트 루프를 닫는 “종료” 메커니즘을 구현할 수 있습니다.
sys.set_asyncgen_hooks()는 스레드별로 적용되므로, 여러 이벤트 루프가 병렬 스레드에서 이를 안전하게 사용할 수 있습니다.
sys.get_asyncgen_hooks()는 firstiter와 finalizer 필드를 포함하는 namedtuple과 유사한 구조를 반환합니다.
asyncio
asyncio 이벤트 루프는 sys.set_asyncgen_hooks() API를 사용하여 예약된 모든 비동기 제너레이터의 약한 집합을 유지하고, 제너레이터가 가비지 컬렉션될 때 해당 제너레이터의 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()
비동기 제너레이터 객체
이 객체는 표준 파이썬 제너레이터 객체를 모델로 합니다. 본질적으로 비동기 제너레이터의 동작은 동기 제너레이터의 동작을 재현하도록 설계되었으며, 유일한 차이점은 API가 비동기라는 점입니다.
다음 메서드와 속성이 정의됩니다:
agen.__aiter__():agen을 반환합니다.agen.__anext__(): 어웨이터블을 반환하며, 이를 어웨이트하면 한 번의 비동기 제너레이터 반복을 수행합니다.agen.asend(val): 어웨이터블을 반환하며,agen제너레이터에val객체를 전달합니다.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.)
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.
agen.aclose(): 어웨이터블을 반환하며, 제너레이터에GeneratorExit예외를 발생시킵니다.agen이 예외를 처리한 경우 어웨이터블은 산출된 값을 반환할 수 있으며, 그렇지 않으면agen이 닫히고 예외가 호출자에게 전파됩니다.agen.__name__및agen.__qualname__: 읽고 쓸 수 있는 이름 및 정규화된 이름 속성입니다.agen.ag_await: 현재agen이 대기하고 있는 객체 또는None입니다. 이는 제너레이터에서 현재 사용할 수 있는gi_yieldfrom및 코루틴에서 사용할 수 있는cr_await와 유사합니다.agen.ag_frame,agen.ag_running및agen.ag_code: 표준 제너레이터의 유사한 속성과 동일한 방식으로 정의됩니다.
StopIteration 및 StopAsyncIteration은 비동기 제너레이터 밖으로 전파되지 않고 RuntimeError로 대체됩니다.
구현 세부 사항
비동기 제너레이터 객체(PyAsyncGenObject)는 PyGenObject와 구조체 레이아웃을 공유합니다. 또한 참조 구현은 세 가지 새로운 객체를 도입합니다.
PyAsyncGenASend:__anext__및asend()메서드를 구현하는 어웨이터블 객체입니다.PyAsyncGenAThrow:athrow()및aclose()메서드를 구현하는 어웨이터블 객체입니다._PyAsyncGenWrappedValue: 비동기 제너레이터에서 직접 산출되는 모든 객체는 암묵적으로 이 구조체로 박싱됩니다. 이를 통해 제너레이터 구현은 일반 반복 프로토콜을 사용하여 산출되는 객체와 비동기 반복 프로토콜을 사용하여 산출되는 객체를 구분할 수 있습니다.
PyAsyncGenASend 및 PyAsyncGenAThrow는 어웨이터블(__await__ 메서드가 self를 반환함)이며 코루틴과 유사한 객체(__iter__, __next__, send() 및 throw() 메서드를 구현함)입니다. 본질적으로 이 객체들은 비동기 제너레이터가 반복되는 방식을 제어합니다.
PyAsyncGenASend 및 PyAsyncGenAThrow
PyAsyncGenASend는 __anext__ 및 asend() 메서드를 구동하고 비동기 반복 프로토콜을 구현하는 코루틴과 유사한 객체입니다.
agen.asend(val) 및 agen.__anext__()는 PyAsyncGenASend의 인스턴스를 반환합니다(이 인스턴스는 부모 agen 객체에 대한 참조를 보유합니다).
데이터 흐름은 다음과 같이 정의됩니다.
PyAsyncGenASend.send(val)이 처음 호출되면val이 부모agen객체에 전달됩니다(PyGenObject의 기존 기능 사용).이후
PyAsyncGenASend객체를 반복하면agen에None이 전달됩니다._PyAsyncGenWrappedValue객체가 산출되면 해당 객체의 래퍼가 제거되고, 래퍼가 제거된 값이 인자로 포함된StopIteration예외가 발생합니다.PyAsyncGenASend.throw(*exc)가 처음 호출되면*exc가 부모agen객체에 전달됩니다.이후
PyAsyncGenASend객체를 반복하면agen에None이 전달됩니다._PyAsyncGenWrappedValue객체가 yield되면 해당 객체의 포장이 해제되고, 포장이 해제된 값을 인자로 하는StopIteration예외가 발생합니다.- 비동기 제너레이터의
return문은StopAsyncIteration예외를 발생시키며, 이 예외는PyAsyncGenASend.send()및PyAsyncGenASend.throw()메서드를 통해 전파됩니다.
PyAsyncGenAThrow는 PyAsyncGenASend와 매우 유사합니다. 유일한 차이점은 PyAsyncGenAThrow.send()를 처음 호출할 때 값을 부모 agen 객체에 넣는 대신 해당 객체에 예외를 던진다는 점입니다.
새로운 표준 라이브러리 함수 및 타입
types.AsyncGeneratorType– 비동기 제너레이터 객체의 타입입니다.- 이벤트 루프에서 비동기 제너레이터 종료자와 반복 인터셉터를 설정하는
sys.set_asyncgen_hooks()및sys.get_asyncgen_hooks()메서드입니다. inspect.isasyncgen()및inspect.isasyncgenfunction()인트로스펙션 함수입니다.- asyncio 이벤트 루프의 새로운 메서드:
loop.shutdown_asyncgens()입니다. - 새로운
collections.abc.AsyncGenerator추상 베이스 클래스입니다.
하위 호환성
이 제안은 완전한 하위 호환성을 제공합니다.
Python 3.5에서는 내부에 yield 표현식이 있는 async def 함수를 정의하면 SyntaxError이므로, Python 3.6에서 비동기 제너레이터를 도입해도 안전합니다.
성능
일반 제너레이터
일반 제너레이터의 성능 저하는 없습니다. 다음 마이크로 벤치마크는 비동기 제너레이터의 유무와 관계없이 CPython에서 동일한 속도로 실행됩니다.:
def gen():
i = 0
while i < 100000000:
yield i
i += 1
list(gen())
비동기 이터레이터에 대한 개선 사항
다음 마이크로 벤치마크는 순수 Python으로 구현된 비동기 이터레이터보다 비동기 제너레이터가 약 2.3x faster빠른 것으로 나타냅니다.:
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__를 awaitable 객체를 반환하여 비동기 이터레이터가 되도록 하는 메서드로 정의했습니다.
그러나 CPython 3.5.2에서는 __aiter__가 비동기 이터레이터를 직접 반환하도록 재정의되었습니다. 하위 호환성이 깨지는 것을 방지하기 위해 Python 3.6에서는 두 방식을 모두 지원하기로 했습니다. 즉, __aiter__는 여전히 awaitable을 반환할 수 있으며, 이 경우 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()
승인
구현
구현은 이슈 28003 [3]에서 추적되고 있습니다. 참조 구현 git 저장소는 [1]에서 확인할 수 있습니다.
참고 자료
감사의 말
이 PEP에 관한 피드백, 코드 리뷰, 그리고 논의에 참여해 주신 Guido van Rossum, Victor Stinner, Elvis Pranskevichus, Nathaniel Smith, Łukasz Langa, Andrew Svetlov 및 많은 다른 분들께 감사드립니다.
Copyright
This document has been placed in the public domain.