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

Python 개선 제안 한국어 번역

PEP 568 – 컨텍스트 변수의 제너레이터 민감성

Author:
Nathaniel J. Smith <njs at pobox.com>
Status:
Deferred
Type:
Standards Track
Created:
04-Jan-2018
Python-Version:
3.8
Post-History:


Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

컨텍스트 변수는 스레드 로컬 저장소와 유사하지만 asyncio Task와 같은 다른 종류의 스레드 유사 컨텍스트에도 대응하도록 일반화된, 동적이고 컨텍스트 로컬인 상태를 추적하는 일반 메커니즘을 제공합니다. PEP 550은 제너레이터 컨텍스트에도 민감한 컨텍스트 로컬 상태 메커니즘을 제안했지만, 이는 상당히 복잡했기 때문에 BDFL은 이를 단순화할 것을 요청했습니다. 그 결과가 PEP 567이며, 3.7에 포함하는 것을 목표로 합니다. 그런 다음 이 PEP는 PEP 567의 메커니즘을 확장하여 제너레이터 컨텍스트 민감성을 추가합니다.

이 PEP는 3.7 기능 동결 전에 충분히 검토할 시간이 없기 때문에 “deferred” 상태로 시작합니다. 지금 당장 유일한 목표는 3.8에 제너레이터 컨텍스트 민감성을 추가하려면 무엇이 필요한지 이해하여, 실수로 이를 배제하게 되는 무언가를 3.7에 제공하는 일을 피하는 것입니다. (의도적으로 이를 배제하는 결정은 3.8까지 미뤄도 됩니다 ;-).)

근거

현재 이 PEP의 목적은 이것이 어떻게 작동할지 이해하는 것뿐이며, 이것이 좋은 생각인지 여부에 대한 논의는 3.7 기능 동결 이후로 미룹니다. 따라서 근거는 TBD입니다.]

상위 수준 요약

단일 Contexts를 보유하는 대신, 이제 스레드 상태는 Contexts의 ChainMap을 보유합니다. ContextVar.getContextVar.setChainMap을 기반으로 합니다. 제너레이터와 비동기 제너레이터에는 각각 연결된 Context가 있으며, 실행되는 동안 이를 ChainMap에 푸시하여 호출자의 컨텍스트 로컬 변경 사항과 격리합니다. 다만 제너레이터에서 호출자로 컨텍스트 변경 사항을 “누출”하는 것이 바람직한 @contextlib.contextmanager와 같은 경우에는 이를 재정의할 수 있습니다.

사양

PEP 567 검토

먼저 PEP 567이 어떻게 작동하는지 검토한 다음, 다음 절에서 차이점을 설명하겠습니다.

관련 PEP 567에서 ContextContextVar 객체를 임의의 값에 매핑하는 Mapping입니다. 여기에서는 의사 코드의 기반 저장소로 dict를 사용한다고 가정하겠습니다. (실제 구현은 dict와 의미상 동등하지만 성능상의 절충점이 다른 HAMT를 사용합니다.):

class Context(collections.abc.Mapping):
    def __init__(self):
        self._data = {}
        self._in_use = False

    def __getitem__(self, key):
        return self._data[key]

    def __iter__(self):
        return iter(self._data)

    def __len__(self):
        return len(self._data)

어느 순간이든 스레드 상태는 현재 Context를 보유합니다(스레드 상태가 생성될 때 빈 Context로 초기화됩니다). Context.run을 사용하여 현재 Context를 일시적으로 전환할 수 있습니다.:

# Context.run
def run(self, fn, *args, **kwargs):
    if self._in_use:
        raise RuntimeError("Context already in use")
    tstate = get_thread_state()
    old_context = tstate.current_context
    tstate.current_context = self
    self._in_use = True
    try:
        return fn(*args, **kwargs)
    finally:
        state.current_context = old_context
        self._in_use = False

copy_context를 호출하여 현재 Context의 얕은 복사본을 가져올 수 있습니다. 이는 새 태스크를 생성할 때 자주 사용되며, 자식 태스크가 부모로부터 컨텍스트를 상속할 수 있게 합니다.:

def copy_context():
    tstate = get_thread_state()
    new_context = Context()
    new_context._data = dict(tstate.current_context)
    return new_context

실제로 최종 사용자가 일반적으로 사용하는 것은 ContextVar 객체이며, 이 객체는 Context를 변경하는 유일한 방법도 제공합니다. 이 객체들은 ContextVar를 이전 값으로 복원하는 데 사용할 수 있는 유틸리티 클래스 Token과 함께 작동합니다.:

class Token:
    MISSING = sentinel_value()

    # Note: constructor is private
    def __init__(self, context, var, old_value):
        self._context = context
        self.var = var
        self.old_value = old_value

    # XX: PEP 567 currently makes this a method on ContextVar, but
    # I'm going to propose it switch to this API because it's simpler.
    def reset(self):
        # XX: should we allow token reuse?
        # XX: should we allow tokens to be used if the saved
        # context is no longer active?
        if self.old_value is self.MISSING:
            del self._context._data[self.context_var]
        else:
            self._context._data[self.context_var] = self.old_value

# XX: the handling of defaults here uses the simplified proposal from
# https://mail.python.org/pipermail/python-dev/2018-January/151596.html
# This can be updated to whatever we settle on, it was just less
# typing this way :-)
class ContextVar:
    def __init__(self, name, *, default=None):
        self.name = name
        self.default = default

    def get(self):
        context = get_thread_state().current_context
        return context.get(self, self.default)

    def set(self, new_value):
        context = get_thread_state().current_context
        token = Token(context, self, context.get(self, Token.MISSING))
        context._data[self] = new_value
        return token

PEP 567에서 이 PEP로의 변경 사항

일반적으로 Context는 동일하게 유지됩니다. 그러나 이제 스레드 상태는 단일 Context 객체를 보유하는 대신 이러한 객체의 스택을 저장합니다. 이 스택은 collections.ChainMap과 똑같이 작동하므로 의사 코드에서 이를 사용하겠습니다. 그러면 Context.run은 다음과 같이 됩니다.:

# Context.run
def run(self, fn, *args, **kwargs):
    if self._in_use:
        raise RuntimeError("Context already in use")
    tstate = get_thread_state()
    old_context_stack = tstate.current_context_stack
    tstate.current_context_stack = ChainMap([self])     # changed
    self._in_use = True
    try:
        return fn(*args, **kwargs)
    finally:
        state.current_context_stack = old_context_stack
        self._in_use = False

일부 변수 이름(예: tstate.current_contexttstate.current_context_stack)이 업데이트된 것을 제외하면, 유일한 변경 사항은 표시된 줄에 있습니다. 이제 이 줄은 컨텍스트를 ChainMap으로 감싼 후 스레드 상태에 저장합니다.

또한 Context.push 메서드를 추가합니다. 이는 전체 스택을 일시적으로 교체하는 대신 기존 스택에 Context를 일시적으로 푸시한다는 점을 제외하면 Context.run과 거의 정확히 같습니다.:

# Context.push
def push(self, fn, *args, **kwargs):
    if self._in_use:
        raise RuntimeError("Context already in use")
    tstate = get_thread_state()
    tstate.current_context_stack.maps.insert(0, self)  # different from run
    self._in_use = True
    try:
        return fn(*args, **kwargs)
    finally:
        tstate.current_context_stack.maps.pop(0)       # different from run
        self._in_use = False

대부분의 경우, push가 직접 사용될 것으로 기대하지 않습니다; 대신, 제너레이터에 의해 암묵적으로 사용될 것입니다. 구체적으로, 모든 제너레이터 객체와 비동기 제너레이터 객체는 새로운 속성 .context를 얻습니다. (비동기) 제너레이터 객체가 생성될 때, 이 속성은 빈 Context로 초기화됩니다(self.context = Context()). 이것은 변경 가능한 속성입니다; 사용자 코드에 의해 변경될 수 있습니다. 하지만 Context 객체나 None이 아닌 것으로 설정하려 하면 오류가 발생합니다.

__next__, send, throw, close를 통해 제너레이터에 진입하거나, __anext__, asend, athrow, aclose 코루틴 중 하나를 호출하여 비동기 제너레이터에 진입할 때마다, 해당 .context 속성이 검사되며, None이 아니면 자동으로 푸시됩니다:

# GeneratorType.__next__
def __next__(self):
    if self.context is not None:
        return self.context.push(self.__real_next__)
    else:
        return self.__real_next__()

사람들이 Context.push를 자주 사용할 것으로 기대하지는 않지만, 이를 공개 API로 만드는 것은 제너레이터가 항상 동등한 의미론을 갖는 명시적 이터레이터 클래스로 다시 작성될 수 있다는 원칙을 유지합니다.

또한, contextlib.(async)contextmanager를 수정하여 해당 (비동기) 제너레이터 객체의 .context 속성을 항상 None으로 설정하도록 합니다:

# contextlib._GeneratorContextManagerBase.__init__
def __init__(self, func, args, kwds):
    self.gen = func(*args, **kwds)
    self.gen.context = None                  # added
    ...

이는 다음과 같은 코드가 계속 예상대로 동작하도록 보장합니다:

@contextmanager
def decimal_precision(prec):
    with decimal.localcontext() as ctx:
        ctx.prec = prec
        yield

with decimal_precision(2):
    ...

여기서의 일반적인 개념은, 기본적으로 모든 제너레이터 객체가 자신만의 로컬 컨텍스트를 갖지만, 사용자가 명시적으로 다른 동작을 원한다면 그렇게 할 수 있다는 것입니다.

그 외에는, 스레드 상태 Context 대신 스레드 상태 ChainMap을 사용하도록 모든 것을 교체한다는 점을 제외하면 대부분 이전과 동일하게 동작합니다. 전체 세부 사항은 다음과 같습니다:

copy_context 함수는 이제 “유효한” 컨텍스트의 평탄화된(flattened) 사본을 반환합니다. (최적화를 위해, 구현체는 이 평탄화 작업을 지연 수행하도록 선택할 수도 있지만, 그렇다 하더라도 사용자에게는 보이지 않게 됩니다.) 위의 이전 구현과 비교하면, 여기서 유일한 변경 사항은 tstate.current_contexttstate.current_context_stack으로 대체되었다는 것입니다:

def copy_context() -> Context:
    tstate = get_thread_state()
    new_context = Context()
    new_context._data = dict(tstate.current_context_stack)
    return new_context

Token은 변경되지 않았으며, ContextVar.get에 대한 변경 사항은 사소합니다:

# ContextVar.get
def get(self):
    context_stack = get_thread_state().current_context_stack
    return context_stack.get(self, self.default)

ContextVar.set은 조금 더 흥미롭습니다: 다른 모든 것처럼 ChainMap 메커니즘을 거치는 대신, 항상 스택의 최상단 Context를 변경하며, 결정적으로! 반환된 Token이 나중에 상태를 복원하도록 설정합니다. 이는 old = var.get(); ...; var.set(old)를 수행했을 때 발생할 수 있는, 스택의 서로 다른 레벨 간에 값이 실수로 “승격(promoting)”되는 것을 방지할 수 있게 합니다:

# ContextVar.set
def set(self, new_value):
    top_context = get_thread_state().current_context_stack.maps[0]
    token = Token(top_context, self, top_context.get(self, Token.MISSING))
    top_context._data[self] = new_value
    return token

마지막으로, 전체 컨텍스트 스택에 대한 인트로스펙션을 허용하기 위해 새로운 함수 contextvars.get_context_stack을 제공합니다.:

def get_context_stack() -> List[Context]:
    return list(get_thread_state().current_context_stack.maps)

이상입니다.

PEP 550과의 비교

우리가 “컨텍스트”와 “컨텍스트 스택”이라고 부르는 것을 두 가지의 서로 다른 구체적 타입(각각 LocalContextExecutionContext)으로 구체화했다는 점이 PEP 550과의 주요 차이점입니다. 이는 차이점이 무엇인지, 그리고 어느 객체를 어디에 사용해야 하는지에 대한 많은 혼란을 초래했습니다. 이 제안은 “단순한 딕셔너리”인 Context만을 구체화함으로써 상황을 단순화하며, “컨텍스트 스택”은 인터프리터 런타임 상태의 이름 없는 기능으로 만듭니다 – 다만 디버깅 등의 목적으로 get_context_stack을 사용하여 여전히 인트로스펙션할 수 있습니다.

구현 노트

ContextVar.set보다 copy_context 호출이 훨씬 더 흔할 것으로 예상되므로, Context는 내부적으로 계속 dict 대신 HAMT 기반 매핑 구조를 사용할 것입니다. 거의 모든 경우에 copy_context는 스택에 Context가 단 하나만 있다는 것을(제너레이터가 새로운 태스크를 생성하는 일은 드물기 때문에) 발견하고 이를 단순히 그대로 재사용할 수 있습니다. 그 밖의 경우에는 HAMT 병합 비용이 저렴하므로 이는 지연 방식으로 수행될 수 있습니다.

실제 ChainMap 객체를 사용하는 대신, 컨텍스트 스택을 적절한 구조를 사용하여 표현할 것입니다 – 가장 적합한 선택지는 아마도, push/pop을 사용할 수 있도록 스택의 “맨 위”를 리스트의 끝으로 두는 단순한 list이거나, 또는 효율적인 push/pop을 위해 스택의 “맨 위”를 리스트의 앞쪽에 두는 침습적 연결 리스트(PyThreadStateContextContext → …)입니다.

ContextVar 내부의 값 캐싱은 PEP 567의 핵심 최적화입니다. 단일 컨텍스트에서 컨텍스트 스택으로 전환하면 이 작업이 조금 더 복잡해지지만, 그리 많이 복잡해지지는 않습니다. 현재는, 스레드 상태의 현재 Context가 변경될 때마다(스레드 전환 시, 그리고 Context.run 진입/종료 시) 캐시를 무효화합니다. 여기서 가장 단순한 접근법은, 스택이 변경될 때마다(스레드 전환 시, Context.run 진입/종료 시, 그리고 Context.push 진입/이탈 시) 캐시를 무효화하는 것입니다. 이로 인한 주된 영향은, 제너레이터를 이터레이션하면 캐시가 무효화된다는 것입니다. 이것이 심각한 문제를 일으킬 가능성은 낮아 보이지만, 만약 그렇게 된다면, Context를 푸시한 다음 팝하면 스레드 상태가 이전 상태로 돌아온다는 것을 인식하는 더 영리한 캐시 키를 사용하여 이를 피할 수 있다고 생각합니다. (아이디어: 특정 스택 구성에 대한 캐시 키를 최상위 Context에 저장합니다.)

이 설계에서는 캐시되지 않은 get이 O(n)이 될 수밖에 없는 것으로 보이며, 여기서 n은 컨텍스트 스택의 크기입니다. 하지만 n은 일반적으로 매우 작을 것입니다 – 이는 대략 중첩된 제너레이터의 개수이므로 보통 n=1이며, n이 예를 들어 5보다 큰 경우는 극히 드뭅니다. 최악의 경우에도 n은 재귀 한도에 의해 제한됩니다. 또한 깊은 제너레이터 재귀의 대부분의 경우, 스택에 있는 Context들 대부분이 비어 있어 조회 시 매우 빠르게 건너뛸 수 있을 것으로 예상할 수 있습니다. 그리고 반복적인 조회에는 캐싱 메커니즘이 작동할 것입니다. 따라서 이로 인해 성능 문제가 발생하는 극단적인 경우를 구성하는 것이 아마 가능하겠지만, 일반적인 코드는 사실상 영향을 받지 않을 것입니다.