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

Python 개선 제안 한국어 번역

PEP 550 – 실행 컨텍스트

Author:
Yury Selivanov <yury at edgedb.com>, Elvis Pranskevichus <elvis at edgedb.com>
Status:
Withdrawn
Type:
Standards Track
Created:
11-Aug-2017
Python-Version:
3.7
Post-History:
11-Aug-2017, 15-Aug-2017, 18-Aug-2017, 25-Aug-2017, 01-Sep-2017

Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 Python 제너레이터와 코루틴에서와 같이 순서가 뒤바뀐 실행의 맥락에서 비국소 상태에 대한 일관된 접근을 보장하는 새로운 범용 메커니즘을 추가합니다.

threading.local()과 같은 스레드 로컬 저장소는 동일한 OS 스레드에서 동시에 실행되는 프로그램에 적합하지 않습니다. 이 PEP는 이 문제에 대한 해결책을 제안합니다.

PEP 상태

범위가 넓고 일부 측면에 대한 일반적인 합의가 부족하여 이 PEP는 철회되었으며, Python 3.7에서 승인되고 포함된 더 단순한 PEP 567로 대체되었습니다.

PEP 567은 동일한 핵심 아이디어를 구현하지만, ContextVar 지원을 비동기 작업으로 제한하고 제너레이터 동작은 변경하지 않은 채로 둡니다. 후자는 향후 PEP에서 다시 검토될 수 있습니다.

근거

Python에서 비동기 프로그래밍이 등장하기 전에는 프로그램이 동시성을 달성하기 위해 OS 스레드를 사용했습니다. 스레드별 상태에 대한 필요성은 threading.local()과 이에 대응하는 C API인 PyThreadState_GetDict()로 해결되었습니다.

스레드 로컬 저장소(TLS)가 일반적으로 사용되는 몇 가지 사례는 다음과 같습니다:

  • decimal 컨텍스트, numpy.errstate, warnings.catch_warnings와 같은 컨텍스트 관리자입니다.
  • 보안 토큰 및 웹 애플리케이션의 요청 데이터와 같은 요청 관련 데이터, gettext 등의 언어 컨텍스트입니다.
  • 대규모 코드베이스에서의 프로파일링, 추적 및 로깅입니다.

안타깝게도 TLS는 단일 스레드에서 동시에 실행되는 프로그램에서 제대로 작동하지 않습니다. Python 제너레이터는 동시 프로그램의 가장 단순한 예입니다. 다음을 살펴보십시오.:

def fractions(precision, x, y):
    with decimal.localcontext() as ctx:
        ctx.prec = precision
        yield Decimal(x) / Decimal(y)
        yield Decimal(x) / Decimal(y ** 2)

g1 = fractions(precision=2, x=1, y=3)
g2 = fractions(precision=6, x=2, y=3)

items = list(zip(g1, g2))

items에 대해 직관적으로 예상되는 값은:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.11'), Decimal('0.222222'))]

그런데 실제 결과는 놀랍게도:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.111111'), Decimal('0.222222'))]

이는 암시적 Decimal 컨텍스트가 스레드 로컬로 저장되므로 fractions() 제너레이터를 동시에 반복하면 상태가 손상되기 때문입니다. 특히 Decimal의 경우 현재 사용할 수 있는 유일한 해결 방법은 모든 산술 연산에 명시적인 컨텍스트 메서드 호출을 사용하는 것입니다 [28]. 이는 오버로드된 연산자의 유용성을 떨어뜨리고 단순한 수식조차 읽고 쓰기 어렵게 만든다고 볼 수 있습니다.

코루틴은 TLS의 불안정성이 중요한 문제가 되는 또 다른 Python 코드 유형입니다.

비동기 코드에서 TLS가 적합하지 않기 때문에 범위가 제한되고 필요한 모든 사용 사례를 지원하지 않는 임시방편적 해결책이 확산되었습니다.

현재 상황은 TLS에 의존하는 모든 라이브러리(표준 라이브러리 포함)가 비동기 코드나 제너레이터와 함께 사용될 때 제대로 작동하지 않을 가능성이 높다는 것입니다([3]을 사례 문제로 참조하십시오).

코루틴이나 제너레이터를 지원하는 일부 언어에서는 모든 함수에 컨텍스트를 인자로 수동 전달할 것을 권장합니다. 예시는 [1]을 참조하십시오. 그러나 TLS와 유사한 컨텍스트를 사용하도록 구축된 대규모 생태계가 존재하는 Python에서는 이 접근 방식의 활용도가 제한적입니다. 또한 decimal이나 numpy와 같은 라이브러리는 오버로드된 연산자 구현에서 컨텍스트에 암묵적으로 의존합니다.

async/await를 지원하는 .NET 런타임에는 이 문제에 대한 범용 해결책인 ExecutionContext가 있습니다([2] 참조).

목표

이 PEP의 목표는 다음을 제공하는 보다 신뢰할 수 있는 threading.local() 대안을 제공하는 것입니다:

  • 코루틴과 제너레이터의 비로컬 상태 문제를 해결할 메커니즘과 API를 제공합니다;
  • 동기 코드에 TLS와 유사한 의미론을 구현하므로, decimalnumpy가 하위 호환성을 깨뜨릴 위험을 최소화하면서 새 메커니즘으로 전환할 수 있습니다;
  • C 확장을 포함하여 기존 코드나 새 메커니즘을 사용하게 될 코드에 성능 영향을 전혀 주지 않거나 무시할 수 있을 정도로만 줍니다.

상위 수준 사양

이 PEP의 전체 사양은 세 부분으로 나뉩니다:

  • 상위 수준 사양(이 섹션): 전체 해결책에 대한 설명입니다. 구현 세부 사항은 다루지 않고, 사용자 코드에서 제너레이터와 코루틴에 어떻게 적용되는지 보여 줍니다.
  • 상세 사양: 새로운 개념, API 및 표준 라이브러리의 관련 변경 사항에 대한 완전한 설명입니다.
  • 구현 세부 사항: 이 PEP를 구현하는 데 사용되는 데이터 구조와 알고리즘에 대한 설명 및 분석과 CPython에 필요한 변경 사항입니다.

이 섹션에서는 실행 컨텍스트를 동시 실행 환경에서 그 내용에 일관되게 접근할 수 있도록 하는 비로컬 상태의 불투명한 컨테이너로 정의합니다.

컨텍스트 변수는 실행 컨텍스트의 값을 나타내는 객체입니다. contextvars.ContextVar(name)을 호출하면 새 컨텍스트 변수 객체가 생성됩니다. 컨텍스트 변수 객체에는 다음 세 가지 메서드가 있습니다:

  • get(): 현재 실행 컨텍스트에서 변수의 값을 반환합니다;
  • set(value): 현재 실행 컨텍스트에서 변수의 값을 설정합니다;
  • delete(): 변수 상태를 복원하는 데 사용할 수 있으며, 그 목적과 의미론은 Setting and restoring context variables에 설명되어 있습니다.

일반 단일 스레드 코드

제너레이터나 코루틴이 관여하지 않는 일반 단일 스레드 코드에서 컨텍스트 변수는 전역 변수처럼 동작합니다.:

var = contextvars.ContextVar('var')

def sub():
    assert var.get() == 'main'
    var.set('sub')

def main():
    var.set('main')
    sub()
    assert var.get() == 'sub'

멀티스레드 코드

멀티스레드 코드에서 컨텍스트 변수는 스레드 로컬처럼 동작합니다.:

var = contextvars.ContextVar('var')

def sub():
    assert var.get() is None  # The execution context is empty
                              # for each new thread.
    var.set('sub')

def main():
    var.set('main')

    thread = threading.Thread(target=sub)
    thread.start()
    thread.join()

    assert var.get() == 'main'

제너레이터

일반 함수 호출과 달리 제너레이터는 실행 제어권을 호출자에게 협력적으로 양보할 수 있습니다. 또한 제너레이터는 실행이 양보 후 어디에서계속될지를 제어하지 않습니다. 제너레이터는 임의의 코드 위치에서 재개될 수 있습니다.

이러한 이유로 제너레이터의 가장 예상에서 벗어나지 않는 동작은 다음과 같습니다:

  • 컨텍스트 변수의 변경 사항은 항상 지역적이며 외부 컨텍스트에는 표시되지 않지만, 제너레이터가 호출한 코드에는 표시됩니다;
  • 제너레이터에서 한 번 설정된 컨텍스트 변수는 반복 사이에 변경되지 않는 것이 보장됩니다;
  • 외부 컨텍스트(제너레이터가 반복되는 컨텍스트)의 컨텍스트 변수 변경 사항은 해당 변수가 제너레이터 내부에서도 수정되지 않은 한 제너레이터에 표시됩니다.

검토해 봅시다:

var1 = contextvars.ContextVar('var1')
var2 = contextvars.ContextVar('var2')

def gen():
    var1.set('gen')
    assert var1.get() == 'gen'
    assert var2.get() == 'main'
    yield 1

    # Modification to var1 in main() is shielded by
    # gen()'s local modification.
    assert var1.get() == 'gen'

    # But modifications to var2 are visible
    assert var2.get() == 'main modified'
    yield 2

def main():
    g = gen()

    var1.set('main')
    var2.set('main')
    next(g)

    # Modification of var1 in gen() is not visible.
    assert var1.get() == 'main'

    var1.set('main modified')
    var2.set('main modified')
    next(g)

이제 Rationale 섹션의 decimal 정밀도 예제를 다시 살펴보고, 실행 컨텍스트가 이 상황을 어떻게 개선할 수 있는지 알아봅시다.:

import decimal

# create a new context var
decimal_ctx = contextvars.ContextVar('decimal context')

# Pre-PEP 550 Decimal relies on TLS for its context.
# For illustration purposes, we monkey-patch the decimal
# context functions to use the execution context.
# A real working fix would need to properly update the
# C implementation as well.
def patched_setcontext(context):
    decimal_ctx.set(context)

def patched_getcontext():
    ctx = decimal_ctx.get()
    if ctx is None:
        ctx = decimal.Context()
        decimal_ctx.set(ctx)
    return ctx

decimal.setcontext = patched_setcontext
decimal.getcontext = patched_getcontext

def fractions(precision, x, y):
    with decimal.localcontext() as ctx:
        ctx.prec = precision
        yield MyDecimal(x) / MyDecimal(y)
        yield MyDecimal(x) / MyDecimal(y ** 2)

g1 = fractions(precision=2, x=1, y=3)
g2 = fractions(precision=6, x=2, y=3)

items = list(zip(g1, g2))

items의 값은:

[(Decimal('0.33'), Decimal('0.666667')),
 (Decimal('0.11'), Decimal('0.222222'))]

이는 예상 결과와 일치합니다.

코루틴과 비동기 작업

제너레이터와 마찬가지로 코루틴은 값을 양보하고 제어권을 되찾을 수 있습니다. 제너레이터와의 주요 차이점은 코루틴이 직접 호출자에게 제어권을 양보하지 않는다는 점입니다. 대신 전체 코루틴 호출 스택(await로 연결된 코루틴)이 다른 코루틴 호출 스택으로 전환됩니다. 이런 점에서 코루틴을 await하는 것은 개념적으로 일반 함수 호출과 유사하며, 코루틴 체인(또는 예를 들어 asyncio.Task와 같은 “작업”)은 개념적으로 스레드와 유사합니다.

이러한 유사성으로부터 코루틴의 컨텍스트 변수는 “작업 로컬”처럼 동작해야 한다고 결론 내릴 수 있습니다.

  • 코루틴의 컨텍스트 변수 변경 사항은 해당 코루틴을 await하는 코루틴에서 볼 수 있습니다.
  • await하기 전에 호출자에서 이루어진 컨텍스트 변수 변경 사항은 await된 코루틴에서 볼 수 있습니다.
  • 한 작업에서 이루어진 컨텍스트 변수 변경 사항은 다른 작업에서는 볼 수 없습니다.
  • 다른 작업이 생성한 작업은 부모 작업에서 실행 컨텍스트를 상속하지만, 자식 작업이 생성된 부모 작업에서 컨텍스트 변수에 적용된 변경 사항은 표시되지 않습니다.

마지막 사항은 OS 스레드와는 다른 동작을 보여 줍니다. OS 스레드는 기본적으로 실행 컨텍스트를 상속하지 않습니다. 여기에는 일반적인 사용 의도와 하위 호환성이라는 두 가지 이유가 있습니다.

작업은 컨텍스트를 상속하지만 스레드는 상속하지 않는 주된 이유는 일반적인 사용 의도입니다. 작업은 작업을 생성한 코드와 논리적으로 연결된 비교적 짧은 작업에 자주 사용됩니다(예를 들어 asyncio에서 시간 제한을 두고 코루틴을 실행하는 경우). 반면 OS 스레드는 일반적으로 오래 실행되며 논리적으로 분리된 코드에 사용됩니다.

하위 호환성과 관련하여 실행 컨텍스트가 threading.local()처럼 동작하기를 원합니다. 이는 라이브러리가 기존 코드와의 호환성을 깨뜨릴 위험을 줄이면서 TLS 대신 실행 컨텍스트를 사용하기 시작할 수 있도록 하기 위한 것입니다.

방금 정의한 의미를 설명하기 위해 몇 가지 예를 살펴보겠습니다.

단일 작업에서의 컨텍스트 변수 전파:

import asyncio

var = contextvars.ContextVar('var')

async def main():
    var.set('main')
    await sub()
    # The effect of sub() is visible.
    assert var.get() == 'sub'

async def sub():
    assert var.get() == 'main'
    var.set('sub')
    assert var.get() == 'sub'

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

작업 간 컨텍스트 변수 전파:

import asyncio

var = contextvars.ContextVar('var')

async def main():
    var.set('main')
    loop.create_task(sub())  # schedules asynchronous execution
                             # of sub().
    assert var.get() == 'main'
    var.set('main changed')

async def sub():
    # Sleeping will make sub() run after
    # "var" is modified in main().
    await asyncio.sleep(1)

    # The value of "var" is inherited from main(), but any
    # changes to "var" made in main() after the task
    # was created are *not* visible.
    assert var.get() == 'main'

    # This change is local to sub() and will not be visible
    # to other tasks, including main().
    var.set('sub')

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

위에서 보았듯이 실행 컨텍스트의 변경 사항은 작업에 국한되며, 작업은 생성 시점의 실행 컨텍스트 스냅샷을 얻습니다.

이로 인해 놀라운 동작이 발생할 수 있는 제한적인 예외 상황이 하나 있습니다. 중첩된 코루틴에서 컨텍스트 변수를 수정하는 다음 예를 살펴보십시오.:

async def sub(var_value):
    await asyncio.sleep(1)
    var.set(var_value)

async def main():
    var.set('main')

    # waiting for sub() directly
    await sub('sub-1')

    # var change is visible
    assert var.get() == 'sub-1'

    # waiting for sub() with a timeout;
    await asyncio.wait_for(sub('sub-2'), timeout=2)

    # wait_for() creates an implicit task, which isolates
    # context changes, which means that the below assertion
    # will fail.
    assert var.get() == 'sub-2'  #  AssertionError!

그러나 컨텍스트 변경 사항이 호출자에게 누출되는 것에 의존하는 것은 궁극적으로 좋지 않은 패턴입니다. 따라서 위 예에 나타난 동작은 중대한 문제로 간주되지 않으며, 적절한 문서화로 해결할 수 있습니다.

상세 명세

개념적으로 실행 컨텍스트(EC)는 논리적 컨텍스트의 스택입니다. Python 스레드마다 항상 정확히 하나의 활성 EC가 존재합니다.

논리적 컨텍스트(LC)는 해당 LC에서 컨텍스트 변수와 그 값의 매핑입니다.

컨텍스트 변수는 실행 컨텍스트의 값을 나타내는 객체입니다. contextvars.ContextVar(name: str)를 호출하여 새 컨텍스트 변수 객체를 생성합니다. 필수 name 인자의 값은 EC 메커니즘에서 사용되지 않지만, 디버깅 및 인트로스펙션에 사용할 수 있습니다.

컨텍스트 변수 객체에는 다음 메서드와 특성이 있습니다.

  • name: ContextVar()에 전달된 값입니다.
  • get(*, topmost=False, default=None)topmostFalse인 경우(기본값) 변수 값이 발견될 때까지 실행 컨텍스트를 위에서 아래로 순회합니다. topmostTrue인 경우 최상위 논리적 컨텍스트에 있는 변수의 값을 반환합니다. 변수 값을 찾지 못한 경우 default의 값을 반환합니다.
  • set(value): 최상위 논리적 컨텍스트에서 변수의 값을 설정합니다.
  • delete(): 최상위 논리적 컨텍스트에서 변수를 제거합니다. 예를 들어 컨텍스트 관리자에서 set() 호출 이전의 상태로 논리적 컨텍스트를 복원할 때 유용합니다. 자세한 내용은 Setting and restoring context variables을 참조하십시오.

제너레이터

생성될 때 각 제너레이터 객체에는 비어 있는 논리적 컨텍스트 객체가 __logical_context__ 특성에 저장됩니다. 이 논리적 컨텍스트는 각 제너레이터 반복의 시작 시 실행 컨텍스트에 푸시되고 마지막에 팝됩니다.:

var1 = contextvars.ContextVar('var1')
var2 = contextvars.ContextVar('var2')

def gen():
    var1.set('var1-gen')
    var2.set('var2-gen')

    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})
    # ]
    n = nested_gen()  # nested_gen_LC is created
    next(n)
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})
    # ]

    var1.set('var1-gen-mod')
    var2.set('var2-gen-mod')
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'})
    # ]
    next(n)

def nested_gen():
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),
    #     nested_gen_LC()
    # ]
    assert var1.get() == 'var1-gen'
    assert var2.get() == 'var2-gen'

    var1.set('var1-nested-gen')
    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),
    #     nested_gen_LC({var1: 'var1-nested-gen'})
    # ]
    yield

    # EC = [
    #     outer_LC(),
    #     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'}),
    #     nested_gen_LC({var1: 'var1-nested-gen'})
    # ]
    assert var1.get() == 'var1-nested-gen'
    assert var2.get() == 'var2-gen-mod'

    yield

# EC = [outer_LC()]

g = gen()  # gen_LC is created for the generator object `g`
list(g)

# EC = [outer_LC()]

위의 코드 조각은 제너레이터 수명 동안 실행 컨텍스트 스택의 상태를 보여 줍니다.

contextlib.contextmanager

contextlib.contextmanager() 데코레이터를 사용하여 제너레이터를 컨텍스트 관리자로 변환할 수 있습니다. 컨텍스트 변수의 값을 일시적으로 수정하는 컨텍스트 관리자는 다음과 같이 정의할 수 있습니다.:

var = contextvars.ContextVar('var')

@contextlib.contextmanager
def var_context(value):
    original_value = var.get()

    try:
        var.set(value)
        yield
    finally:
        var.set(original_value)

안타깝게도 이는 바로 작동하지 않습니다. var 변수에 대한 수정이 var_context() 제너레이터에 국한되므로 with 블록 내부에서는 표시되지 않기 때문입니다.:

def func():
    # EC = [{}, {}]

    with var_context(10):
        # EC becomes [{}, {}, {var: 10}] in the
        # *precision_context()* generator,
        # but here the EC is still [{}, {}]

        assert var.get() == 10  # AssertionError!

이를 수정하려면 제너레이터의 __logical_context__ 특성을 None으로 설정해야 합니다. 이렇게 하면 제너레이터가 실행 컨텍스트 스택을 수정하지 않습니다.

올바르게 동작하는 컨텍스트 관리자를 생성하기 위해 contextlib.contextmanager() 데코레이터를 수정하여 genobj.__logical_context__None으로 설정합니다.:

def func():
    # EC = [{}, {}]

    with var_context(10):
        # EC = [{}, {var: 10}]
        assert var.get() == 10

    # EC becomes [{}, {var: None}]

컨텍스트 변수 열거

ExecutionContext.vars() 메서드는 실행 컨텍스트에 값을 가진 ContextVar객체의 목록을 반환합니다. 이 메서드는 주로 인트로스펙션 및 로깅에 유용합니다.

코루틴

CPython에서 코루틴은 제너레이터와 구현을 공유합니다. 차이점은 코루틴에서 __logical_context__의 기본값이 None이라는 것입니다. 이는 async def 코루틴과 기존 스타일의 제너레이터 기반 코루틴(@types.coroutine으로 데코레이트된 제너레이터) 모두에 영향을 줍니다.

비동기 제너레이터

비동기 제너레이터의 실행 컨텍스트 의미론은 일반 제너레이터의 의미론과 다르지 않습니다.

asyncio

asyncio는 함수의 비동기 실행을 예약하기 위해 Loop.call_soon, Loop.call_later, Loop.call_at를 사용합니다. asyncio.Task는 래핑된 코루틴을 실행하기 위해 call_soon()을 사용합니다.

새로운 선택적 execution_context 키워드 인자를 받아들이도록 Loop.call_{at,later,soon}을 수정하며, 이 인자의 기본값은 현재 실행 컨텍스트의 복사본입니다:

def call_soon(self, callback, *args, execution_context=None):
    if execution_context is None:
        execution_context = contextvars.get_execution_context()

    # ... some time later

    contextvars.run_with_execution_context(
        execution_context, callback, args)

contextvars.get_execution_context() 함수는 현재 실행 컨텍스트의 얕은 복사본을 반환합니다. 여기서 얕은 복사본이란 다음과 같은 새로운 실행 컨텍스트를 의미합니다.

  • 복사본에서 조회하면 원래 실행 컨텍스트에서와 동일한 결과를 제공하며,
  • 원래 실행 컨텍스트의 변경 사항이 복사본에 영향을 주지 않고,
  • 복사본의 변경 사항이 원래 실행 컨텍스트에 영향을 주지 않습니다.

다음 중 하나를 사용하면 복사 요구 사항을 충족합니다.

  • 논리 컨텍스트의 얕은 복사본을 포함하는 새 스택
  • 하나의 병합된 논리 컨텍스트를 포함하는 새 스택

contextvars.run_with_execution_context(ec, func, *args, **kwargs) 함수는 ec를 실행 컨텍스트로 하여 func(*args, **kwargs)를 실행합니다. 이 함수는 다음 단계를 수행합니다.

  1. 현재 스레드에서 ec를 현재 실행 컨텍스트 스택으로 설정하십시오.
  2. 빈 논리 컨텍스트를 스택에 푸시하십시오.
  3. func(*args, **kwargs)를 실행하십시오.
  4. 논리 컨텍스트를 스택에서 팝하십시오.
  5. 원래 실행 컨텍스트 스택을 복원하십시오.
  6. func()의 결과를 반환하거나 예외를 발생시키십시오.

이러한 단계는 ecfunc에 의해 수정되지 않도록 보장하므로, run_with_execution_context()를 멱등적으로 만듭니다.

asyncio.Task는 다음과 같이 수정합니다:

class Task:
    def __init__(self, coro):
        ...
        # Get the current execution context snapshot.
        self._exec_context = contextvars.get_execution_context()

        # Create an empty Logical Context that will be
        # used by coroutines run in the task.
        coro.__logical_context__ = contextvars.LogicalContext()

        self._loop.call_soon(
            self._step,
            execution_context=self._exec_context)

    def _step(self, exc=None):
        ...
        self._loop.call_soon(
            self._step,
            execution_context=self._exec_context)
        ...

제너레이터를 이터레이터로 변환

모든 Python 제너레이터는 동등한 이터레이터로 표현할 수 있습니다. Cython과 같은 컴파일러는 이 공리에 의존합니다. 실행 컨텍스트와 관련하여 이러한 이터레이터는 자신이 나타내는 제너레이터와 동일한 방식으로 동작해야 합니다.

따라서 새로운 논리 컨텍스트를 만들고 지정된 논리 컨텍스트에서 코드를 실행하는 Python API가 필요합니다.

contextvars.LogicalContext() 함수는 새로운 빈 논리 컨텍스트를 만듭니다.

contextvars.run_with_logical_context(lc, func, *args, **kwargs) 함수는 지정된 논리 컨텍스트에서 함수를 실행하는 데 사용할 수 있습니다. 호출 결과로 lc가 수정될 수 있습니다.

contextvars.run_with_logical_context() 함수는 다음 단계를 수행합니다.

  1. lc를 현재 실행 컨텍스트 스택에 푸시하십시오.
  2. func(*args, **kwargs)를 실행하십시오.
  3. lc를 실행 컨텍스트 스택에서 팝하십시오.
  4. func() 결과를 반환하거나 예외를 발생시킵니다.

LogicalContext()run_with_logical_context()를 사용하면 다음과 같이 제너레이터 동작을 재현할 수 있습니다.:

class Generator:

    def __init__(self):
        self.logical_context = contextvars.LogicalContext()

    def __iter__(self):
        return self

    def __next__(self):
        return contextvars.run_with_logical_context(
            self.logical_context, self._next_impl)

    def _next_impl(self):
        # Actual __next__ implementation.
        ...

예제 제너레이터에 이 패턴을 어떻게 적용할 수 있는지 살펴봅시다.:

# create a new context variable
var = contextvars.ContextVar('var')

def gen_series(n):
    var.set(10)

    for i in range(1, n):
        yield var.get() * i

# gen_series is equivalent to the following iterator:

class CompiledGenSeries:

    # This class is what the `gen_series()` generator can
    # be transformed to by a compiler like Cython.

    def __init__(self, n):
        # Create a new empty logical context,
        # like the generators do.
        self.logical_context = contextvars.LogicalContext()

        # Initialize the generator in its LC.
        # Otherwise `var.set(10)` in the `_init` method
        # would leak.
        contextvars.run_with_logical_context(
            self.logical_context, self._init, n)

    def _init(self, n):
        self.i = 1
        self.n = n
        var.set(10)

    def __iter__(self):
        return self

    def __next__(self):
        # Run the actual implementation of __next__ in our LC.
        return contextvars.run_with_logical_context(
            self.logical_context, self._next_impl)

    def _next_impl(self):
        if self.i == self.n:
            raise StopIteration

        result = var.get() * self.i
        self.i += 1
        return result

직접 작성한 이터레이터에서는 일반적으로 이러한 컨텍스트 관리 방식이 필요하지 않으며, __next__에서 컨텍스트 변수를 직접 설정하고 복원하는 것이 더 쉽습니다.:

class MyIterator:

    # ...

    def __next__(self):
        old_val = var.get()
        try:
            var.set(new_val)
            # ...
        finally:
            var.set(old_val)

구현

실행 컨텍스트는 논리 컨텍스트의 불변 연결 리스트로 구현되며, 각 논리 컨텍스트는 불변 약한 키 매핑입니다. 현재 활성화된 실행 컨텍스트에 대한 포인터는 OS 스레드 상태에 저장됩니다:

                  +-----------------+
                  |                 |     ec
                  |  PyThreadState  +-------------+
                  |                 |             |
                  +-----------------+             |
                                                  |
ec_node             ec_node             ec_node   v
+------+------+     +------+------+     +------+------+
| NULL |  lc  |<----| prev |  lc  |<----| prev |  lc  |
+------+--+---+     +------+--+---+     +------+--+---+
          |                   |                   |
LC        v         LC        v         LC        v
+-------------+     +-------------+     +-------------+
| var1: obj1  |     |    EMPTY    |     | var1: obj4  |
| var2: obj2  |     +-------------+     +-------------+
| var3: obj3  |
+-------------+

불변 매핑으로 이루어진 불변 리스트를 기본 데이터 구조로 선택한 이유는 비동기 작업과 콜백에서 자주 사용될 contextvars.get_execution_context()를 효율적으로 구현해야 하기 때문입니다. EC가 불변이면 get_execution_context()는 현재 실행 컨텍스트를 참조로 간단히 복사할 수 있습니다.:

def get_execution_context(self):
    return PyThreadState_Get().ec

가능한 모든 컨텍스트 수정 시나리오를 살펴봅시다:

  • ContextVariable.set()메서드가 호출됩니다.:
    def ContextVar_set(self, val):
        # See a more complete set() definition
        # in the `Context Variables` section.
    
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec
        top_lc = top_ec_node.lc
        new_top_lc = top_lc.set(self, val)
        tstate.ec = ec_node(
            prev=top_ec_node.prev,
            lc=new_top_lc)
    
  • contextvars.run_with_logical_context()가 호출되며, 이 경우 전달된 논리 컨텍스트 객체가 실행 컨텍스트에 추가됩니다.:
    def run_with_logical_context(lc, func, *args, **kwargs):
        tstate = PyThreadState_Get()
    
        old_top_ec_node = tstate.ec
        new_top_ec_node = ec_node(prev=old_top_ec_node, lc=lc)
    
        try:
            tstate.ec = new_top_ec_node
            return func(*args, **kwargs)
        finally:
            tstate.ec = old_top_ec_node
    
  • contextvars.run_with_execution_context()가 호출되며, 이 경우 현재 실행 컨텍스트가 전달된 실행 컨텍스트로 설정되고 새로운 빈 논리 컨텍스트가 여기에 추가됩니다.:
    def run_with_execution_context(ec, func, *args, **kwargs):
        tstate = PyThreadState_Get()
    
        old_top_ec_node = tstate.ec
        new_lc = contextvars.LogicalContext()
        new_top_ec_node = ec_node(prev=ec, lc=new_lc)
    
        try:
            tstate.ec = new_top_ec_node
            return func(*args, **kwargs)
        finally:
            tstate.ec = old_top_ec_node
    
  • genobj.send(), genobj.throw(), genobj.close() 중 하나가 genobj 제너레이터에서 호출되면, genobj에 기록된 논리 컨텍스트가 스택에 푸시됩니다.:
    PyGen_New(PyGenObject *gen):
        if (gen.gi_code.co_flags &
                (CO_COROUTINE | CO_ITERABLE_COROUTINE)):
            # gen is an 'async def' coroutine, or a generator
            # decorated with @types.coroutine.
            gen.__logical_context__ = None
        else:
            # Non-coroutine generator
            gen.__logical_context__ = contextvars.LogicalContext()
    
    gen_send(PyGenObject *gen, ...):
        tstate = PyThreadState_Get()
    
        if gen.__logical_context__ is not None:
            old_top_ec_node = tstate.ec
            new_top_ec_node = ec_node(
                prev=old_top_ec_node,
                lc=gen.__logical_context__)
    
            try:
                tstate.ec = new_top_ec_node
                return _gen_send_impl(gen, ...)
            finally:
                gen.__logical_context__ = tstate.ec.lc
                tstate.ec = old_top_ec_node
        else:
            return _gen_send_impl(gen, ...)
    
  • 코루틴과 비동기 제너레이터는 제너레이터와 구현을 공유하므로 위 변경 사항이 이들에게도 적용됩니다.

특정 시나리오에서는 체인의 크기를 제한하기 위해 EC를 압축해야 할 수 있습니다. 예를 들어 다음과 같은 특수한 경우를 생각해 봅시다.:

async def repeat(coro, delay):
    await coro()
    await asyncio.sleep(delay)
    loop.create_task(repeat(coro, delay))

async def ping():
    print('ping')

loop = asyncio.get_event_loop()
loop.create_task(repeat(ping, 1))
loop.run_forever()

위 코드에서는 repeat()가 호출되는 동안 EC 체인이 계속 증가합니다. 각각의 새 작업은 contextvars.run_with_execution_context()를 호출하며, 이 호출은 체인에 새로운 논리 컨텍스트를 추가합니다. 무제한 증가를 방지하기 위해 contextvars.get_execution_context()는 체인이 미리 정해진 최대 길이보다 긴지 확인하고, 길다면 체인을 하나의 LC로 압축합니다.:

def get_execution_context():
    tstate = PyThreadState_Get()

    if tstate.ec_len > EC_LEN_MAX:
        squashed_lc = contextvars.LogicalContext()

        ec_node = tstate.ec
        while ec_node:
            # The LC.merge() method does not replace
            # existing keys.
            squashed_lc = squashed_lc.merge(ec_node.lc)
            ec_node = ec_node.prev

        return ec_node(prev=NULL, lc=squashed_lc)
    else:
        return tstate.ec

논리 컨텍스트

논리 컨텍스트는 가비지 컬렉션과 관련하여 다음과 같은 속성을 가지는 불변 약한 키 매핑입니다:

  • ContextVar 객체는 애플리케이션 코드에서만 강하게 참조되며, 실행 컨텍스트 메커니즘이나 해당 객체가 가리키는 값에서는 강하게 참조되지 않습니다. 이는 수명을 필요 이상으로 연장하거나 GC에 의한 수집을 방해할 수 있는 참조 순환이 없음을 의미합니다.
  • 실행 컨텍스트에 저장된 값은 스레드에 해당 값을 참조하는 ContextVar 키가 있는 동안 계속 살아 있는 것이 보장됩니다.
  • ContextVar가 가비지 컬렉션되면 모든 컨텍스트에서 해당 값이 제거되어 필요할 경우 GC될 수 있습니다.
  • OS 스레드가 실행을 종료하면 해당 스레드의 실행 컨텍스트와 함께 스레드 상태가 정리되고, 스레드의 모든 컨텍스트 변수에 바인딩된 모든 값도 정리됩니다.

앞서 설명했듯이 실행 컨텍스트의 크기와 관계없이 contextvars.get_execution_context()를 일관되게 빠르게 실행해야 하므로, 논리 컨텍스트는 필연적으로 불변 매핑입니다.

기반 구현으로 dict를 선택하는 것은 최적이 아닙니다. LC.set()dict.copy()를 호출하게 되며, 이는 LC의 항목 수를 N으로 할 때 O(N) 연산이기 때문입니다.

get_execution_context()는 EC를 압축할 때 O(M) 연산이며, 여기서 M은 EC에 있는 컨텍스트 변수 값의 총 개수입니다.

따라서 dict대신 논리 컨텍스트의 기반 구현으로 해시 배열 매핑 트라이(Hash Array Mapped Trie, HAMT)를 선택합니다. (Scala와 Clojure는 고성능 불변 컬렉션을 구현하는 데 HAMT를 사용합니다 [5], [6].

HAMT에서는 .set()이 O(log N) 연산이 되며, HAMT의 구조적 공유 덕분에 평균적으로 get_execution_context()를 압축하는 작업이 더 효율적입니다.

HAMT 성능을 dict와 비교한 보다 상세한 분석은 Appendix: HAMT Performance Analysis를 참고하십시오.

컨텍스트 변수

ContextVar.get()ContextVar.set() 메서드는 다음과 같이 구현됩니다(의사 코드).:

class ContextVar:

    def get(self, *, default=None, topmost=False):
        tstate = PyThreadState_Get()

        ec_node = tstate.ec
        while ec_node:
            if self in ec_node.lc:
                return ec_node.lc[self]
            if topmost:
                break
            ec_node = ec_node.prev

        return default

    def set(self, value):
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec

        if top_ec_node is not None:
            top_lc = top_ec_node.lc
            new_top_lc = top_lc.set(self, value)
            tstate.ec = ec_node(
                prev=top_ec_node.prev,
                lc=new_top_lc)
        else:
            # First ContextVar.set() in this OS thread.
            top_lc = contextvars.LogicalContext()
            new_top_lc = top_lc.set(self, value)
            tstate.ec = ec_node(
                prev=NULL,
                lc=new_top_lc)

    def delete(self):
        tstate = PyThreadState_Get()
        top_ec_node = tstate.ec

        if top_ec_node is None:
            raise LookupError

        top_lc = top_ec_node.lc
        if self not in top_lc:
            raise LookupError

        new_top_lc = top_lc.delete(self)

        tstate.ec = ec_node(
            prev=top_ec_node.prev,
            lc=new_top_lc)

numpydecimal과 같은 성능 민감 코드 경로에서 효율적으로 액세스할 수 있도록 ContextVar.get()의 조회를 캐시하여, 캐시 적중 시 O(1) 연산이 되도록 합니다. 캐시 키는 다음 항목으로 구성됩니다.

  • 새로운 uint64_t PyThreadState->unique_id는 전역적으로 고유한 스레드 상태 식별자입니다. 이는 새로운 uint64_t PyInterpreterState->ts_counter로부터 계산되며, 새 스레드 상태가 생성될 때마다 증가합니다.
  • 새로운 uint64_t PyThreadState->stack_version은 스레드별 카운터이며, 비어 있지 않은 논리 컨텍스트가 스택에 푸시되거나 스택에서 팝될 때마다 증가합니다.
  • uint64_t ContextVar->version 카운터는 모든 OS 스레드의 모든 논리 컨텍스트에서 컨텍스트 변수 값이 변경될 때마다 증가합니다.

그러면 캐시는 다음과 같이 구현됩니다.:

class ContextVar:

    def set(self, value):
        ...  # implementation
        self.version += 1

    def get(self, *, default=None, topmost=False):
        if topmost:
            return self._get_uncached(
                default=default, topmost=topmost)

        tstate = PyThreadState_Get()
        if (self.last_tstate_id == tstate.unique_id and
                self.last_stack_ver == tstate.stack_version and
                self.last_version == self.version):
            return self.last_value

        value = self._get_uncached(default=default)

        self.last_value = value  # borrowed ref
        self.last_tstate_id = tstate.unique_id
        self.last_stack_version = tstate.stack_version
        self.last_version = self.version

        return value

last_value는 빌린 참조라는 점에 유의하십시오. 버전 검사가 올바르면 값 객체가 계속 존재한다고 가정합니다. 이를 통해 컨텍스트 변수의 값이 올바르게 가비지 수집될 수 있습니다.

이 일반적인 캐싱 방식은 현재 C로 구현된 decimal이 현재 십진 컨텍스트를 캐시하는 방식과 유사하며, 성능 특성도 비슷합니다.

성능 고려 사항

이 PEP의 이전 개정판을 기반으로 한 참조 구현의 테스트에서는 제너레이터 마이크로벤치마크에서 1~2%의 성능 저하가 나타났으며, 매크로벤치마크에서는 눈에 띄는 차이가 나타나지 않았습니다.

제너레이터가 아니며 비동기도 아닌 코드의 성능은 이 PEP의 영향을 받지 않습니다.

새로운 API 요약

Python

이 PEP에서는 다음과 같은 새로운 Python API를 도입합니다.

  1. 새로운 contextvars.ContextVar(name: str='...') 클래스이며, 인스턴스는 다음 항목을 갖습니다.
    • 읽기 전용 .name 속성입니다.
    • 현재 실행 컨텍스트에서 변수의 값을 반환하는 .get() 메서드입니다.
    • 현재 논리 컨텍스트에서 변수의 값을 설정하는 .set() 메서드입니다.
    • 현재 논리 컨텍스트에서 변수의 값을 제거하는 .delete() 메서드입니다.
  2. 실행 컨텍스트를 나타내는 새로운 contextvars.ExecutionContext() 클래스입니다.
  3. 논리 컨텍스트를 나타내는 새로운 contextvars.LogicalContext() 클래스입니다.
  4. 현재 실행 컨텍스트의 복사본을 나타내는 ExecutionContext 인스턴스를 반환하는 새로운 contextvars.get_execution_context() 함수입니다.
  5. 제공된 실행 컨텍스트를 사용하여 func을 실행하는 contextvars.run_with_execution_context(ec: ExecutionContext, func, *args, **kwargs) 함수입니다.
  6. 현재 실행 컨텍스트 위에서 제공된 논리 컨텍스트를 사용하여 func을 실행하는 contextvars.run_with_logical_context(lc: LogicalContext, func, *args, **kwargs) 함수입니다.

C API

  1. PyContextVar * PyContext_NewVar(char *desc): PyContextVar 객체를 생성합니다.
  2. PyObject * PyContext_GetValue(PyContextVar *, int topmost): 현재 실행 컨텍스트에서 변수의 값을 반환합니다.
  3. int PyContext_SetValue(PyContextVar *, PyObject *): 현재 논리적 컨텍스트에서 변수의 값을 설정합니다.
  4. int PyContext_DelValue(PyContextVar *): 현재 논리적 컨텍스트에서 변수의 값을 삭제합니다.
  5. PyLogicalContext * PyLogicalContext_New(): 비어 있는 새 PyLogicalContext를 생성합니다.
  6. PyExecutionContext * PyExecutionContext_New(): 비어 있는 새 PyExecutionContext를 생성합니다.
  7. PyExecutionContext * PyExecutionContext_Get(): 현재 실행 컨텍스트를 반환합니다.
  8. int PyContext_SetCurrent( PyExecutionContext *, PyLogicalContext *): 전달된 EC 객체를 활성 스레드 상태의 현재 실행 컨텍스트로 설정하고, 전달된 LC 객체를 현재 논리적 컨텍스트로 설정합니다.

설계 고려 사항

“yield from”은 컨텍스트 변경 사항을 누출해야 합니까?

아닙니다. yield from은 함수를 호출하는 것과 의미상 동등하므로 컨텍스트 변경 사항을 누출해야 한다고 주장할 수 있습니다. 그러나 다음 조건을 동시에 만족하는 것은 불가능합니다.

  • next(gen)gen에서 이루어진 컨텍스트 변경 사항을 전파하지 않으며,
  • yield from gengen에서 이루어진 컨텍스트 변경 사항을 전파합니다.

그 이유는 yield from을 이미 로컬 컨텍스트 변경 사항이 있는 부분적으로 이터레이션된 제너레이터와 함께 사용할 수 있기 때문입니다.:

var = contextvars.ContextVar('var')

def gen():
    for i in range(10):
        var.set('gen')
        yield i

def outer_gen():
    var.set('outer_gen')
    g = gen()

    yield next(g)
    # Changes not visible during partial iteration,
    # the goal of this PEP:
    assert var.get() == 'outer_gen'

    yield from g
    assert var.get() == 'outer_gen'  # or 'gen'?

또 다른 예로 명시적인 for..in yield 구문을 yield from 표현식으로 리팩터링하는 경우를 들 수 있습니다. 다음 코드를 고려하십시오.:

def outer_gen():
    var.set('outer_gen')

    for i in gen():
        yield i
    assert var.get() == 'outer_gen'

이를 yield from을 사용하도록 리팩터링하려고 합니다.:

def outer_gen():
    var.set('outer_gen')

    yield from gen()
    assert var.get() == 'outer_gen'  # or 'gen'?

위 예시는 컨텍스트 변경 사항이 누출될 수 있는 경우 yield from을 사용하여 제너레이터 코드를 리팩터링하는 것이 안전하지 않음을 보여 줍니다.

따라서 잘 정의되고 일관된 동작은 이터레이션 방식과 관계없이 제너레이터의 컨텍스트 변경 사항을 항상 격리하는 것뿐입니다.

PyThreadState_GetDict()는 실행 컨텍스트를 사용해야 합니까?

아닙니다. PyThreadState_GetDict는 TLS를 기반으로 하므로 그 의미를 변경하면 하위 호환성이 깨집니다.

PEP 521

PEP 521은 두 가지 새로운 메서드인 __suspend__()__resume__()를 추가하여 컨텍스트 관리자 프로토콜을 확장하는 문제의 대안적 해결책을 제안합니다. 마찬가지로 비동기 컨텍스트 관리자 프로토콜도 __asuspend__()__aresume__()로 확장됩니다.

이를 통해 비로컬 상태를 관리하며 제너레이터와 코루틴에서 올바르게 동작하는 컨텍스트 관리자를 구현할 수 있습니다.

예를 들어 실행 상태를 사용하는 다음 컨텍스트 관리자를 고려하십시오.:

class Context:

    def __init__(self):
        self.var = contextvars.ContextVar('var')

    def __enter__(self):
        self.old_x = self.var.get()
        self.var.set('something')

    def __exit__(self, *err):
        self.var.set(self.old_x)

동등한 구현은 PEP 521을 사용합니다.:

local = threading.local()

class Context:

    def __enter__(self):
        self.old_x = getattr(local, 'x', None)
        local.x = 'something'

    def __suspend__(self):
        local.x = self.old_x

    def __resume__(self):
        local.x = 'something'

    def __exit__(self, *err):
        local.x = self.old_x

이 접근 방식의 단점은 컨텍스트 관리자 프로토콜과 인터프리터 구현에 상당한 새 복잡성이 추가된다는 점입니다. 또한 이 접근 방식은 제너레이터와 코루틴의 성능에 부정적인 영향을 미칠 가능성이 높습니다.

추가로 PEP 521의 해결책은 컨텍스트 관리자에 한정되며, 비동기 작업과 콜백에서 상태를 전파할 메커니즘을 제공하지 않습니다.

실행 컨텍스트를 CPython을 수정하지 않고 구현할 수 있습니까?

아닙니다.

라이브러리에서 코루틴에 “task-locals” 개념을 구현할 수 있다는 것은 사실입니다(예를 들어 [29][30] 참조). 반면 제너레이터는 Python 인터프리터가 직접 관리하므로 해당 컨텍스트도 인터프리터가 관리해야 합니다.

또한 실행 컨텍스트는 서드파티 모듈에서 전혀 구현할 수 없습니다. 그렇지 않으면 decimal을 포함한 표준 라이브러리가 이에 의존할 수 없습니다.

sys.displayhook 및 다른 API를 업데이트하여 EC를 사용하도록 해야 합니까?

sys.stdout을 덮어써서 stdout을 리디렉션하거나 sys.displayhook함수를 덮어써서 새로운 예외 표시 훅을 지정하는 것과 같은 API는 설계상 전체 Python 프로세스에 영향을 줍니다. 해당 API의 사용자는 이를 변경한 효과가 운영 체제 스레드 전체에 표시된다고 가정합니다. 따라서 이러한 API가 새로운 Execution Context를 사용하도록 단순히 변경할 수는 없습니다.

그렇지만 컨텍스트를 인식하는 새로운 API를 설계하는 것은 가능하다고 생각하지만, 이는 이 PEP의 범위를 벗어납니다.

Greenlets

Greenlet은 Python을 위한 협력적 스케줄링의 대안 구현입니다. greenlet 패키지는 CPython의 일부가 아니지만 gevent와 같은 인기 있는 프레임워크가 이에 의존하며, greenlet을 수정하여 실행 컨텍스트를 지원할 수 있도록 하는 것이 중요합니다.

개념적으로 그린렛의 동작은 제너레이터의 동작과 매우 유사하므로, 실행 컨텍스트를 지원하기 위해 그린렛 진입 및 종료와 관련된 유사한 변경을 수행할 수 있습니다. 이 PEP는 이를 수행하는 데 필요한 C API를 제공합니다.

수정을 위한 인터페이스로서의 컨텍스트 관리자

이 PEP는 실행 컨텍스트를 사용한 기본 작업을 가능하게 하는 저수준 메커니즘과 최소 API에 집중합니다.

개발자의 편의를 위해 고수준 컨텍스트 관리자 인터페이스를 contextvars모듈에 추가할 수 있습니다. 예를 들어 다음과 같습니다.:

with contextvars.set_var(var, 'foo'):
    # ...

컨텍스트 변수 설정 및 복원

ContextVar.delete()메서드는 최상위 논리적 컨텍스트에서 컨텍스트 변수를 제거합니다.

최상위 논리적 컨텍스트에서 변수를 찾지 못하면 LookupError가 발생하며, 이는 var가 스코프에 없을 때 del varNameError를 발생시키는 것과 유사합니다.

이 메서드는 중첩된 제너레이터가 논리적 컨텍스트를 일시적으로 수정하려는 경우처럼 논리적 컨텍스트의 상태를 올바르게 복원해야 할 필요가 (드물게) 있을 때 유용합니다.:

var = contextvars.ContextVar('var')

def gen():
    with some_var_context_manager('gen'):
        # EC = [{var: 'main'}, {var: 'gen'}]
        assert var.get() == 'gen'
        yield

    # EC = [{var: 'main modified'}, {}]
    assert var.get() == 'main modified'
    yield

def main():
    var.set('main')
    g = gen()
    next(g)
    var.set('main modified')
    next(g)

위의 예제가 올바르게 작동하려면 gen()에서 논리적 컨텍스트의 var를 삭제할 방법이 있어야 합니다. __exit__()에서 이를 “이전 값”으로 설정하면 반복 사이에 main()에서 이루어진 변경을 가리게 됩니다.

ContextVar API의 대안 설계

값 스택을 사용하는 논리적 컨텍스트

이 PEP에서 제시하는 설계에 따르면 논리적 컨텍스트는 단순한 LC({ContextVar: value, ...}) 매핑입니다. 대안 표현은 각 컨텍스트 변수에 대한 값 스택을 저장하는 것입니다: LC({ContextVar: [val1, val2, ...], ...}).

그러면 ContextVar메서드는 다음과 같습니다.

  • get(*, default=None) – 논리적 컨텍스트 스택을 순회하여 비어 있지 않은 첫 번째 논리적 컨텍스트의 최상위 값을 반환합니다.
  • push(val) – 현재 논리적 컨텍스트의 값 스택에 val을 푸시합니다.
  • pop() – 현재 논리적 컨텍스트의 값 스택에서 최상위 값을 팝합니다.

set()delete() 메서드를 사용하는 단일 값 설계와 비교하면, 스택 기반 접근 방식은 set/restore 패턴을 더 간단하게 구현할 수 있도록 합니다. 그러나 이 접근 방식에서는 고려해야 할 스택이 개, 즉 LC의 스택과 각 LC에 있는 값의 스택이 존재하므로 정신적 부담이 더 큰 것으로 여겨집니다.

(이 아이디어는 Nathaniel Smith가 제안했습니다.)

ContextVar “set/reset”

또 다른 접근 방식은 ContextVar.set()에서 특수 객체를 반환하는 것으로, 이 객체는 현재 논리 컨텍스트에서 컨텍스트 변수의 수정을 나타냅니다.:

var = contextvars.ContextVar('var')

def foo():
    mod = var.set('spam')

    # ... perform work

    mod.reset()  # Reset the value of var to the original value
                 # or remove it from the context.

이 접근 방식의 치명적인 결함은 컨텍스트 변수 “수정 객체”를 다른 실행 컨텍스트에서 실행 중인 코드에 전달할 수 있게 되어, 정의되지 않은 부작용이 발생한다는 점입니다.

하위 호환성

이 제안은 하위 호환성을 100% 유지합니다.

거부된 아이디어

threading.local() 인터페이스 복제

컨텍스트 변수에 threading.local()-와 같은 인터페이스를 선택하는 방안이 고려되었으나 다음과 같은 이유로 거부되었습니다.

  • 표준 라이브러리와 Django를 조사한 결과, threading.local() 사용의 대다수가 단일 속성과 관련되어 있는 것으로 나타났으며, 이는 네임스페이스 접근 방식이 실제 환경에서는 그다지 유용하지 않음을 보여 줍니다.
  • 값 조회에 __getattr__().get() 대신 사용하면 조회 깊이, 즉 최상위 논리 컨텍스트만 검색할지 여부를 지정할 방법이 없습니다.
  • 단일 값 ContextVar는 가시성 측면에서 더 쉽게 추론할 수 있습니다. ContextVar()가 네임스페이스라고 가정하고 다음을 고려해 보십시오.:
    ns = contextvars.ContextVar('ns')
    
    def gen():
        ns.a = 2
        yield
        assert ns.b == 'bar' # ??
    
    def main():
        ns.a = 1
        ns.b = 'foo'
        g = gen()
        next(g)
        # should not see the ns.a modification in gen()
        assert ns.a == 1
        # but should gen() see the ns.b modification made here?
        ns.b = 'bar'
        yield
    

    위의 예는 동일한 컨텍스트 변수에서 서로 다른 속성의 가시성을 추론하는 일이 간단하지 않음을 보여 줍니다.

  • 단일 값 ContextVar는 조회 캐시를 간단하게 구현할 수 있도록 합니다;
  • 단일 값 ContextVar 인터페이스를 사용하면 C-API를 단순하게 만들 수 있으며, 본질적으로 Python API와 동일하게 만들 수 있습니다.

메일링 리스트 논의도 참고하십시오: [26], [27].

기본적으로 컨텍스트 변경 사항을 누출하지 않는 코루틴

이 PEP의 V4 (Version History)에서는 실행 컨텍스트와 관련하여 코루틴이 제너레이터와 정확히 동일하게 동작하는 것으로 간주했습니다. 즉, await된 코루틴의 변경 사항이 바깥쪽 코루틴에 표시되지 않았습니다.

이 아이디어는 태스크 모델과 스레드 모델의 의미적 유사성을 깨뜨리고, 더 구체적으로는 컨텍스트 변수를 수정하는 비동기 컨텍스트 관리자를 안정적으로 구현할 수 없게 만든다는 이유로 거부되었습니다. __aenter__는 코루틴이기 때문입니다.

부록: HAMT 성능 분석

../_images/pep-0550-hamt_vs_dict-v2.png

Figure 1. Benchmark code can be found here: [9].

위 차트는 다음을 보여 줍니다.

  • HAMT는 벤치마크 대상인 모든 딕셔너리 크기에서 O(1)에 가까운 성능을 보입니다.
  • dict.copy()는 항목 수가 약 100개에 이르면 매우 느려집니다.
../_images/pep-0550-lookup_hamt.png

Figure 2. Benchmark code can be found here: [10].

그림 2는 dict와 HAMT 기반 불변 매핑의 조회 비용을 비교합니다. HAMT 조회 시간은 평균적으로 Python 딕셔너리 조회보다 30~40% 느리지만, 후자가 매우 잘 최적화되어 있다는 점을 고려하면 매우 좋은 결과입니다.

HAMT의 성능을 더욱 개선할 수 있는 방법이 있음을 보여 주는 연구가 있습니다 [8].

CPython용 HAMT 참조 구현은 여기에서 찾을 수 있습니다: [7].

감사의 말

이 주제에 관해 셀 수 없이 많은 논의를 나누고 PEP 교정 및 편집을 해 주신 Victor Petrovykh에게 감사드립니다.

ContextVar설계를 제안하고 [17] [18], PEP를 더욱 완전한 설계로 발전시키며, 스레드 상태에 컨텍스트 스택을 두자는 아이디어를 제시해 주신 Nathaniel Smith에게 감사드립니다.

메일링 리스트에서 수많은 제안과 아이디어를 제시하고, 초기 PEP 버전을 완전히 다시 작성하게 한 사례를 제시해 주신 Alyssa (Nick) Coghlan에게 감사드립니다 [19].

버전 기록입니다.

  1. 초기 개정판이 2017년 8월 11일에 게시되었습니다 [20].
  2. V2가 2017년 8월 15일에 게시되었습니다 [21].

    첫 번째 버전을 완전히 재설계하게 한 근본적인 한계는 제너레이터와 동일한 방식으로 EC와 상호 작용하는 이터레이터를 구현할 수 없었다는 점입니다( [19]. 참조).

    버전 2는 완전히 다시 작성되었으며, 새로운 용어(로컬 컨텍스트, 실행 컨텍스트, 컨텍스트 항목)와 새로운 API를 도입했습니다.

  3. V3가 2017년 8월 18일에 게시되었습니다 [22].

    업데이트:

    • 로컬 컨텍스트의 이름이 논리 컨텍스트로 변경되었습니다. “로컬”이라는 용어는 모호했으며 로컬 이름 스코프와 충돌했습니다.
    • 컨텍스트 항목의 이름이 컨텍스트 키로 변경되었습니다. 자세한 내용은 Alyssa Coghlan, Stefan Krah, Yury Selivanov와의 스레드를 참조하십시오 [23].
    • Nathaniel Smith가 [25]에서 제안한 아이디어에 따라 컨텍스트 항목 가져오기 캐시 설계를 조정했습니다.
    • 코루틴은 논리 컨텍스트 없이 생성되며, ceval 루프는 더 이상 await 표현식을 특별히 처리할 필요가 없습니다(Alyssa Coghlan이 [24]. 에서 제안했습니다).
  4. V4가 2017년 8월 25일에 게시되었습니다 [31].
    • 사양 섹션을 완전히 다시 작성했습니다.
    • 이제 코루틴에는 자체 논리 컨텍스트가 있습니다. 이는 실행 컨텍스트와 상호 작용하는 방식에서 코루틴, 제너레이터 및 비동기 제너레이터 사이에 차이가 없음을 의미합니다.
    • 컨텍스트 키의 이름이 컨텍스트 변수로 변경되었습니다.
    • 논리 컨텍스트 격리와 관련하여 제너레이터와 코루틴을 구분하던 것을 제거했습니다.
  5. V5가 2017년 9월 1일에 게시되었습니다. 현재 버전입니다.

참고 자료