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

Python 개선 제안 한국어 번역

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 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

추상

때로는 특수한 경우에, 함수 호출 체인의 각 함수에 정보를 인자로 명시적으로 전달하지 않고도 코드가 호출되는 함수들에 함수 호출 체인을 따라 정보를 전달할 수 있기를 원합니다. 이 제안은 특정 컨텍스트 변수에 주어진 값이 할당된 컨텍스트 안팎으로 코드가 명시적으로 전환할 수 있도록 하는 구문을 설명합니다. 이는 전통적인 단일 스레드(또는 스레드 안전하지 않은) 코드에서 전역 변수와 같은 것을 사용하는 일부 방식과 전통적인 동시성 안전하지 않은 코드(단일 스레드 또는 다중 스레드)에서 스레드 로컬 저장소를 사용하는 일부 방식을 대체하는 현대적인 방법입니다. 특히 제안된 메커니즘은 비동기적으로 실행되는 코루틴과 같은 더욱 현대적인 동시 실행 메커니즘과도 함께 사용할 수 있으며, 동시에 실행되는 호출 체인들이 서로의 컨텍스트를 방해하지 않습니다.

“호출 체인”은 일반 함수, 대기된 코루틴 또는 제너레이터로 구성될 수 있습니다. 컨텍스트 변수 범위의 의미 체계는 모든 경우에 동일하므로, 컨텍스트 변수의 의미 체계에 영향을 주지 않고 코드를 서브루틴 (여기서는 함수, 서브 제너레이터 또는 서브 코루틴을 의미합니다)으로 자유롭게 리팩터링할 수 있습니다. 구현 측면에서 이 제안은 단순성과 CPython 인터프리터 및 다른 Python 인터프리터에 대한 변경을 최소화하는 것을 목표로 합니다.

근거

현대적인 Python 호출 체인(또는 호출 트리)을 생각해 보십시오. 이 제안에서 이는 일반 함수 호출 또는 awaityield from을 사용하는 표현식을 가능한 모든 방식으로 조합하여 서브루틴을 연쇄적으로(중첩하여) 실행하는 것을 의미합니다. 어떤 경우에는 필요한 정보를 호출 체인을 따라 인자로 전달하는 일이 필요한 함수 시그니처를 상당히 복잡하게 만들 수 있으며, 실제로는 이를 달성하는 것 자체가 불가능할 수도 있습니다. 이러한 경우에는 이 정보를 저장할 다른 장소를 찾을 수 있습니다. 몇 가지 역사적 사례를 살펴보겠습니다.

가장 단순한 방법은 전역 변수나 이와 유사한 곳에 값을 할당하여 호출 체인을 따라 실행되는 코드가 그 값에 액세스할 수 있도록 하는 것입니다. 그러나 여러 스레드가 있을 때 모든 스레드가 동일한 전역 변수에 값을 할당하고 다른 스레드가 호출 체인의 어느 지점에서든 간섭할 수 있으므로, 이렇게 하면 코드가 즉시 스레드 안전하지 않게 됩니다. 머지않아 누군가는 같은 코드를 병렬 스레드에서 실행할 이유를 찾게 될 것입니다.

다소 덜 단순한 방법은 스레드 로컬 저장소에 스레드별 정보로 정보를 저장하는 것입니다. 이 경우 각 스레드는 다른 스레드가 간섭할 수 없는 변수의 자체 “복사본”을 가집니다. 이상적이지는 않지만, 이는 많은 경우에 가장 나은 해결책이었습니다. 그러나 제너레이터와 코루틴 덕분에 호출 체인의 실행을 일시 중지했다가 재개할 수 있으므로, 다른 컨텍스트의 코드가 동시에 실행될 수 있습니다. 따라서 스레드 로컬 저장소를 사용하는 것은 동시성 안전하지 않은 방식입니다. 다른 컨텍스트의 호출 체인이 스레드 로컬 변수에 간섭할 수 있기 때문입니다.

위의 두 가지 역사적 접근 방식에서는 문제를 일으키지 않으면서 이용할 수 있는 가장 넓은 범위에 저장된 정보가 놓인다는 점에 유의하십시오. 같은 방향의 세 번째 해결책을 위해서는 먼저 비동기 실행과 동시성을 위한 “스레드”에 해당하는 것을 정의해야 합니다. 이는 실행 순서에 모호함이 없이 순차적으로 실행된다고 보장되는 코드와 중첩 호출의 최대 규모로 볼 수 있습니다. 이를 동시성 로컬 저장소 또는 태스크 로컬 저장소라고 부를 수 있습니다. 이러한 “태스크”의 의미에서는 하나의 태스크 내부 코드 실행 순서에 모호함이 없습니다. (이 태스크 개념은 asyncioTask와 거의 동등하지만 정확히 같지는 않습니다.) 이러한 동시성 로컬 저장소에서는 다른 코드 경로가 백그라운드에서 해당 값에 간섭하지 않고 호출 체인을 따라 호출되는 함수들에 정보를 전달할 수 있습니다.

위의 접근 방식에 공통된 점은 모두 범위가 넓지만 문제를 일으키지 않을 만큼만 좁은 범위의 변수를 실제로 사용한다는 것입니다. 스레드 로컬 변수는 스레드 전역 변수라고도 부를 수 있습니다. 단일 스레드 코드에서는 실제로 진정한 전역 변수이기 때문입니다. 또한 태스크는 매우 클 수 있으므로 태스크 로컬 변수는 태스크 전역 변수라고 부를 수 있습니다.

여기서 문제는 전역 변수, 스레드 로컬 변수 또는 태스크 로컬 변수가 실행 컨텍스트의 정보를 호출 체인 아래로 전달하는 이러한 목적으로 실제로 사용되도록 의도된 것이 아니라는 점입니다. 가능한 한 가장 넓은 변수 스코프를 사용하는 대신, 변수의 스코프는 일반적으로 라이브러리의 프로그래머가 원하는 범위로, 더 넓지 않게 제어해야 합니다. 다시 말해, 태스크 로컬 변수(그리고 전역 변수와 스레드 로컬 변수)는 이 제안이 가능하게 하려는 종류의 컨텍스트에 결합된 정보 전달과는 아무런 관련이 없습니다. 태스크 로컬 변수를 사용하여 원하는 의미론을 모방할 수 있더라도 마찬가지입니다. 따라서 다음에서는 이 제안이 컨텍스트 로컬 변수(또는 컨텍스트 변수, 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또는 다른 예외와 함께 종료된 후 제너레이터 외부에서 do 표시됩니다.:

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

그러나 순 내용에 할당 해제가 포함된 경우에는 해당 “delta”를 다시 적용하지 못할 수 있습니다(구현 및 미해결 문제도 참조하십시오).

컨텍스트 상태의 스냅샷 가져오기

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__, throwclose에는 일부 변경이 필요합니다. 다음은 제너레이터의 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__() 호출을 잊으면 종종 의미 있는 오류가 발생합니다. 이를 요구 사항으로 두는 것은 구현의 단순성과 성능 면에서도 유익합니다. 그럼에도 불구하고, 순서를 벗어난 컨텍스트 종료를 허용하는 것이 완전히 불가능한 것은 아니며, 이를 위한 합리적인 구현 전략이 실제로 존재합니다.

거부된 아이디어

서브루틴 범위와 연결된 동적 스코핑

값의 가시성 범위는 코드가 서브루틴으로 리팩터링되는 방식에 의해 결정되어서는 안 됩니다. 할당 범위를 변수별로 제어할 수 있어야 합니다.

감사의 말

추후 추가 예정입니다.

참고 문헌

추후 추가 예정입니다.