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와 유사한 의미 체계를 구현하므로, decimal이나 numpy 같은 사용자가 하위 호환성을 깨뜨릴 위험을 최소화하면서 새로운 메커니즘으로 전환할 수 있습니다.
  • 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'

제너레이터

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

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

  • 컨텍스트 변수에 대한 변경은 항상 지역적이어서 바깥 컨텍스트에서는 보이지 않지만, 제너레이터가 호출하는 코드에서는 보입니다.
  • 제너레이터 안에서 한 번 설정된 컨텍스트 변수는 이터레이션 사이에 바뀌지 않음이 보장됩니다.
  • 바깥 컨텍스트(제너레이터가 이터레이션되고 있는 곳)에서 컨텍스트 변수를 변경하면 제너레이터에서도 보입니다. 단, 해당 변수가 제너레이터 안에서도 수정된 경우는 예외입니다.

살펴봅시다:

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'))]

이는 기대한 결과와 일치합니다.

코루틴과 비동기 태스크

제너레이터와 마찬가지로 코루틴은 제어를 양보(yield)했다가 다시 돌려받을 수 있습니다. 제너레이터와의 주요 차이점은 코루틴이 직접 호출자에게 제어를 양보하지 않는다는 점입니다. 대신 전체 코루틴 호출 스택(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): topmost가 False이면(기본값) 변수 값을 찾을 때까지 실행 컨텍스트를 위에서 아래로 탐색합니다. topmost가 True이면 최상위 논리 컨텍스트에 있는 변수 값을 반환합니다. 변수 값을 찾지 못하면 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()을 사용합니다.

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

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() 함수는 현재 실행 컨텍스트의 얕은 복사본을 반환합니다. 여기서 얕은 복사본이란 다음 조건을 만족하는 새로운 실행 컨텍스트를 의미합니다:

  • 복사본에서 조회하면 원래 실행 컨텍스트에서와 같은 결과를 얻으며,
  • 원래 실행 컨텍스트의 어떠한 변경도 복사본에 영향을 주지 않고,
  • 복사본의 어떠한 변경도 원래 실행 컨텍스트에 영향을 주지 않습니다.

다음 중 어느 것이든 복사 요구 사항을 만족합니다:

  • 논리 컨텍스트의 얕은 복사본들로 이루어진 새로운 스택,
  • 하나로 합쳐진(squashed) 논리 컨텍스트로 이루어진 새로운 스택.

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

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

이 단계들은 ec가 func에 의해 수정될 수 없도록 보장하며, 이로써 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 제너레이터에 대해 genobj.send(), genobj.throw(), genobj.close() 중 하나가 호출되며, 이 경우 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()

위 코드에서 EC 체인은 repeat()가 호출되는 한 계속 늘어납니다. 새 태스크마다 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()를 일으키기 때문인데, 이는 O(N) 연산이며 여기서 N은 LC의 항목 수입니다.

EC를 압축할 때 get_execution_context()는 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)

numpy나 decimal 같은 성능에 민감한 코드 경로에서 효율적으로 접근할 수 있도록 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는 빌린 참조라는 점에 유의하십시오. 버전 검사를 통과하면 값 객체가 살아 있을 것이라고 가정합니다. 이렇게 하면 컨텍스트 변수의 값이 올바르게 가비지 컬렉션될 수 있습니다.

이 범용 캐싱 방식은 현재 decimal의 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 gen은 gen에서 이루어진 컨텍스트 변경을 누출합니다.

그 이유는 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이 컨텍스트 변경을 누출할 수 있는 경우 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의 사용자들은 이를 변경한 효과가 모든 OS 스레드에 걸쳐 보인다고 가정합니다. 따라서 이 API들이 새로운 실행 컨텍스트를 사용하도록 그냥 바꿀 수는 없습니다.

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

Greenlet

Greenlet은 Python에서 협력적 스케줄링을 제공하는 또 다른 구현입니다. greenlet 패키지는 CPython의 일부가 아니지만 gevent 같은 인기 있는 프레임워크가 이에 의존하므로, greenlet을 수정하여 실행 컨텍스트를 지원할 수 있게 하는 것이 중요합니다.

개념적으로 greenlet의 동작은 제너레이터의 동작과 매우 비슷하므로, greenlet 진입과 종료 부분에 비슷한 변경을 가하여 실행 컨텍스트 지원을 추가할 수 있습니다. 이 PEP는 이를 위해 필요한 C API를 제공합니다.

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

이 PEP는 저수준 메커니즘과, 실행 컨텍스트에 대한 기본 연산을 가능하게 하는 최소한의 API에 집중합니다.

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

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

컨텍스트 변수 설정 및 복원

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

최상위 논리 컨텍스트에서 변수를 찾을 수 없으면 LookupError가 발생합니다. 이는 var가 스코프에 없을 때 del var가 NameError를 발생시키는 것과 비슷합니다.

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

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() 메서드를 사용하는 단일 값 설계와 비교하면, 스택 기반 접근 방식은 설정/복원 패턴을 더 간단하게 구현할 수 있게 합니다. 그러나 이 접근 방식에서는 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() 사용의 대다수가 단일 속성만을 다룬다는 것이 밝혀졌으며, 이는 네임스페이스 방식이 실제 사용 현장(필드)에서는 그다지 유용하지 않음을 나타냅니다.
  • 값 조회에 .get() 대신 __getattr__()를 사용하면 조회 깊이를 지정할 방법(즉, 최상위 논리적 컨텍스트만 검색하는 것)이 없습니다.
  • 단일 값 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 dict 조회보다 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].

    변경 사항:

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

참고 문헌