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

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 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가 여러 가지이므로 그중 어느 것과 통합해야 할지 정하기 어렵다는 것입니다. (CPython이 “태스크 로컬 도메인”을 생성하고 전환하는 표준 API를 제공하고 asyncio, Twisted, tornado 등이 이를 활용한다면 이 문제를 어느 정도 완화할 수 있습니다.)
  • 이 방식을 충분히 빠르게 만들 수 있을지도 전혀 분명하지 않습니다. NumPy는 산술 연산을 할 때마다 빠짐없이 부동 소수점 오류 설정을 확인해야 합니다. 스레드 로컬 저장소에 있는 데이터를 확인하는 작업은 터무니없을 정도로 빠릅니다. 현대 플랫폼들이 이 경우를 최적화하는 데 막대한 자원을 투입했기 때문입니다(예: 이 용도에 CPU 레지스터 하나를 전용으로 할당함). 반면 이벤트 루프의 메서드를 호출하여 네임스페이스에 대한 핸들을 가져온 다음 그 네임스페이스에서 조회하는 작업은 훨씬 느립니다.

    더 중요한 점은, 이벤트 루프를 전혀 사용하지 않는 프로그램에서도 전역 데이터에 접근할 때마다 매번 이 추가 비용을 치러야 한다는 것입니다. 반면 이 PEP의 제안은 실제로 with 블록과 yield 문을 함께 사용하는 코드에만 영향을 미칩니다. 따라서 비용을 치르는 사용자와 이점을 누리는 사용자가 같습니다.

한편, 태스크 컨텍스트와 이벤트 루프를 이처럼 긴밀하게 통합하면 현재 제안의 범위를 벗어나는 다른 기능들도 가능해질 수 있습니다. 예를 들어, 이벤트 루프는 태스크가 call_soon을 호출했을 때 어떤 태스크 네임스페이스가 유효했는지 기록해 둘 수 있습니다. 그리고 나중에 콜백이 실행될 때 같은 태스크 네임스페이스에 접근할 수 있도록 할 수 있습니다. 이것이 유용한지, 스레드 간 호출에서는 제대로 정의될 수나 있는지(태스크 로컬 저장소에 두 스레드가 동시에 접근한다는 것은 무엇을 의미합니까?)는 이벤트 루프 구현자들이 고민할 과제로 남겨 둡니다. 이 제안의 어떤 내용도 그러한 개선을 막지 않습니다. 다만 그러한 기능은 주로 이미 이벤트 루프와 긴밀하게 통합된 상태에 유용할 것으로 보입니다. 요청 ID는 call_soon을 거치더라도 보존되기를 바랄 수 있겠지만, 대부분의 사람들은 다음 코드가:

with warnings.catch_warnings():
    loop.call_soon(f)

f를 경고가 비활성화된 상태로 실행하리라고는 기대하지 않을 것입니다. 그런데 call_soon이 전역 컨텍스트를 일반적으로 보존한다면 바로 그런 결과가 나타납니다. 또한 경고 컨텍스트 관리자의 __exit__가 f보다 먼저 호출된다는 점을 생각하면, 이것이 도대체 어떻게 동작할 수 있을지도 불분명합니다.

따라서 이 PEP는 __suspend__/__resume__와 “태스크 로컬 저장소”가 서로 다른 상황에서 각각 유용한, 상호 보완적인 두 도구라는 입장을 취합니다.

하위 호환성

__suspend__와 __resume__은 선택 사항이며 기본적으로 아무 동작도 하지 않습니다. 따라서 기존의 모든 컨텍스트 관리자는 이전과 정확히 똑같이 계속 동작합니다.

속도 측면에서 이 제안은 with 블록에 진입할 때 추가 오버헤드를 발생시킵니다(이제 추가 메서드가 있는지 확인해야 하는데, CPython에서 속성 조회가 실패하면 AttributeError를 할당해야 하므로 꽤 느립니다). 일시 중단 지점에서도 추가 오버헤드가 생깁니다. with 블록과 일시 중단 지점의 위치는 정적으로 알 수 있으므로, 실제로 with 안에 yield가 있는 경우를 제외한 모든 경우에 컴파일러가 이 오버헤드를 간단히 최적화하여 없앨 수 있습니다. 게다가 __suspend__와 __resume__에 대한 속성 검사는 with 블록을 시작할 때 한 번만 수행합니다. 따라서 이 속성들이 정의되어 있지 않다면 yield마다 드는 오버헤드를 C 수준의 if (frame->needs_suspend_resume_calls) { ... } 하나로 줄일 수 있습니다. 따라서 전체 오버헤드는 무시할 수 있을 정도일 것으로 예상합니다.

PEP 492와의 상호 작용

PEP 492는 새로운 비동기 컨텍스트 관리자를 추가했습니다. 비동기 컨텍스트 관리자는 일반 컨텍스트 관리자와 비슷하지만, 일반 메서드 __enter__와 __exit__ 대신 코루틴 메서드 __aenter__와 __aexit__를 가집니다.

이 패턴을 따른다면, 이 제안이 __asuspend__와 __aresume__ 코루틴 메서드를 추가할 것이라고 예상할 수도 있습니다. 하지만 이는 별로 타당하지 않습니다. 핵심은 우리의 실행 스레드를 양보하여 다른 코드가 실행되도록 허용하기 전에 __suspend__가 호출되어야 한다는 것이기 때문입니다. __asuspend__를 코루틴으로 만들어서 얻는 유일한 결과는 __asuspend__ 자체가 양보할 수 있게 된다는 것뿐입니다. 따라서 __asuspend__ 내부에서 __asuspend__를 재귀적으로 호출해야 하거나, 아니면 포기하고 일시 중단 콜백을 호출하지 않은 채 이러한 양보가 일어나도록 허용해야 합니다. 어느 쪽이든 본래의 목적을 무너뜨립니다.

다만 한 가지 예외가 있습니다. 코루틴 코드에서 가능한 패턴 중 하나는 실제로 실행을 일시 중단하지 않으면서 코루틴 러너와 통신하기 위해 yield를 호출하는 것입니다(즉, 코루틴은 코루틴 러너가 yield된 메시지를 처리한 직후 자신을 즉시 재개할 것임을 알고 있을 수 있습니다). 그 예로 curio.timeout_after 비동기 컨텍스트 관리자가 있습니다. 이 컨텍스트 관리자는 curio 커널에 특별한 set_timeout 메시지를 양보하며, 그러면 커널은 메시지를 보낸 코루틴을 즉시(동기적으로) 재개합니다. 그리고 사용자 관점에서 이 타임아웃 값은 이 PEP의 동기가 된 종류의 전역 변수와 똑같이 동작합니다. 하지만 결정적인 차이가 있습니다. 이러한 종류의 비동기 컨텍스트 관리자는 정의상 코루틴 러너와 긴밀하게 통합되어 있습니다. 따라서 코루틴 러너는 이 PEP가 전혀 없어도 어떤 타임아웃이 어떤 코루틴에 적용되는지 추적하는 책임을 떠맡을 수 있습니다(실제로 curio.timeout_after가 이렇게 동작합니다).

그렇다면 비동기 컨텍스트 관리자를 다루는 합리적인 접근 방식은 두 가지가 남습니다.

  1. 일반 __suspend__와 __resume__ 메서드를 추가합니다.
  2. 비동기 컨텍스트 관리자에 대한 경험이 더 쌓일 때까지 당분간 그대로 둡니다.

둘 다 타당해 보이므로, 게으름 / YAGNI 원칙에 따라 이 PEP는 잠정적으로 선택지 (2)를 유지할 것을 제안합니다.

참고 문헌