PEP 521 – 제너레이터와 코루틴에서 ‘with’ 블록을 통한 전역 컨텍스트 관리
- Author:
- Nathaniel J. Smith <njs at pobox.com>
- Status:
- Withdrawn
- Type:
- Standards Track
- Created:
- 27-Apr-2015
- Python-Version:
- 3.6
- Post-History:
- 29-Apr-2015
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
PEP 철회
이 문서는 PEP 567로 대체하기 위해 철회되었습니다.
초록
가능한 경우 일반적으로 전역 상태를 피하려고 하지만, 그럼에도 전역 상태가 최선의 접근 방식이라고 합의된 상황이 여러 가지 존재합니다. Python에서는 이러한 경우를 처리하는 표준 패턴으로 전역 상태를 전역 또는 스레드 로컬 저장소에 저장한 다음, with 블록을 사용하여 이 전역 상태의 수정을 단일 동적 스코프로 제한합니다. 이 패턴이 사용되는 예로는 표준 라이브러리의 warnings.catch_warnings 및 decimal.localcontext, NumPy의 numpy.errstate(IEEE 754 부동 소수점 표준에서 제공하는 오류 처리 설정을 노출함), 그리고 여러 서버 애플리케이션 프레임워크에서 로깅 컨텍스트 또는 HTTP 요청 컨텍스트를 처리하는 방식이 있습니다.
그러나 현재는 제너레이터나 코루틴을 작성할 때 이러한 전역 상태의 로컬 변경을 편리하게 관리할 방법이 없습니다. 예를 들어 다음 코드는:
def f():
with warnings.catch_warnings():
for x in g():
yield x
g()가 발생시킨 경고를 성공적으로 포착할 수도 있고 그렇지 않을 수도 있으며, 코드의 다른 부분에서 발생한 경고를 의도치 않게 삼킬 수도 있고 그렇지 않을 수도 있습니다. f와 그 피호출자에게만 적용되도록 의도된 컨텍스트 관리자가 결국 호출자의 임의적이고 예측할 수 없는 부분까지 포괄하는 동적 스코프를 갖게 됩니다. 이 문제는 사실상 모든 함수가 코루틴이 되는 비동기 코드를 작성할 때 특히 심각해집니다.
여기서는 실행이 해당 스코프 내에서 일시 중단되거나 재개될 때마다 컨텍스트 관리자에게 알림으로써 컨텍스트 관리자가 그 효과를 적절히 제한할 수 있도록 하여 이 문제를 해결할 것을 제안합니다.
사양
컨텍스트 관리자 프로토콜에 새로운 선택적 메서드 두 개가 추가됩니다: __suspend__ 및 __resume__. 이 메서드가 존재하면 with 블록의 컨텍스트 내에서 프레임의 실행이 일시 중단되거나 재개될 때마다 호출됩니다.
더 형식적으로 다음 코드를 살펴보십시오.:
with EXPR as VAR:
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
현재 이는 다음 코드와 동등합니다(PEP 343에서 복사함).:
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
이 PEP에서는 대신 with 블록 처리를 다음과 같이 변경할 것을 제안합니다.:
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
### --- NEW STUFF ---
if the_block_contains_yield_points: # known statically at compile time
suspend = getattr(type(mgr), "__suspend__", lambda: None)
resume = getattr(type(mgr), "__resume__", lambda: None)
### --- END OF NEW STUFF ---
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
### --- NEW STUFF ---
suspend(mgr)
tmp = yield foo
resume(mgr)
f(tmp)
### --- END OF NEW STUFF ---
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
유사한 일시 중단/재개 호출은 yield from, await, async with 및 async for 구문 내부에 포함된 yield 지점 주위에도 추가됩니다.
중첩 블록
다음 코드가 주어졌다고 하겠습니다.:
def f():
with OUTER:
with INNER:
yield VALUE
그러면 다음 순서로 다음 작업을 수행합니다.:
INNER.__suspend__()
OUTER.__suspend__()
yield VALUE
OUTER.__resume__()
INNER.__resume__()
이는 다음과 같은 리팩터링이 유효함을 보장한다는 점에 유의하십시오.:
def f():
with OUTER:
yield from g()
def g():
with INNER
yield VALUE
마찬가지로 여러 컨텍스트 관리자가 있는 with 문은 오른쪽에서 왼쪽으로 일시 중단되고, 왼쪽에서 오른쪽으로 재개됩니다.
기타 변경 사항
warnings.catch_warnings 및 decimal.localcontext에 적절한 __suspend__ 및 __resume__ 메서드가 추가됩니다.
근거
앞서 그럴듯하지만 잘못된 코드의 예를 추상적으로 제시했습니다.:
def f():
with warnings.catch_warnings():
for x in g():
yield x
현재 Python에서 이를 올바르게 만들려면 대신 다음과 비슷하게 작성해야 합니다.:
def f():
with warnings.catch_warnings():
it = iter(g())
while True:
with warnings.catch_warnings():
try:
x = next(it)
except StopIteration:
break
yield x
반면 이 PEP가 수용되면 원래 코드는 수정 없이 올바르게 동작하게 됩니다. 또는 이것이 설득력 있게 느껴지지 않는다면, 다음은 망가진 코드의 또 다른 예입니다. 이를 수정하려면 훨씬 더 복잡한 작업이 필요하며, 그 방법은 독자가 직접 생각해 볼 문제로 남겨 둡니다.:
async def test_foo_emits_warning():
with warnings.catch_warnings(record=True) as w:
await foo()
assert len(w) == 1
assert "xyzzy" in w[0].message
그리고 이 마지막 예제가 전혀 인위적인 것이 아니라는 점에 주목하십시오. 이는 async/await를 사용하는 코루틴이 경고를 올바르게 발생시키는지 테스트를 작성하는 정확한 방법입니다. async/await를 사용하는 코드에서 warnings.catch_warnings, decimal.localcontext또는 numpy.errstate를 사용하는 거의 모든 경우에도 유사한 문제가 발생합니다. 따라서 여기에는 해결해야 할 실제 문제가 분명히 존재하며, 비동기 코드의 중요성이 커지면서 이 문제는 점점 더 시급해지고 있습니다.
대안적 접근법
제안된 주요 대안은 “스레드 로컬 저장소”와 유사한 일종의 “태스크 로컬 저장소”를 만드는 것입니다 [1]. 본질적으로 이벤트 루프가 자신이 스케줄링하는 각 태스크에 대해 새로운 “태스크 네임스페이스”를 할당하고, 언제든 현재 실행 중인 태스크에 해당하는 네임스페이스를 가져오는 API를 제공한다는 아이디어입니다. 해결해야 할 세부 사항은 많지만 [2], 기본 아이디어는 구현 가능해 보이며, 비동기 애플리케이션 프레임워크의 최상위 수준에서 발생하는 전역 컨텍스트의 유형을 처리하는 특히 자연스러운 방법입니다(예를 들어 웹 프레임워크에서 컨텍스트 객체를 설정하는 경우). 그러나 여기에는 여러 가지 결함도 있습니다.
- 이는
yield를 비동기 이벤트 루프로 되돌리는 코루틴의 전역 상태 관리 문제만 해결합니다. 그러나 실제로 이 문제는 asyncio에만 특수한 것이 아닙니다. 위의 예에서 보인 것처럼 단순한 제너레이터도 정확히 동일한 문제에 부딪힙니다. - 이는 이벤트 루프와 전역 상태를 관리해야 하는 코드 사이에 불필요한 결합을 만듭니다. 물론 비동기 웹 프레임워크는 어차피 어떤 이벤트 루프 API와 상호작용해야 하므로, 이 경우에는 큰 문제가 아닙니다. 그러나
warnings나decimal또는 NumPy가 자신들은 비동기 코드를 전혀 포함하지 않는데도 내부 상태에 액세스하기 위해 비동기 라이브러리의 API를 호출해야 한다는 것은 이상합니다. 더 나쁜 점은 일반적으로 사용되는 이벤트 루프 API가 여러 개이므로 어느 API와 통합할지 선택하는 방법이 명확하지 않다는 것입니다. (CPython이 “태스크 로컬 도메인”을 생성하고 전환하기 위한 표준 API를 제공하여 asyncio, Twisted, tornado 등이 이를 사용할 수 있게 한다면 이 문제를 어느 정도 완화할 수 있습니다.) - 이것을 허용 가능한 속도로 만들 수 있을지는 전혀 명확하지 않습니다. NumPy는 모든 산술 연산마다 부동 소수점 오류 설정을 확인해야 합니다. 스레드 로컬 저장소에서 데이터 하나를 확인하는 것은 터무니없이 빠릅니다. 현대 플랫폼이 이 경우를 최적화하는 데 막대한 자원을 투입했기 때문입니다(예를 들어 이 목적을 위해 CPU 레지스터를 전용으로 할당합니다). 반면 이벤트 루프에서 메서드를 호출하여 네임스페이스에 대한 핸들을 가져온 다음 해당 네임스페이스에서 조회하는 것은 훨씬 느립니다.
더 중요한 점은 이 추가 비용이 전역 데이터에 액세스할 때마다 모든 경우에 발생한다는 것입니다. 이는 이벤트 루프를 전혀 사용하지 않는 프로그램에서도 마찬가지입니다. 이 PEP의 제안은 이와 대조적으로 실제로
with블록과yield문을 함께 사용하는 코드에만 영향을 줍니다. 즉, 비용을 부담하는 사용자는 혜택도 얻는 사용자와 동일합니다.
반면 태스크 컨텍스트와 이벤트 루프를 이렇게 긴밀하게 통합하면 현재 제안의 범위를 벗어나는 다른 기능을 잠재적으로 허용할 수 있습니다. 예를 들어 이벤트 루프는 태스크가 call_soon을 호출했을 때 어떤 태스크 네임스페이스가 적용되고 있었는지 기록하고, 콜백이 실행될 때 동일한 태스크 네임스페이스에 액세스할 수 있도록 할 수 있습니다. 이것이 유용한지, 또는 스레드 간 호출의 경우에 잘 정의되는지(태스크 로컬 저장소에 두 스레드가 동시에 액세스한다는 것은 무엇을 의미합니까?)는 이벤트 루프 구현자가 숙고해야 할 과제로 남겨 둡니다. 이 제안의 어떤 내용도 이러한 개선 사항을 배제하지 않습니다. 하지만 이러한 기능은 이미 이벤트 루프와 긴밀하게 통합된 상태에 주로 유용할 것으로 보입니다. call_soon을 가로질러 요청 ID를 보존하고 싶을 수는 있지만, 대부분의 사람은:
with warnings.catch_warnings():
loop.call_soon(f)
그 결과 f가 경고가 비활성화된 상태로 실행되는 것을 기대하지 않을 것입니다. 이는 call_soon이 일반적으로 전역 컨텍스트를 보존할 경우 발생할 결과입니다. 또한 f보다 먼저 경고 컨텍스트 관리자 __exit__가 호출된다는 점을 고려하면 이것이 어떻게 작동할 수 있는지도 명확하지 않습니다.
따라서 이 PEP는 __suspend__/__resume__와 “태스크 로컬 저장소”가 서로 다른 상황에서 모두 유용한 상호 보완적 도구라는 입장을 취합니다.
하위 호환성
__suspend__와 __resume__는 선택 사항이며 기본값이 아무 작업도 하지 않는 것이므로, 기존의 모든 컨텍스트 관리자는 이전과 정확히 동일하게 계속 작동합니다.
속도 측면에서 이 제안은 with 블록에 진입할 때(이제 추가 메서드를 확인해야 하며, CPython에서 실패한 속성 조회는 AttributeError를 할당해야 하므로 상당히 느립니다)와 일시 중단 지점에서 추가 오버헤드를 발생시킵니다. with 블록과 일시 중단 지점의 위치는 정적으로 알려져 있으므로, 컴파일러는 실제로 with 내부에 yield가 있는 경우를 제외한 모든 경우에 이 오버헤드를 간단히 제거하도록 최적화할 수 있습니다. 또한 with 블록 시작 시 __suspend__와 __resume__에 대한 속성 확인을 한 번만 수행하므로, 이러한 속성이 정의되지 않은 경우 yield마다 발생하는 오버헤드를 단일 C 수준의 if (frame->needs_suspend_resume_calls) { ... }로 최적화할 수 있습니다. 따라서 전체 오버헤드는 무시할 수 있을 정도로 작을 것으로 예상합니다.
PEP 492와의 상호작용
PEP 492는 새로운 비동기 컨텍스트 관리자를 추가했는데, 이는 일반 컨텍스트 관리자와 비슷하지만 일반 메서드인 __enter__와 __exit__ 대신 코루틴 메서드인 __aenter__와 __aexit__를 가집니다.
이 패턴을 따르면, 이 제안이 __asuspend__와 __aresume__ 코루틴 메서드를 추가할 것으로 예상할 수 있습니다. 하지만 이는 그다지 말이 되지 않는데, 핵심은 __suspend__가 우리의 실행 스레드를 양보하고 다른 코드가 실행되도록 허용하기 전에 호출되어야 한다는 것이기 때문입니다. __asuspend__를 코루틴으로 만듦으로써 얻는 유일한 것은 __asuspend__ 자신이 양보할 수 있게 되는 것뿐입니다. 따라서 __asuspend__ 내부에서 __asuspend__를 재귀적으로 호출해야 하거나, 아니면 포기하고 suspend 콜백을 호출하지 않은 채 이러한 양보가 일어나도록 허용해야 합니다. 어느 쪽이든 핵심을 무산시킵니다.
다만 한 가지 예외가 있는데, 코루틴 코드에서 가능한 한 패턴은 코루틴 실행기와 통신하기 위해 yield를 호출하되 실제로는 실행을 정지하지 않는 것입니다(즉, 코루틴은 코루틴 실행기가 yield된 메시지를 처리한 직후 자신을 즉시 재개할 것임을 알고 있을 수 있습니다). 이에 대한 예시는 curio.timeout_after 비동기 컨텍스트 관리자인데, 이는 curio 커널에 특별한 set_timeout 메시지를 양보하고, 그러면 커널이 즉시(동기적으로) 그 메시지를 보낸 코루틴을 재개합니다. 그리고 사용자 관점에서 보면, 이 타임아웃 값은 이 PEP의 동기가 된 그런 종류의 전역 변수처럼 작동합니다. 하지만 결정적인 차이가 있는데, 이런 종류의 비동기 컨텍스트 관리자는 정의상 코루틴 실행기와 긴밀하게 통합되어 있다는 것입니다. 따라서 코루틴 실행기는 이 PEP가 전혀 필요 없이 어떤 타임아웃이 어떤 코루틴에 적용되는지 추적하는 책임을 떠맡을 수 있습니다(그리고 실제로 curio.timeout_after는 이런 방식으로 동작합니다).
이렇게 되면 비동기 컨텍스트 관리자를 처리하는 합리적인 두 가지 접근법이 남습니다.
- 일반
__suspend__와__resume__메서드를 추가합니다. - 경험이 더 쌓일 때까지 당분간 비동기 컨텍스트 관리자는 그대로 둡니다.
둘 다 그럴듯해 보이므로, 게으름 / YAGNI를 이유로 이 PEP는 잠정적으로 옵션 (2)를 고수할 것을 제안합니다.
참고 문헌
Copyright
This document has been placed in the public domain.