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
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 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)
안타깝게도 이 버전에는 버그가 있습니다. 제너레이터가 값을 생성한 후 재개되기 전에 타임아웃이 만료될 수 있습니다! 이 경우 외부 태스크에서 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
백그라운드 태스크 누출(취소 및 예외 처리를 중단함)
타임아웃만 취소 스코프를 감싸는 인터페이스인 것은 아닙니다. 백그라운드 작업자 태스크가 필요하다면 값을 생성하기 전에 TaskGroup을 간단히 닫을 수 없습니다.
예로 팬인 제너레이터를 살펴보겠습니다. 이 제너레이터를 사용하여 여러 “센서”의 피드를 병합할 것입니다. 또한 모의 센서에 작은 버퍼를 설정하여, 제어 흐름이 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()])을 생성하고 발생시킬 기회를 얻지 못했습니다.
이를 수정하려면 비동기 제너레이터를 비동기 컨텍스트 관리자로 변환해야 합니다. 이 관리자는 비동기 이터러블을 생성하며, 여기서는 큐를 감싸는 제너레이터입니다. 향후에는 perhaps the queue itself자체일 수도 있습니다.
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:
...
사용자 정의 컨텍스트 관리자에서
취소 스코프 안에서 값을 생성하는 것은 안전할 수 있지만, 제너레이터를 사용하여 컨텍스트 관리자를 구현하는 경우에만 그렇습니다 [3]. 이 경우 전파되는 예외는 예상된 태스크로 리디렉션됩니다.
또한 flake8-async에 ASYNC101 린터 규칙을 구현했으며, 알려진 취소 스코프에서 값을 생성하지 않도록 경고합니다. 사용자 교육만으로 이러한 문제를 방지하기에 충분할 수 있을까요? 안타깝게도 그렇지 않습니다. 사용자 정의 컨텍스트 관리자도 취소 스코프를 감쌀 수 있으며, 이러한 모든 경우를 인식하거나 린트하는 것은 현실적으로 불가능합니다.
이는 실제로 정기적으로 발생합니다. ‘이 컨텍스트가 유지되는 동안 일부 백그라운드 태스크 실행’은 구조적 동시성에서 매우 흔한 패턴이기 때문입니다. 위의 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하는 것을 방지할 수 있습니다. - 제너레이터-컨텍스트-관리자 데코레이터가 한 번의 호출을 가로질러 값을 생성할 수 있도록 하는 메커니즘을 도입합니다. 이것이 어떤 형태여야 하는지는 아직 확실하지 않으며, 유력한 후보는 다음과 같습니다.
- 코드 객체 속성
fn.__code__.co_allow_yields = True또는 - 예를 들어
fn.__invoke_with_yields__와 같은 호출 플래그를 사용하여 데코레이터가 적용된 함수와 적용되지 않은 함수 사이에서 공유될 수 있는 코드 객체를 변경하지 않는 방법
- 코드 객체 속성
구현 - 프레임 추적
새로운 sys.prevent_yields 컨텍스트 관리자를 지원하려면 인터프리터 지원이 필요합니다. 각 프레임에 대해 이 컨텍스트 관리자의 진입과 종료를 추적합니다.
정확한 표현 방식에 특별히 집착하지는 않습니다. 명확한 오류 메시지를 지원할 수 있는 스택으로 이를 논의하겠지만, 정수 쌍과 같은 더 압축적인 표현 방식도 사용할 수 있습니다.
- 새로 생성되거나 재개된 프레임에 진입할 때 진입 및 종료를 위한 빈 스택을 초기화합니다.
- 프레임에서 반환할 때 이 스택을 부모 프레임의 스택에 병합합니다.
- yield할 때:
- entries != [] and not frame.allow_yield_flag이면 yield하는 대신
RuntimeError를 발생시킵니다(이 PEP가 제안하는 새로운 동작). - 그렇지 않으면 반환할 때와 마찬가지로 스택을 부모 프레임에 병합합니다.
- entries != [] and not frame.allow_yield_flag이면 yield하는 대신
이는 태스크 간 전환이 아니라 태스크 내부에서 프레임을 yield하는 것에 관한 것이므로, yield 및 yield from 구문은 영향을 받아야 하지만 await 표현식은 영향을 받지 않아야 합니다.
제너레이터가 아닌 모든 스택 프레임에 대해 스레드당 단일 스택에 이 메타데이터를 저장하여 오버헤드를 줄일 수 있습니다.
작성 예제
yield하지 않는 예제
이 예제에서는 sys.prevent_yields에서 빠져나와 사용자 정의 ContextManager를 거쳐 원래 프레임으로 돌아가는 동안 스택이 여러 차례 병합되는 것을 볼 수 있습니다. 간결성을 위해 yield를 방지하는 이유는 표시하지 않았으며, 이는 “1 enter” 상태의 일부입니다.
yield가 없으면 오류가 발생하지 않으며, 진입 횟수와 종료 횟수의 균형이 맞으므로 추가 추적 없이 평소처럼 프레임이 반환됩니다.
yield 시도 예제
이 예제에서 프레임은 sys.prevent_yields 컨텍스트 안에서 yield를 시도합니다. 인터프리터가 이를 감지하고 프레임을 일시 중단하는 대신 RuntimeError를 발생시킵니다.
yield가 허용되는 예제
이 예제에서는 데코레이터가 프레임을 yield 허용으로 표시했습니다. 이는 @contextlib.contextmanager 또는 관련 데코레이터일 수 있습니다.
프레임에서 yield가 허용되면 일시 중단하기 전에 진입/종료 스택을 부모 프레임의 스택에 병합합니다. 프레임이 재개될 때 해당 프레임의 스택은 비어 있습니다. 마지막으로 프레임이 종료되면 종료 항목을 부모 프레임의 스택에 병합하여 균형을 다시 맞춥니다.
이를 통해 프레임이 안전하게 일시 중단되었다가 재개되는 동시에 부모 프레임이 남아 있는 sys.prevent_yields 상태를 올바르게 상속합니다.
컨텍스트 관리자의 yield 허용
TODO: 이 섹션은 ``@contextmanager``가 래핑된 함수에서 yield를 다시 활성화하는 메커니즘에 대한 결정이 내려질 때까지의 자리 표시자입니다.
@asynccontextmanager가 플래그를 설정하는 방법을 설명하고 코드 샘플을 보여 주십시오.
@pytest.fixture와 같은 서드파티 데코레이터는 인터프리터가 contextlib를 특별히 처리하도록 할 수만은 없다는 점을 보여 줍니다.
sys.prevent_yields를 잘못 사용할 경우의 동작
바람직하지는 않지만, sys.prevent_yields.__enter__ 및 .__exit__를 유효한 중첩에 해당하지 않는 순서로 호출하거나 다른 방식으로 잘못된 프레임 상태를 만들 수 있습니다.
sys.prevent_yields.__exit__가 잘못된 상태를 감지하는 방법은 두 가지입니다. 첫째, yield가 방지되지 않는 경우 상태를 변경하지 않고 단순히 예외를 발생시킬 수 있습니다. 둘째, 스택의 맨 위에 예상하지 못한 항목이 있으면 해당 항목을 꺼내고 예외를 발생시키는 방법을 제안합니다. 이렇게 하면 순서가 뒤바뀐 호출도 스택을 계속 비우면서, 동시에 무언가 잘못되었다는 점을 분명히 알릴 수 있습니다.
(또한 스택 기반 표현 대신 예를 들어 정수 표현을 선택하면, 이러한 상태가 올바른 중첩과 전혀 구별되지 않을 수 있으며, 이 경우에는 해당 문제가 발생하지 않습니다)
예상되는 사용 사례
표준 라이브러리에서 sys.prevent_yields는 asyncio.TaskGroup, asyncio.timeout, asyncio.timeout_at에서 사용할 수 있습니다. 하위 수준에서는 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에서는 즉시 오류를 발생시키도록 할 수 있습니다.
표준 라이브러리의 사용 여부와 관계없이 하위 프레임워크는 이 기능을 즉시 도입할 것입니다.
이 버그는 얼마나 널리 퍼져 있습니까?
확실한 수치는 없지만, 실제 환경에서 많은 프로젝트가 영향을 받고 있다고 생각합니다. 직장에서 같은 주에 취소 범위를 일시 중단한 것과 관련된 중간 수준의 버그와 치명적인 버그를 겪은 이후, 어느 정도 성과를 거두며 used static analysis를 사용해 왔습니다. PyCon에서 Zac이 이야기를 나눈 세 사람은 증상을 알아차렸고 자신들도 영향을 받았을 가능성이 높다고 결론 내렸습니다.
TODO: 생태계 프로젝트, 예를 들어 aio-libs 패키지 전반에서 ASYNC101 린트 규칙을 실행하여 널리 사용되는 PyPI 패키지에서의 발생 빈도를 파악할 것인가? 이는 표준 라이브러리 코드의 호환성 중단 및 지원 중단 경로를 결정하는 데 도움이 될 것입니다.
이 내용을 가르치는 방법
비동기 제너레이터는 초보 프로그래머에게 가르치는 경우가 매우 드뭅니다.
대부분의 중급 및 고급 Python 프로그래머는 TaskGroup, timeout, @contextmanager의 사용자로서만 이 PEP와 상호작용하게 됩니다. 이러한 사용자에게는 명확한 예외 메시지와 문서만으로 충분할 것으로 예상합니다.
- developing with asyncio 페이지에 새로운 섹션을 추가하여, 비동기 제너레이터는 “취소 범위” 컨텍스트, 즉
TaskGroup또는timeout컨텍스트 관리자 내부에서yield할 수 없다는 점을 간략히 설명합니다. 문제 재진술과 동기 부여 섹션의 일부가 이러한 문서의 토대가 될 것으로 예상합니다. - 취소 범위를 감싸며 따라서 이제
sys.prevent_yields를 사용하는 각 컨텍스트 관리자의 문서에 “비동기 제너레이터 안에서 사용하면 [이 컨텍스트 관리자 내부에서yield하는 것은 오류입니다].”와 같은 표준 문장을 위의 설명에 대한 하이퍼링크와 함께 포함합니다.
asyncio, Trio, curio 또는 기타 프레임워크에서 취소 범위 의미 체계를 구현하는 유지 관리자들을 위해, sys.prevent_yields의 문서에 이 PEP의 해결책 및 구현 섹션에서 정리한 전체 설명을 제공합니다. 대부분의 이러한 유지 관리자에게 초안 PEP에 대한 의견을 구할 예정입니다.
기각된 대안
PEP 533, 이터레이터의 결정론적 정리
PEP 533은 이터레이터 프로토콜에 __[a]iterclose__를 추가하여 각 (비동기) for 루프를 사실상 with [a]closing(ait)로 감싸는 방식을 제안합니다. 이는 이터레이터가 보유한 리소스를 시의적절하고 결정론적으로 정리하는 데 유용하지만, 이 PEP가 해결하려는 문제를 완전히 해결하지는 못하며 이 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를 발생시키지 않을 수 있으며, 이는 제너레이터가 재개될 때나 종료될 때입니다.
이 시간은 임의로 길어질 수 있습니다. 종료 시 (비동기) 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.