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는 “보류(deferred)” 상태로 시작합니다. 3.7 기능 동결 전에 이를 충분히 검토할 시간이 없기 때문입니다. 지금 유일한 목표는 3.8에서 제너레이터 컨텍스트 민감성을 추가하려면 무엇이 필요한지 파악하여, 3.7에서 실수로 그 가능성을 배제하는 무언가를 출시하는 일을 피하는 것입니다. (의도적으로 배제하는 것은 3.8까지 미뤄도 됩니다 ;-).)
근거
[현재 이 PEP의 목적은 이것이 어떻게 동작할지 이해하는 것뿐이며, 이것이 좋은 아이디어인지 여부에 대한 논의는 3.7 기능 동결 이후로 미룹니다. 따라서 근거는 추후 작성합니다(TBD).]
상위 수준 요약
이제 스레드 상태(threadstate)는 단일 Context를 보유하는 대신 Context들의 ChainMap을 보유합니다. ContextVar.get과 ContextVar.set은 ChainMap을 기반으로 동작합니다. 제너레이터와 비동기 제너레이터는 각각 연관된 Context를 가지며, 실행 중에 이를 ChainMap에 푸시하여 자신의 컨텍스트 로컬 변경 사항을 호출자로부터 격리합니다. 다만 제너레이터의 컨텍스트 변경 사항이 호출자에게 “새어 나가는” 것이 바람직한 @contextlib.contextmanager 같은 경우에는 이 동작을 재정의할 수 있습니다.
명세
PEP 567 검토
먼저 PEP 567이 어떻게 동작하는지 검토한 다음, 다음 절에서 차이점을 설명하겠습니다.
PEP 567에서 Context는 ContextVar 객체를 임의의 값에 대응시키는 Mapping입니다. 여기의 의사 코드에서는 기반 저장소로 dict를 사용한다고 가정하겠습니다. (실제 구현은 HAMT를 사용하는데, 이는 의미상으로 dict와 동등하지만 성능상의 트레이드오프가 다릅니다.):
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를 변경하는 유일한 방법이기도 합니다. 이 객체는 유틸리티 클래스 Token과 함께 동작하며, Token은 ContextVar를 이전 값으로 복원하는 데 사용할 수 있습니다:
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_context → tstate.current_context_stack)을 제외하면, 여기서 유일한 변경 사항은 표시된 줄에 있습니다. 이 줄은 이제 컨텍스트를 스레드 상태에 저장하기 전에 ChainMap으로 감쌉니다.
또한 Context.push 메서드를 추가합니다. 이 메서드는 Context.run과 거의 똑같지만, 전체 스택을 일시적으로 교체하는 대신 Context를 기존 스택에 일시적으로 푸시한다는 점이 다릅니다:
# 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 함수는 “실효” 컨텍스트를 평탄화한 복사본을 반환합니다. (최적화로서 구현은 이 평탄화를 지연 수행하도록 선택할 수도 있지만, 그렇게 하더라도 사용자에게는 드러나지 않게 할 것입니다.) 위의 이전 구현과 비교하면, 여기서 유일하게 바뀐 점은 tstate.current_context가 tstate.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)를 했을 때 일어나는 것처럼, 스택의 서로 다른 단계 사이에서 값이 실수로 “승격”되는 일을 피할 수 있습니다:
# 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과의 비교
PEP 550과의 주된 차이점은, 이 PEP가 우리가 “컨텍스트”와 “컨텍스트 스택”이라고 부르는 것을 서로 다른 두 개의 구체적인 타입(각각 LocalContext와 ExecutionContext)으로 구체화했다는 것입니다. 이 때문에 둘의 차이가 무엇인지, 그리고 어느 곳에서 어느 객체를 사용해야 하는지를 두고 많은 혼란이 생겼습니다. 이 제안은 “그저 딕셔너리”인 Context만 구체화하고 “컨텍스트 스택”은 인터프리터 런타임 상태의 이름 없는 기능으로 만들어서 문제를 단순화합니다 – 그렇더라도 디버깅 및 기타 목적을 위해 get_context_stack을 사용하여 여전히 이를 들여다볼 수 있습니다.
구현 참고 사항
copy_context 호출이 ContextVar.set보다 훨씬 더 흔할 것으로 예상하므로, Context는 내부적으로 dict 대신 HAMT 기반 매핑 구조를 계속 사용할 것입니다. 거의 모든 경우에 copy_context는 스택에 Context가 하나뿐임을 알게 되므로(제너레이터가 새 태스크를 생성하는 일은 드물기 때문입니다) 그것을 그대로 재사용하면 됩니다. 그 밖의 경우에도 HAMT는 병합 비용이 적고, 이 병합은 지연 수행할 수 있습니다.
실제 ChainMap 객체를 사용하는 대신 적절한 구조를 사용해 컨텍스트 스택을 표현할 것입니다 – 가장 적절한 선택지는 아마도 push/pop을 사용할 수 있도록 리스트의 끝을 스택의 “최상위”로 삼는 단순한 list이거나, 효율적인 push/pop이 가능하도록 리스트의 시작을 스택의 “최상위”로 삼는 침투형 연결 리스트(PyThreadState → Context → Context → …)일 것입니다.
PEP 567의 핵심적인 최적화는 ContextVar 내부에 값을 캐싱하는 것입니다. 단일 컨텍스트에서 컨텍스트 스택으로 바꾸면 이 작업이 조금 더 복잡해지지만, 그리 많이 복잡해지지는 않습니다. 현재는 스레드 상태의 현재 Context가 바뀔 때마다(스레드 전환 시, 그리고 Context.run에 진입/퇴장할 때) 캐시를 무효화합니다. 여기서 가장 간단한 접근법은 스택이 바뀔 때마다(스레드 전환 시, Context.run에 진입/퇴장할 때, 그리고 Context.push에 진입/퇴장할 때) 캐시를 무효화하는 것입니다. 이로 인한 주된 영향은 제너레이터를 이터레이션하면 캐시가 무효화된다는 것입니다. 이것이 심각한 문제를 일으킬 가능성은 낮아 보이지만, 만약 문제가 된다면 Context를 푸시했다가 팝하면 스레드 상태가 이전 상태로 돌아간다는 점을 인식하는 더 영리한 캐시 키로 피할 수 있다고 생각합니다. (아이디어: 특정 스택 구성에 대한 캐시 키를 최상위 Context에 저장합니다.)
이 설계에서는 캐시되지 않은 get이 O(n)이 되는 것을 피할 수 없어 보입니다. 여기서 n은 컨텍스트 스택의 크기입니다. 하지만 n은 일반적으로 매우 작습니다. n은 대략 중첩된 제너레이터의 개수이므로 보통 n=1이며, n이 이를테면 5보다 큰 경우는 극히 드물 것입니다. 최악의 경우에도 n은 재귀 한도에 의해 제한됩니다. 또한 깊은 제너레이터 재귀가 일어나는 대부분의 경우, 스택에 있는 Context들 대부분이 비어 있을 것이므로 조회 중에 매우 빠르게 건너뛸 수 있을 것으로 예상할 수 있습니다. 그리고 반복되는 조회에는 캐싱 메커니즘이 작동할 것입니다. 따라서 이로 인해 성능 문제가 생기는 극단적인 경우를 만들어 내는 것은 아마 가능하겠지만, 일반적인 코드는 사실상 영향을 받지 않을 것입니다.
Copyright
This document has been placed in the public domain.