PEP 555 – 컨텍스트 지역 변수(contextvars)
- Author:
- Koos Zevenhoven
- Status:
- Withdrawn
- Type:
- Standards Track
- Created:
- 06-Sep-2017
- Python-Version:
- 3.7
- Post-History:
- 06-Sep-2017
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
때로는 특수한 경우에, 호출 체인의 각 함수에 정보를 인자로 명시적으로 전달하지 않고도 코드가 함수 호출 체인을 따라 피호출자에게 정보를 전달할 수 있기를 바랄 때가 있습니다. 이 제안은 특정 컨텍스트 변수에 주어진 값이 할당된 컨텍스트로 코드가 명시적으로 전환해 들어가고 나올 수 있게 하는 구조를 설명합니다. 이는 전통적인 단일 스레드(또는 스레드에 안전하지 않은) 코드에서 전역 변수 같은 것을 사용하던 일부 방식과, 전통적인 동시성에 안전하지 않은 코드(단일 스레드 또는 다중 스레드)에서 스레드 로컬 저장소를 사용하던 일부 방식을 대체하는 현대적인 대안입니다. 특히 제안된 메커니즘은 비동기로 실행되는 코루틴 같은 더 현대적인 동시 실행 메커니즘과 함께 사용할 수도 있으며, 이때 동시에 실행되는 호출 체인들이 서로의 컨텍스트를 간섭하지 않습니다.
“호출 체인”은 일반 함수, await된 코루틴, 또는 제너레이터로 구성될 수 있습니다. 컨텍스트 변수 범위의 의미는 모든 경우에 동일하므로, 컨텍스트 변수의 의미에 영향을 주지 않고 코드를 자유롭게 서브루틴 (여기서는 함수, 하위 제너레이터 또는 하위 코루틴을 가리킵니다)으로 리팩터링할 수 있습니다. 구현 측면에서 이 제안은 단순성을 추구하며, CPython 인터프리터와 다른 파이썬 인터프리터에 대한 변경을 최소화하는 것을 목표로 합니다.
근거
현대적인 Python 호출 체인(또는 호출 트리)을 생각해 봅시다. 이 제안에서 호출 체인은 일반 함수 호출이나 await 또는 yield from을 사용하는 표현식을 어떤 방식으로든 조합하여 서브루틴을 연쇄적으로(중첩하여) 실행하는 것을 가리킵니다. 경우에 따라서는 필요한 정보를 인자로 호출 체인을 따라 전달하면 필요한 함수 시그니처가 상당히 복잡해지거나, 실제로는 아예 불가능할 수도 있습니다. 이러한 경우에는 이 정보를 저장할 다른 장소를 찾게 됩니다. 몇 가지 역사적인 예를 살펴봅시다.
가장 단순한 방법은 값을 전역 변수나 그와 비슷한 곳에 할당하여 호출 체인 아래쪽의 코드가 접근할 수 있게 하는 것입니다. 그러나 이렇게 하면 코드는 곧바로 스레드에 안전하지 않게 됩니다. 스레드가 여러 개이면 모든 스레드가 같은 전역 변수에 할당하므로, 호출 체인의 어느 지점에서든 다른 스레드가 간섭할 수 있기 때문입니다. 머지않아 누군가는 같은 코드를 병렬 스레드에서 실행해야 할 이유를 찾게 될 것입니다.
이보다 조금 덜 단순한 방법은 정보를 스레드별 정보로서 스레드 로컬 저장소에 저장하는 것입니다. 이 경우 각 스레드는 다른 스레드가 간섭할 수 없는 자기만의 변수 “사본”을 가집니다. 이상적이지는 않지만, 많은 경우에 이것이 최선의 해결책이었습니다. 그러나 제너레이터와 코루틴 덕분에 호출 체인의 실행이 일시 중단되었다가 재개될 수 있으므로, 다른 컨텍스트의 코드가 동시에 실행될 수 있습니다. 따라서 스레드 지역 저장소를 사용하는 것은 동시성에 안전하지 않습니다. 다른 컨텍스트에 있는 다른 호출 체인이 스레드 지역 변수에 간섭할 수 있기 때문입니다.
위의 두 가지 역사적 접근 방식에서, 저장된 정보는 문제를 일으키지 않는 한도에서 가능한 가장 넓은 범위를 가진다는 점에 유의하십시오. 같은 방향으로 세 번째 해결책을 찾으려면, 먼저 비동기 실행과 동시성을 위한 “스레드”에 해당하는 것을 정의해야 합니다. 이는 실행 순서의 모호함 없이 순차적으로 실행됨이 보장되는 가장 큰 단위의 코드와 중첩 호출이라고 볼 수 있습니다. 이를 동시성 로컬 저장소 또는 태스크 로컬 저장소라고 부를 수 있습니다. 이러한 의미의 “태스크”에서는 한 태스크 안에 있는 코드의 실행 순서에 모호함이 없습니다. (이 태스크 개념은 asyncio의 Task와 거의 같지만, 정확히 같지는 않습니다.) 이러한 동시성 로컬에서는 다른 코드 경로가 배후에서 값을 간섭하지 않은 채로 호출 체인을 따라 피호출자에게 정보를 전달할 수 있습니다.
위의 접근 방식들의 공통점은 실제로 넓지만 딱 필요한 만큼만 좁은 범위를 가진 변수를 사용한다는 것입니다. 스레드 로컬은 스레드 범위의 전역 변수라고 부를 수도 있습니다—단일 스레드 코드에서는 실제로 진정한 전역 변수입니다. 그리고 태스크는 매우 클 수 있으므로, 태스크 로컬은 태스크 범위의 전역 변수라고 부를 수 있습니다.
여기서 문제는 전역 변수, 스레드 로컬, 태스크 로컬 중 어느 것도 실행 컨텍스트의 정보를 호출 체인 아래로 전달하는 이러한 목적으로 사용하도록 만들어진 것이 아니라는 점입니다. 변수의 스코프는 가능한 한 가장 넓은 스코프가 아니라, 프로그래머(보통 라이브러리 프로그래머)가 원하는 스코프를 갖도록 제어해야 하며, 그보다 넓어서는 안 됩니다. 다시 말해, 태스크 지역 변수를 사용하여 원하는 의미를 흉내 낼 수는 있더라도, 태스크 지역 변수(그리고 전역 변수와 스레드 지역 변수)는 이 제안이 가능하게 하려는 컨텍스트에 묶인 정보 전달과는 아무런 관련이 없습니다. 그러므로 이 제안에서는 이어서 컨텍스트 지역 변수(또는 컨텍스트 변수, contextvars)의 의미와 구현의 개요를 설명합니다. 실제로 이 PEP의 부수 효과로, 비동기 프레임워크는 제안된 기능을 사용하여 태스크 지역 변수를 구현할 수 있습니다.
제안
제안된 의미론은 Python에 이미 있는 어떤 기능을 직접 확장한 것이 아니므로, 이 제안은 먼저 꽤 높은 수준에서 의미론과 API의 관점으로 설명합니다. 특히 설명에서는 Python with 문을 많이 사용하는데, 제안된 의미론과 잘 맞기 때문입니다. 하지만 그 밑에 있는 __enter__와 __exit__ 메서드는 속도에 최적화된 저수준 (C) API의 함수에 대응합니다. 이 문서를 명확하게 하기 위해, 의미론을 정의할 때 저수준 함수의 이름은 명시하지 않습니다. 의미론과 고수준 API를 설명한 다음, 더 낮은 수준으로 내려가 구현을 설명합니다.
의미론과 고수준 API
핵심 개념
컨텍스트 지역 변수는 contextvars.Var의 단일 인스턴스로 표현되며, 이를 cvar라고 하겠습니다. cvar 객체에 접근할 수 있는 코드라면 어떤 코드든 현재 컨텍스트에 대한 그 값을 요청할 수 있습니다. 고수준 API에서는 이 값을 cvar.value 프로퍼티로 얻습니다:
cvar = contextvars.Var(default="the default value",
description="example context variable")
assert cvar.value == "the default value" # default still applies
# In code examples, all ``assert`` statements should
# succeed according to the proposed semantics.
이 컨텍스트에서는 cvar에 대한 할당이 적용된 적이 없으므로 cvar.value는 기본값을 돌려줍니다. contextvars에 새 값을 할당하는 일은 스코프를 매우 철저하게 의식하는 방식으로 이루어집니다:
with cvar.assign(new_value):
assert cvar.value is new_value
# Any code here, or down the call chain from here, sees:
# cvar.value is new_value
# unless another value has been assigned in a
# nested context
assert cvar.value is new_value
# the assignment of ``cvar`` to ``new_value`` is no longer visible
assert cvar.value == "the default value"
여기서 cvar.assign(value)는 다른 객체, 즉 contextvars.Assignment(cvar, new_value)를 반환합니다. 여기서 핵심은 컨텍스트 변수 할당을 적용하는 일(Assignment.__enter__)이 할당 해제(Assignment.__exit__)와 짝을 이룬다는 점입니다. 이 연산들이 할당된 값의 스코프 경계를 정합니다.
같은 컨텍스트 변수에 대한 할당을 중첩하면 더 좁은 컨텍스트에서 바깥쪽 할당을 덮어쓸 수 있습니다:
assert cvar.value == "the default value"
with cvar.assign("outer"):
assert cvar.value == "outer"
with cvar.assign("inner"):
assert cvar.value == "inner"
assert cvar.value == "outer"
assert cvar.value == "the default value"
또한 여러 변수를 서로 영향을 주지 않으면서 중첩된 방식으로 할당할 수 있습니다:
cvar1 = contextvars.Var()
cvar2 = contextvars.Var()
assert cvar1.value is None # default is None by default
assert cvar2.value is None
with cvar1.assign(value1):
assert cvar1.value is value1
assert cvar2.value is None
with cvar2.assign(value2):
assert cvar1.value is value1
assert cvar2.value is value2
assert cvar1.value is value1
assert cvar2.value is None
assert cvar1.value is None
assert cvar2.value is None
또는 더 편리한 Python 문법으로 할 수도 있습니다:
with cvar1.assign(value1), cvar2.assign(value2):
assert cvar1.value is value1
assert cvar2.value is value2
다른 컨텍스트, 즉 다른 스레드나 그 밖에 동시에 실행되는 태스크 또는 코드 경로에서는 컨텍스트 변수가 완전히 다른 상태를 가질 수 있습니다. 따라서 프로그래머는 지금 다루는 컨텍스트만 신경 쓰면 됩니다.
서브루틴으로 리팩터링하기
contextvars를 사용하는 코드는 의미론에 영향을 주지 않고 서브루틴으로 리팩터링할 수 있습니다. 예를 들면 다음과 같습니다:
assi = cvar.assign(new_value)
def apply():
assi.__enter__()
assert cvar.value == "the default value"
apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"
또는 await 표현식을 사용하는 비동기 컨텍스트에서도 비슷합니다. 이제 서브루틴은 코루틴이 될 수 있습니다:
assi = cvar.assign(new_value)
async def apply():
assi.__enter__()
assert cvar.value == "the default value"
await apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"
또는 서브루틴이 제너레이터일 때도 마찬가지입니다:
def apply():
yield
assi.__enter__()
이는 yield from apply()를 사용하거나 next 또는 .send를 호출하여 호출됩니다. 이에 대해서는 이후 절에서 더 자세히 다룹니다.
제너레이터와 제너레이터 기반 코루틴의 의미론
제너레이터, 코루틴, 비동기 제너레이터는 일반 함수와 거의 같은 방식으로 서브루틴 역할을 합니다. 그러나 이들은 yield 표현식에 의해 일시 중단될 수 있다는 가능성을 추가로 가집니다. 제너레이터 내부에서 진입한 할당 컨텍스트는 일반적으로 yield를 거쳐도 유지됩니다:
def genfunc():
with cvar.assign(new_value):
assert cvar.value is new_value
yield
assert cvar.value is new_value
g = genfunc()
next(g)
assert cvar.value == "the default value"
with cvar.assign(another_value):
next(g)
그러나 제너레이터에서 보이는 외부 컨텍스트는 yield를 거치는 동안 상태가 바뀔 수 있습니다:
def genfunc():
assert cvar.value is value2
yield
assert cvar.value is value1
yield
with cvar.assign(value3):
assert cvar.value is value3
with cvar.assign(value1):
g = genfunc()
with cvar.assign(value2):
next(g)
next(g)
next(g)
assert cvar.value is value1
async def ... yield ...로 정의된 비동기 제너레이터에도 비슷한 의미론이 적용됩니다 ).
기본적으로 제너레이터 내부에서 할당된 값은 yield를 통해 제너레이터를 구동하는 코드로 새어 나가지 않습니다. 그러나 제너레이터 내부에서 진입한 뒤 열어 둔 할당 컨텍스트는 제너레이터가 StopIteration 또는 다른 예외로 종료된 후에는 제너레이터 외부에서 실제로 보이게 됩니다:
assi = cvar.assign(new_value)
def genfunc():
yield
assi.__enter__():
yield
g = genfunc()
assert cvar.value == "the default value"
next(g)
assert cvar.value == "the default value"
next(g) # assi.__enter__() is called here
assert cvar.value == "the default value"
next(g)
assert cvar.value is new_value
assi.__exit__()
프레임워크 작성자를 위한 특수 기능
asyncio나 서드파티 라이브러리 같은 프레임워크는 Python 인터프리터가 결정하지 않는 경우에 원하는 의미론을 얻기 위해 contextvars의 추가 기능을 사용할 수 있습니다. 이 절에서 설명하는 의미론 중 일부는 이후 내부 구현을 설명하는 데에도 사용됩니다.
yield를 통한 누출
contextvars.leaking_yields 데코레이터를 사용하면 yield 표현식을 통해 컨텍스트를 제너레이터를 구동하는 외부 컨텍스트로 누출하도록 선택할 수 있습니다:
@contextvars.leaking_yields
def genfunc():
assert cvar.value == "outer"
with cvar.assign("inner"):
yield
assert cvar.value == "inner"
assert cvar.value == "outer"
g = genfunc():
with cvar.assign("outer"):
assert cvar.value == "outer"
next(g)
assert cvar.value == "inner"
next(g)
assert cvar.value == "outer"
contextvar 할당 포착하기
contextvars.capture()를 사용하면 코드 블록이 진입하는 할당 컨텍스트를 포착할 수 있습니다. 그런 다음 코드 블록이 적용한 변경 사항을 되돌렸다가 나중에 다시 적용할 수 있으며, 다른 컨텍스트에서도 그렇게 할 수 있습니다:
assert cvar1.value is None # default
assert cvar2.value is None # default
assi1 = cvar1.assign(value1)
assi2 = cvar1.assign(value2)
with contextvars.capture() as delta:
assi1.__enter__()
with cvar2.assign("not captured"):
assert cvar2.value is "not captured"
assi2.__enter__()
assert cvar1.value is value2
delta.revert()
assert cvar1.value is None
assert cvar2.value is None
...
with cvar1.assign(1), cvar2.assign(2):
delta.reapply()
assert cvar1.value is value2
assert cvar2.value == 2
그러나 “델타”의 최종 내용에 할당 해제가 포함되어 있으면 이를 다시 적용하지 못할 수도 있습니다(구현 및 미해결 문제도 참조하십시오).
컨텍스트 상태의 스냅숏 얻기
contextvars.get_local_state() 함수는 이 함수가 호출된 컨텍스트에서 모든 컨텍스트 지역 변수에 적용된 할당을 나타내는 객체를 반환합니다. 이는 contextvars.capture()를 사용하여 실행 시작부터의 모든 컨텍스트 변경 사항을 포착하는 것과 동등하다고 볼 수 있습니다. 반환된 객체는 위와 같이 .revert() 및 reapply() 메서드를 지원합니다.
깨끗한 상태에서 코드 실행하기
위의 기본 요소를 사용하여 적용된 모든 컨텍스트 변경 사항을 되돌릴 수도 있지만, 깨끗한 컨텍스트에서 코드 블록을 실행하는 더 편리한 방법이 제공됩니다:
with context_vars.clean_context():
# here, all context vars start off with their default values
# here, the state is back to what it was before the with block.
구현
이 절에서는 설명한 의미론을 어떻게 구현할 수 있는지를 부분에 따라 다른 수준으로 자세히 설명합니다. 현재는 단순함을 목표로 하면서도 충분한 기능을 갖춘 구현을 설명합니다. 더 자세한 내용은 나중에 추가될 예정입니다.
또는 다소 더 복잡한 구현을 택하면 약간의 추가 기능을 얻을 수 있지만, 성능 오버헤드가 다소 생기고 구현에 더 많은 코드가 필요합니다.
자료 구조와 핵심 개념의 구현
Python 인터프리터의 각 스레드는 contextvars.Assignment 객체의 스택을 각자 유지하며, 각 객체는 연결 리스트처럼 이전(바깥쪽) 할당을 가리키는 포인터를 가집니다. 그러면 로컬 상태(contextvars.get_local_state()가 반환하는 것이기도 합니다)는 스택의 맨 위에 대한 참조와 스택의 맨 아래에 대한 포인터/약한 참조로 구성됩니다. 이를 통해 스택을 효율적으로 조작할 수 있습니다. contextvars.capture()가 생성하는 객체도 이와 비슷하지만 스택의 일부만 참조하며, 그 맨 아래 참조는 캡처 블록이 시작될 당시의 스택 맨 위를 가리킵니다.
이제 스택은 할당의 __enter__ 및 __exit__ 메서드에 따라 변화합니다. 예를 들면 다음과 같습니다:
cvar1 = contextvars.Var()
cvar2 = contextvars.Var()
# stack: []
assert cvar1.value is None
assert cvar2.value is None
with cvar1.assign("outer"):
# stack: [Assignment(cvar1, "outer")]
assert cvar1.value == "outer"
with cvar1.assign("inner"):
# stack: [Assignment(cvar1, "outer"),
# Assignment(cvar1, "inner")]
assert cvar1.value == "inner"
with cvar2.assign("hello"):
# stack: [Assignment(cvar1, "outer"),
# Assignment(cvar1, "inner"),
# Assignment(cvar2, "hello")]
assert cvar2.value == "hello"
# stack: [Assignment(cvar1, "outer"),
# Assignment(cvar1, "inner")]
assert cvar1.value == "inner"
assert cvar2.value is None
# stack: [Assignment(cvar1, "outer")]
assert cvar1.value == "outer"
# stack: []
assert cvar1.value is None
assert cvar2.value is None
cvar1.value를 사용하여 컨텍스트에서 값을 가져오는 동작은 스택에서 가장 위에 있는 cvar1 할당을 찾아 그 값을 반환하거나, 스택에서 할당을 찾지 못하면 기본값을 반환하는 방식으로 구현할 수 있습니다. 그러나 대부분의 경우에는 이를 최적화하여 대신 O(1) 연산으로 만들 수 있습니다. 그렇더라도 이러한 스택은 매우 커지도록 의도된 것이 아니므로, 스택을 탐색하는 것조차 상당히 빠를 수 있습니다.
위의 설명만으로도 핵심 개념을 구현하기에 이미 충분합니다. 일시 중단 가능한 프레임에는 다음에 설명하는 것처럼 추가적인 주의가 필요합니다.
제너레이터 및 코루틴 의미론의 구현
제너레이터, 코루틴, 비동기 제너레이터 내부에서도 할당과 할당 해제는 다른 모든 곳과 정확히 같은 방식으로 처리됩니다. 그러나 내장 제너레이터 메서드 send, __next__, throw, close에는 몇 가지 변경이 필요합니다. 다음은 제너레이터의 send에 필요한 변경 사항을 Python으로 표현한 동등한 코드입니다(여기서 _old_send는 Python 3.6에서의 동작을 가리킵니다):
def send(self, value):
if self.gi_contextvars is LEAK:
# If decorated with contextvars.leaking_yields.
# Nothing needs to be done to leak context through yields :)
return self._old_send(value)
try:
with contextvars.capture() as delta:
if self.gi_contextvars:
# non-zero captured content from previous iteration
self.gi_contextvars.reapply()
ret = self._old_send(value)
except Exception:
raise # back to the calling frame (e.g. StopIteration)
else:
# suspending, revert context changes but save them for later
delta.revert()
self.gi_contextvars = delta
return ret
다른 메서드에 대한 해당 수정 사항도 본질적으로 동일합니다. 코루틴과 비동기 제너레이터에도 마찬가지로 적용됩니다.
contextvars를 사용하지 않는 코드의 경우, 추가되는 작업은 O(1)이며 본질적으로 포인터 비교 몇 번으로 축소됩니다. contextvars를 사용하는 코드의 경우에도, 추가되는 작업은 대부분 여전히 O(1)입니다.
구현에 대한 추가 설명
contextvars.leaking_yields, contextvars.capture(), contextvars.get_local_state(), contextvars.clean_context()를 포함한 나머지 기능은 사실 구현하기가 상당히 간단하지만, 그 구현은 이 제안의 이후 버전에서 더 자세히 논의할 것입니다. 할당된 값의 캐싱은 다소 더 복잡하며 나중에 논의할 것이지만, 대부분의 경우 O(1) 복잡도를 달성할 수 있을 것으로 보입니다.
하위 호환성
완전히 새로운 기능을 제안하는 것이므로 직접적인 하위 호환성 문제는 없습니다.
그러나 스레드 로컬 저장소를 사용하는 다양한 전통적인 방식은 동시성에 안전해지기 위해 contextvars로 원활하게 전환해야 할 수 있습니다. 이를 위한 방법은 여러 가지가 있으며, 비동기 프레임워크의 약간의 도움을 받아 태스크 로컬 저장소를 에뮬레이션하는 방법도 그중 하나입니다. 원하는 의미론이 프레임워크의 설계에 따라 달라질 수 있으므로, 완전히 범용적인 구현은 제공할 수 없습니다.
전환을 다루는 또 다른 방법은 코드가 먼저 contextvars를 사용하여 생성된 컨텍스트를 찾도록 하는 것입니다. 새로운 방식의 컨텍스트가 설정되지 않았거나 코드가 이전 Python 버전에서 실행되어 이것이 실패하면, 스레드 로컬 저장소로 대체합니다.
미해결 문제
순서가 뒤바뀐 할당 해제
이 제안에서는 모든 변수 할당 해제가 앞선 할당과 반대 순서로 이루어집니다. 이 방식에는 두 가지 유용한 특성이 있습니다. 할당 범위를 정의할 때 with 문을 사용하도록 장려하며, 오류를 일찍 잡아내는 경향이 있습니다(.__exit__() 호출을 잊으면 대개 의미 있는 오류가 발생합니다. 이를 요구 사항으로 두는 것은 구현의 단순성과 성능 측면에서도 유리합니다. 그럼에도 컨텍스트를 순서와 다르게 종료하도록 허용하는 방안을 완전히 배제할 수는 없으며, 이를 위한 합리적인 구현 전략도 실제로 존재합니다.
기각된 아이디어
서브루틴 범위에 연결된 동적 스코핑
값이 보이는 범위는 코드를 서브루틴으로 리팩터링하는 방식에 따라 결정되어서는 안 됩니다. 할당 범위를 변수별로 제어할 수 있어야 합니다.
감사의 글
추후 추가될 예정입니다.
참고 문헌
추후 추가될 예정입니다.
Copyright
This document has been placed in the public domain.