PEP 789 – 비동기 제너레이터에서 yield를 제한하여 태스크 취소 버그 방지하기
- Author:
- Zac Hatfield-Dodds <zac at zhd.dev>, Nathaniel J. Smith <njs at pobox.com>
- PEP-Delegate:
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 14-May-2024
- Python-Version:
- 3.14
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
Structured concurrency(구조적 동시성)는 Python에서 점점 더 널리 쓰이고 있습니다. asyncio.TaskGroup이나 asyncio.timeout 컨텍스트 관리자 같은 인터페이스는 조합적 추론을 가능하게 하며, 개발자가 동시 실행 태스크의 수명 범위를 명확하게 정할 수 있게 합니다. 그러나 이러한 컨텍스트 안에서 yield를 사용해 프레임을 일시 중단하면, 엉뚱한 태스크가 취소되거나 타임아웃이 무시되거나 예외가 잘못 처리되는 상황이 발생합니다. 더 근본적으로, TaskGroup 안에서 프레임을 일시 중단하는 것은 자식 태스크가 부모 프레임 안에 캡슐화된다는 구조적 동시성의 설계 원칙을 위반합니다.
이러한 문제를 해결하기 위해 이 PEP는 새로운 sys.prevent_yields() 컨텍스트 관리자를 제안합니다. 구문상 이 컨텍스트 안에 있을 때 yield를 시도하면 RuntimeError가 발생하여 태스크가 yield하지 못하게 됩니다. 또한 @contextmanager 같은 데코레이터가 데코레이트된 함수 안에서 yield를 허용할 수 있도록 하는 메커니즘도 제공됩니다. sys.prevent_yields()는 asyncio와 하위 라이브러리에서 태스크 그룹, 타임아웃, 취소를 구현하는 데 사용되며, 관련 메커니즘은 contextlib 등에서 제너레이터를 안전한 yield를 허용하는 컨텍스트 관리자로 변환하는 데 사용됩니다.
배경
구조적 동시성은 새로운 asyncio 인터페이스와 Trio, anyio 같은 서드파티 라이브러리의 형태로 Python에서 점점 더 널리 쓰이고 있습니다. 이러한 인터페이스는 사용자가 취소 스코프 안에서 프레임을 일시 중단하는 yield를 절대 작성하지 않는 한 조합적 추론을 가능하게 합니다.
취소 스코프는 해당 컨텍스트(…스코프) 안에서 일어나는 모든 작업을… 취소할 수 있는… 컨텍스트 관리자입니다. asyncio에서는 이것이 with asyncio.timeout():과 async with asyncio.TaskGroup() as tg:의 설계에 암묵적으로 들어 있으며, 이들은 각각 지정된 시간이 지나면 내부 작업을 취소하거나, 형제 태스크 중 하나가 예외를 발생시키면 나머지 형제 태스크를 취소합니다. 취소 스코프의 핵심 기능은 동기적이지만, 사용자에게 노출되는 컨텍스트 관리자는 동기일 수도 있고 비동기일 수도 있습니다. [1] [2]
이 구조적 접근 방식은 한 가지 특정한 함정에 걸리지 않는 한 훌륭하게 작동합니다. 그 함정이란 취소 스코프 안에서 yield하여 중첩 구조를 깨뜨리는 것입니다. 이는 구조적 제어 흐름에 함수를 넘나드는 goto 문을 몇 개 추가하는 것과 거의 같은 효과를 내며, 그 결과는 실로 심각합니다.
- 타임아웃, 형제 태스크의 오류, 또는 다른 태스크를 취소하라는 명시적 요청 등으로 인해 엉뚱한 태스크가 취소될 수 있습니다
CancelledError를 포함한 예외가 엉뚱한 태스크로 전달될 수 있습니다- 예외가
ExceptionGroup에 추가되지 않고 버려져 완전히 사라질 수 있습니다
문제 기술
근본적인 문제는 이렇습니다. yield는 호출 프레임을 일시 중단합니다. yield는 리프 프레임에서만 의미가 있습니다. 즉, 호출 스택이 A -> B -> C와 같다면 C는 일시 중단할 수 있지만, C를 실행 중인 상태로 둔 채 B를 일시 중단할 수는 없습니다.
그런데 TaskGroup은 일종의 “동시 호출” 프리미티브로, 하나의 프레임이 동시에 실행되는 여러 자식 프레임을 가질 수 있습니다. 이는 yield와 TaskGroup을 섞어 쓰도록 허용하면, B는 일시 중단되었는데 C는 활발히 실행 중인 바로 그 상황에 처할 수 있다는 뜻입니다. 이는 말이 되지 않으며, 심각한 실질적 문제를 일으킵니다(예를 들어, C가 예외를 발생시켰는데 A가 이미 반환했다면 그 예외를 전파할 방법이 없습니다).
이는 제너레이터 제어 흐름과 구조적 동시성 제어 흐름 사이의 근본적인 비호환성이며, API를 조금 손본다고 고칠 수 있는 문제가 아닙니다. 유일한 해결책은 TaskGroup 안에서 yield를 금지하는 것으로 보입니다.
타임아웃은 자식 태스크를 실행 중인 상태로 남겨 두지는 않지만, 밀접한 유사성과 관련 문제들을 고려하여 TaskGroup뿐 아니라 모든 취소 스코프 안에서 yield를 금지해야 한다는 결론에 이르렀습니다. 이에 대한 논의는 그냥 예외를 올바른 곳으로 전달하면 되지 않습니까?를 참고하십시오.
동기 부여 예제
실제로 이것이 어떤 모습일지 알아보기 위해 세 가지 예제를 살펴봅시다.
타임아웃이 바깥 스코프로 새어 나가는 경우
비동기 이터레이터를 순회하면서 각 요소마다 최대 max_time 초까지만 기다리고 싶다고 가정해 봅시다. 이 로직은 비동기 제너레이터로 캡슐화하는 것이 자연스럽습니다. 그러면 호출하는 쪽에서는 단순한 async for 루프를 계속 사용할 수 있습니다:
async def iter_with_timeout(ait, max_time):
try:
while True:
with timeout(max_time):
yield await anext(ait)
except StopAsyncIteration:
return
async def fn():
async for elem in iter_with_timeout(ait, max_time=1.0):
await do_something_with(elem)
안타깝게도 이 버전에는 버그가 있습니다. 제너레이터가 yield한 후 다시 재개되기 전에 타임아웃이 만료될 수 있습니다! 이 경우 바깥 태스크에서 CancelledError가 발생하며, 그곳에서는 with timeout(max_time): 문이 이를 잡을 수 없습니다.
해결 방법은 꽤 간단합니다. 타임아웃 컨텍스트 안에서 다음 요소를 가져온 다음, 그 컨텍스트 바깥에서 yield하면 됩니다.
async def correct_iter_with_timeout(ait, max_time):
try:
while True:
with timeout(max_time):
tmp = await anext(ait)
yield tmp
except StopAsyncIteration:
return
백그라운드 태스크가 새어 나가는 경우 (취소 및 예외 처리를 망가뜨림)
취소 스코프를 감싸는 인터페이스는 타임아웃만이 아닙니다. 게다가 백그라운드 워커 태스크가 필요하다면 yield하기 전에 TaskGroup을 그냥 닫아 버릴 수도 없습니다.
예를 들어, 여러 “센서”의 피드를 병합하는 데 사용할 팬인(fan-in) 제너레이터를 살펴봅시다. 또한 모의 센서에 작은 버퍼를 설정하여, 제어 흐름이 combined_iterators 제너레이터 바깥에 있는 동안 백그라운드 태스크에서 오류가 발생하도록 하겠습니다.
import asyncio, itertools
async def mock_sensor(name):
for n in itertools.count():
await asyncio.sleep(0.1)
if n == 1 and name == "b": # 'presence detection'
yield "PRESENT"
elif n == 3 and name == "a": # inject a simple bug
print("oops, raising RuntimeError")
raise RuntimeError
else:
yield f"{name}-{n}" # non-presence sensor data
async def move_elements_to_queue(ait, queue):
async for obj in ait:
await queue.put(obj)
async def combined_iterators(*aits):
"""Combine async iterators by starting N tasks, each of
which move elements from one iterable to a shared queue."""
q = asyncio.Queue(maxsize=2)
async with asyncio.TaskGroup() as tg:
for ait in aits:
tg.create_task(move_elements_to_queue(ait, q))
while True:
yield await q.get()
async def turn_on_lights_when_someone_gets_home():
combined = combined_iterators(mock_sensor("a"), mock_sensor("b"))
async for event in combined:
print(event)
if event == "PRESENT":
break
print("main task sleeping for a bit")
await asyncio.sleep(1) # do some other operation
asyncio.run(turn_on_lights_when_someone_gets_home())
이 코드를 실행하면 예상한 순서대로 관측값이 나오고, 이어서 ‘감지’가 나온 다음, 메인 태스크가 잠들어 있는 동안 백그라운드에서 해당 RuntimeError를 발생시킵니다. 그런데… 실제로는 RuntimeError를 전혀 볼 수 없으며, 다른 예외의 __context__로도 나타나지 않습니다!
>> python3.11 demo.py
a-0
b-0
a-1
PRESENT
main task sleeping for a bit
oops, raising RuntimeError
Traceback (most recent call last):
File "demo.py", line 39, in <module>
asyncio.run(turn_on_lights_when_someone_gets_home())
...
File "demo.py", line 37, in turn_on_lights_when_someone_gets_home
await asyncio.sleep(1) # do some other operation
File ".../python3.11/asyncio/tasks.py", line 649, in sleep
return await future
asyncio.exceptions.CancelledError
여기서도 문제는 취소 스코프 안에서 yield했다는 점입니다. 이번에는 자식 태스크 중 하나가 예외를 발생시킬 때 TaskGroup이 형제 태스크들을 취소하는 데 사용하는 스코프입니다. 그러나 형제 태스크를 대상으로 한 CancelledError가 대신 바깥 태스크에 주입되었고, 그 결과 ExceptionGroup(..., [RuntimeError()])을 생성하여 발생시킬 기회를 얻지 못했습니다.
이를 해결하려면 비동기 제너레이터를 비동기 이터러블을 yield하는 비동기 컨텍스트 관리자로 바꿔야 합니다. 이 경우 비동기 이터러블은 큐를 감싸는 제너레이터이며, 향후에는 어쩌면 큐 자체가 될 수도 있습니다:
async def queue_as_aiterable(queue):
# async generators that don't `yield` inside a cancel scope are fine!
while True:
try:
yield await queue.get()
except asyncio.QueueShutDown:
return
@asynccontextmanager # yield-in-cancel-scope is OK in a context manager
async def combined_iterators(*aits):
q = asyncio.Queue(maxsize=2)
async with asyncio.TaskGroup() as tg:
for ait in aits:
tg.create_task(move_elements_to_queue(ait, q))
yield queue_as_aiterable(q)
async def turn_on_lights_when_someone_gets_home():
...
async with combined_iterators(...) as ait:
async for event in ait:
...
사용자 정의 컨텍스트 관리자에서
취소 스코프 안에서 yield하는 것은 제너레이터를 사용하여 컨텍스트 관리자를 구현하는 경우에 한해서만 안전할 수 있습니다 [3]. 이 경우 전파되는 모든 예외는 예상한 태스크로 리디렉션됩니다.
또한 flake8-async에 ASYNC101 린터 규칙을 구현했습니다. 이 규칙은 알려진 취소 스코프 안에서 yield하는 것을 경고합니다. 사용자 교육만으로 이러한 문제를 피할 수 있을까요? 안타깝게도 그렇지 않습니다. 사용자 정의 컨텍스트 관리자도 취소 스코프를 감쌀 수 있으며, 그러한 경우를 모두 인식하거나 린트하는 것은 현실적으로 불가능합니다.
‘이 컨텍스트가 유지되는 동안 몇몇 백그라운드 태스크를 실행한다’는 구조적 동시성에서 매우 흔한 패턴이므로, 이 문제는 실제로 자주 발생합니다. 위의 combined_iterators()에서 이를 확인했으며, 웹소켓 프로토콜의 여러 구현에서도 이 버그를 발견했습니다:
async def get_messages(websocket_url):
# The websocket protocol requires background tasks to manage the socket heartbeat
async with open_websocket(websocket_url) as ws: # contains a TaskGroup!
while True:
yield await ws.get_message()
async with open_websocket(websocket_url) as ws:
async for message in get_messages(ws):
...
명세
이러한 문제를 방지하기 위해 다음을 제안합니다:
- 새로운 컨텍스트 관리자
with sys.prevent_yields(reason): ...로, 그 안에서 yield를 시도하면 RuntimeError를 발생시킵니다. [4] 그러면 asyncio와 다운스트림 코드에서 취소 스코프와 유사한 컨텍스트 관리자가 이를 감싸서 자신의 with 블록 안에서 yield하는 것을 방지할 수 있습니다. - 제너레이터를 컨텍스트 관리자로 바꾸는 데코레이터가 한 번의 호출에 걸쳐 yield를 허용할 수 있도록 하는 메커니즘. 이것이 어떤 형태가 되어야 할지는 아직 확실하지 않으며, 유력한 후보는 다음과 같습니다:
- 코드 객체 속성
fn.__code__.co_allow_yields = True, 또는 - 데코레이트된 함수와 데코레이트되지 않은 함수가 공유할 수도 있는 코드 객체를 변경하지 않도록 하는 일종의 호출 플래그(예:
fn.__invoke_with_yields__)
- 코드 객체 속성
구현 - 프레임 추적
새 sys.prevent_yields 컨텍스트 관리자에는 인터프리터 지원이 필요합니다. 각 프레임마다 이 컨텍스트 관리자의 진입과 탈출을 추적합니다.
정확한 표현 방식을 고집하지는 않습니다. 여기서는 스택(명확한 오류 메시지를 지원할 수 있는 방식)으로 설명하지만, 정수 쌍처럼 더 간결한 표현도 가능합니다.
- 새로 생성되었거나 재개된 프레임에 진입할 때, 진입과 탈출에 대한 빈 스택을 초기화합니다.
- 프레임에서 반환할 때, 이 스택들을 부모 프레임의 스택에 병합합니다.
- yield할 때:
entries != [] and not frame.allow_yield_flag이면, yield하는 대신RuntimeError를 발생시킵니다(이 PEP가 제안하는 새 동작)- 그렇지 않으면, 반환할 때와 마찬가지로 스택들을 부모 프레임에 병합합니다.
이는 태스크 사이의 전환이 아니라 태스크 내부에서 프레임이 yield하는 것에 관한 것이므로, 구문상의 yield와 yield from은 영향을 받아야 하지만 await 표현식은 영향을 받지 않아야 합니다.
제너레이터가 아닌 모든 스택 프레임에 대해 이 메타데이터를 스레드당 하나의 스택에 저장하면 오버헤드를 줄일 수 있습니다.
동작 예제
yield하지 않는 예제
이 예제에서는 sys.prevent_yields에서부터 사용자 정의 ContextManager를 거쳐 원래 Frame으로 되돌아가며 스택이 여러 차례 병합되는 과정을 볼 수 있습니다. 간결하게 하기 위해 yield를 막는 이유는 표시하지 않았습니다. 이는 “1 enter” 상태에 포함되어 있습니다.
yield가 없으므로 어떤 오류도 발생시키지 않으며, 진입과 탈출 횟수가 균형을 이루므로 프레임은 추가 추적 없이 평소처럼 반환됩니다.
yield를 시도하는 예제
이 예제에서는 Frame이 sys.prevent_yields 컨텍스트 안에서 yield를 시도합니다. 인터프리터가 이를 감지하고, 프레임을 일시 중단하는 대신 RuntimeError를 발생시킵니다.
yield가 허용되는 예제
이 예제에서는 데코레이터가 Frame을 yield가 허용되는 것으로 표시했습니다. 이 데코레이터는 @contextlib.contextmanager 또는 이와 관련된 데코레이터일 수 있습니다.
Frame이 yield하도록 허용된 경우, 일시 중단하기 전에 진입/탈출 스택이 부모 프레임의 스택에 병합됩니다. Frame이 재개되면 그 스택은 비어 있습니다. 마지막으로 Frame이 종료되면 탈출이 부모 프레임의 스택에 병합되어 스택의 균형이 다시 맞춰집니다.
이를 통해 부모 프레임은 남아 있는 sys.prevent_yields 상태를 올바르게 물려받고, Frame은 안전하게 일시 중단되고 재개될 수 있습니다.
컨텍스트 관리자에서 yield 허용하기
TODO: 이 절은 자리 표시자이며, ``@contextmanager``가 감싼 함수에서 yield를 다시 활성화하는 메커니즘에 대한 결정을 기다리고 있습니다.
@asynccontextmanager가 플래그를 설정하는 방식을 설명하고 코드 예제를 보여 주십시오
@pytest.fixture 같은 서드파티 데코레이터를 보면, 인터프리터가 contextlib만 특별 취급하도록 할 수는 없다는 것을 알 수 있습니다.
sys.prevent_yields를 잘못 사용할 경우의 동작
현명하지는 않지만, 유효한 중첩에 해당하지 않는 순서로 sys.prevent_yields.__enter__와 .__exit__를 호출하거나, 다른 방식으로 유효하지 않은 프레임 상태를 만들 수 있습니다.
sys.prevent_yields.__exit__가 잘못된 상태를 감지할 수 있는 방법은 두 가지입니다. 첫째, yield가 막혀 있지 않다면 상태를 바꾸지 않고 예외만 발생시키면 됩니다. 둘째, 예상하지 못한 항목이 스택 맨 위에 있다면 그 항목을 꺼내고 예외를 발생시킬 것을 제안합니다. 이렇게 하면 호출 순서가 어긋나더라도 스택은 여전히 정리되고, 무언가 잘못되었다는 사실도 분명히 드러납니다.
(그리고 예를 들어 스택 기반이 아닌 정수 기반 표현을 선택한다면 이러한 상태는 올바른 중첩과 전혀 구별되지 않을 수도 있으며, 이 경우에는 이 문제가 아예 생기지 않습니다)
예상되는 용도
표준 라이브러리에서는 asyncio.TaskGroup, asyncio.timeout, asyncio.timeout_at이 sys.prevent_yields를 사용할 수 있습니다. 다운스트림에서는 trio.CancelScope, (pytest-trio, anyio 등의) 비동기 픽스처, 그리고 아마 그 밖의 곳에서도 이를 사용할 것으로 예상합니다.
제너레이터 밖으로 decimal.localcontext가 새어 나가지 않게 하는 것처럼 비동기 정확성과 관련 없는 사용 사례는 이 PEP의 범위 밖으로 봅니다.
제너레이터를 컨텍스트 관리자로 바꾸는 지원은 @contextlib.(async)contextmanager가 사용하며, 필요하다면 (Async)ExitStack에서도 사용합니다.
하위 호환성
sys.prevent_yields 컨텍스트 관리자 추가, @contextlib.(async)contextmanager 변경, 그리고 이에 대응하는 인터프리터 지원은 모두 하위 호환성을 완전히 유지합니다.
asyncio.TaskGroup, asycio.timeout, asyncio.timeout_at 안에서 yield를 막으면 실제로 쓰이는 코드 중 적어도 일부가 깨지게 됩니다. 이런 코드는 (아무리 안전하지 않고 위에서 설명한 문제에 취약하더라도) 충분히 자주 잘 동작해서 프로덕션까지 들어갔을 수 있습니다.
표준 라이브러리 코드에 적합한 폐지 경로에 대해 커뮤니티의 피드백을 구할 것이며, 여기에는 폐지 기간의 권장 길이도 포함됩니다. 첫 제안으로, 3.14에서는 asyncio 디버그 모드에서만 표준 라이브러리 컨텍스트를 일시 중단할 때 DeprecationWarning을 내보내고, 3.15에서는 기본적으로 경고하되 디버그 모드에서는 오류를 발생시키도록 바꾸며, 마지막으로 3.16에서는 항상 오류를 발생시키는 방식을 생각할 수 있습니다.
표준 라이브러리에서 사용하는지와 관계없이, 다운스트림 프레임워크는 이 기능을 바로 도입할 것입니다.
이 버그는 얼마나 널리 퍼져 있습니까?
이에 대한 확실한 수치는 없지만, 실제로 많은 프로젝트가 영향을 받고 있다고 봅니다. 회사에서 같은 주에 취소 범위를 일시 중단한 탓으로 밝혀진 중간 수준의 버그와 치명적인 버그를 하나씩 겪은 뒤로 정적 분석을 사용했으며 어느 정도 성과를 거두었습니다. PyCon에서 Zac이 이야기를 나눈 세 사람은 이 증상을 알아보았고, 자신들도 아마 영향을 받았을 것이라고 결론지었습니다.
TODO: aio-libs 패키지 같은 생태계 프로젝트 전반에 ASYNC101 린트 규칙을 실행하여, 널리 쓰이는 PyPI 패키지에서 얼마나 자주 발생하는지 파악해 보면 어떻겠습니까? 이는 표준 라이브러리 코드의 호환성 파괴/폐지 경로를 정하는 데 도움이 될 것입니다.
교육 방법
초보 프로그래머에게 비동기 제너레이터를 가르치는 경우는 매우 드뭅니다.
중급 및 고급 Python 프로그래머 대부분은 TaskGroup, timeout, @contextmanager의 사용자로서만 이 PEP를 접하게 됩니다. 이 사용자층에게는 명확한 예외 메시지와 문서만으로 충분할 것으로 예상합니다.
- asyncio로 개발하기 페이지에 새 절을 추가하여, 비동기 제너레이터가 “취소 범위” 컨텍스트, 즉
TaskGroup이나timeout컨텍스트 관리자 안에 있을 때는yield할 수 없다는 점을 간단히 설명할 것입니다. 문제를 다시 정리한 부분과 동기 절의 일부가 이 문서의 바탕이 될 것으로 예상합니다. - 취소 범위를 감싸고, 따라서 이제
sys.prevent_yields도 감싸는 각 컨텍스트 관리자의 문서에 “비동기 제너레이터 안에서 사용할 경우, [이 컨텍스트 관리자 안에서yield하는 것은 오류입니다].”와 같은 표준 문장을 위의 설명으로 가는 하이퍼링크와 함께 넣습니다.
취소 스코프 의미를 구현하는 asyncio, Trio, curio 또는 기타 프레임워크의 유지 관리자를 위해, sys.prevent_yields의 문서가 이 PEP의 해결책 및 구현 섹션에서 추려 낸 완전한 설명을 제공하도록 하겠습니다. PEP 초안에 대해 그러한 유지 관리자 대부분에게 피드백을 구할 예정입니다.
기각된 대안
PEP 533, 이터레이터를 위한 결정적 정리
PEP 533은 이터레이터 프로토콜에 __[a]iterclose__를 추가하자고 제안합니다. 이는 본질적으로 각 (async) for 루프를 with [a]closing(ait)로 감싸는 것입니다. 이는 이 제안이 해결하려는 문제, 즉 이터레이터가 보유한 자원을 적시에 결정적으로 정리하는 데에는 유용하겠지만, 이 PEP의 동기가 된 문제들을 완전히 해결하지는 못합니다.
PEP 533이 있더라도 잘못 발동된 취소는 여전히 엉뚱한 태스크로 전달될 것이며, 이터레이터가 닫히기 전에 큰 혼란을 일으킬 수 있습니다. 게다가 이는 TaskGroup의 근본적인 구조적 동시성 문제도 해결하지 못합니다. 이 문제는 TaskGroup을 소유한 프레임을 일시 중단하는 것이 자식 태스크가 부모 프레임 안에 완전히 캡슐화된다는 모델과 양립할 수 없다는 것입니다.
비동기 제너레이터 전면 폐기
2024년 언어 서밋에서 여러 참석자가 대신 비동기 제너레이터를 in toto. 폐기하자고 제안했습니다. 안타깝게도 실제로 흔히 발생하는 사례는 모두 비동기 제너레이터를 사용하지만, Trio 코드는 표준 제너레이터로도 같은 문제를 일으킬 수 있습니다:
# We use Trio for this example, because while `asyncio.timeout()` is async,
# Trio's CancelScope type and timeout context managers are synchronous.
import trio
def abandon_each_iteration_after(max_seconds):
# This is of course broken, but I can imagine someone trying it...
while True:
with trio.move_on_after(max_seconds):
yield
@trio.run
async def main():
for _ in abandon_each_iteration_after(max_seconds=1):
await trio.sleep(3)
해당 버그만 없다면 이 코드는 꽤 관용적으로 보일 것입니다. 하지만 약 1초 후, 다음 반복으로 넘어가는 대신 다음과 같은 예외를 발생시킵니다:
Traceback (most recent call last):
File "demo.py", line 10, in <module>
async def main():
File "trio/_core/_run.py", line 2297, in run
raise runner.main_task_outcome.error
File "demo.py", line 12, in main
await trio.sleep(3)
File "trio/_timeouts.py", line 87, in sleep
await sleep_until(trio.current_time() + seconds)
...
File "trio/_core/_run.py", line 1450, in raise_cancel
raise Cancelled._create()
trio.Cancelled: Cancelled
또한 앞서 언급한 decimal.localcontext처럼 취소 범위가 아니면서도 관련 문제를 보이는 동기 컨텍스트 관리자들이 있습니다. 아래 예제를 고치는 것은 이 PEP의 목표가 아니지만, 이 예제는 with 안의 yield 문제가 비동기 제너레이터에만 국한되지 않음을 보여 줍니다:
import decimal
def why_would_you_do_this():
with decimal.localcontext(decimal.Context(prec=1)):
yield
one = decimal.Decimal(1)
print(one / 3) # 0.3333333333333333333333333333
next(gen := why_would_you_do_this())
print(one / 3) # 0.3
저는 비동기 제너레이터 없이도 비동기 Python에서 좋은 경험을 해 왔지만 [5], 비동기 제너레이터를 언어에서 제거하기보다는 문제를 고치는 편을 선호합니다.
그냥 예외를 올바른 곳으로 전달하면 되지 않습니까?
PEP 568(컨텍스트 변수의 제너레이터 민감성, PEP 550도 참조)을 구현한다면 타임아웃에서 비롯된 예외를 처리할 수 있을 것입니다. 이벤트 루프는 컨텍스트 관리자를 포함한 제너레이터 프레임이 스택에 올라올 때까지, 즉 제너레이터가 재개되거나 종료될 때까지 CancelledError를 발생시키지 않을 수 있습니다.
이는 임의로 오래 걸릴 수 있습니다. (async) for 루프를 빠져나갈 때 적시에 정리되도록 PEP 533을 구현하더라도, 여전히 next/send로 제너레이터를 수동으로 구동할 수 있습니다.
그러나 이는 TaskGroup의 다른 문제를 해결하지 못합니다. 제너레이터의 모델에서는 스택 프레임을 가사 상태로 만들어 두고, 이를 저장하거나 옮기거나 어쩌면 임의의 장소에서 폐기하거나 되살릴 수 있는 비활성 값으로 다룰 수 있습니다. 구조적 동시성의 모델에서는 스택이 트리가 되고, 자식 태스크가 어떤 부모 프레임 안에 캡슐화됩니다. 두 모델은 기본적인 구조적 프로그래밍 모델을 서로 다른, 그리고 안타깝게도 양립할 수 없는 방향으로 확장하고 있습니다.
예를 들어 열린 TaskGroup을 포함한 프레임을 일시 중단하면 모든 자식 태스크도 일시 중단된다고 가정해 봅시다. 이렇게 하면 자식이 캡슐화된 상태로 남는다는 점에서 ‘하향’ 구조적 동시성은 유지될 것입니다. 다만 그 대가로 우리가 동기로 든 두 예제와 수많은 실제 코드가 교착 상태에 빠지게 됩니다. 그러나 여전히 제너레이터를 다른 태스크에서 재개할 수 있으므로, 구조적 동시성의 ‘상향’ 불변 조건을 위반하게 됩니다.
태스크 그룹은 여전히 망가진 채로 둔 채 취소 범위를 처리하려고 이만큼의 장치를 추가할 가치는 없다고 생각합니다.
대안 구현 - 바이트코드 검사
Jelle Zijlstra가 대안을 구상했는데(sketched an alternative), 여기서는 sys.prevent_yields가 호출 명령어 포인터와 다음 컨텍스트 종료 사이에 yield가 없음을 확인할 때까지 호출자들의 바이트코드를 검사합니다. 구문적으로 중첩된 컨텍스트 관리자에 대한 지원은 꽤 쉽게 추가할 수 있을 것으로 예상합니다.
그러나 사용자 정의 컨텍스트 관리자가 sys.prevent_yields를 감쌀 때 이것이 어떻게 동작할지는 아직 명확하지 않습니다. 더 나쁜 점은 이 접근 방식이 __enter__()와 __exit__()의 명시적 호출을 무시한다는 것입니다. 이는 with 문을 사용했는지에 따라 컨텍스트 관리 프로토콜이 달라진다는 뜻입니다.
‘사용할 때만 비용을 치르는’ 성능 비용은 매우 매력적입니다. 그러나 프레임 객체를 검사하는 것은 핵심 제어 흐름 구조에 쓰기에는 감당할 수 없을 만큼 비용이 크며, 최적화 해제로 인해 프로그램 전체가 느려집니다. 반면 더 나은 성능을 위해 인터프리터 지원을 추가하면, 결국 위에서 우리가 선호한 해법과 마찬가지로 사용 여부와 관계없이 비용을 치르는 의미론으로 되돌아가게 됩니다.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.