Python ContextVar

roachroach
4 min read

ContextVar 란?

FastAPI 와 같은 비동기 프레임워크를 사용하다보면 하나의 세션안에서 동일한 컨텍스트를 유지해야 하는 일들이 발생한다. 기존 멀티 스레드기반에서 주로 사용되는 TLS(Thread Local Storage) 기반의 방식을 비동기에 적용하게 되면 Task 가 다른 스레드에서 실행되어 Context 가 예기치 않게 다른 스레드에 노출될수도 있다.

파이썬 PEP567 에서는 이러한 문제점을 해결하기 위해 ContextVar 라는 방식을 제안하였다. 제안서에 따르면 ContextVargetset 메소드를 이용하여 값을 수정또는 읽기가 가능하다고 합니다. 백문이 불여일견이라고 코드로 한번 보도록하겠습니다

import contextvars

from uuid import uuid4

ctx = contextvars.ContextVar("test_context", default=uuid4())

위와 같이 컨텍스트 객체를 생성할수 있습니다. ContextVar 의 첫번째 인자는 name 으로 주로 debug 의 목적으로 이용됩니다. 이제 이렇게 생성한 컨텍스트 객체가 async 함수들 안에서 잘 동작하는지 확인해보도록 하겠습니다.

async def nested_context():
    print(f"Nested context value: {ctx.get()}")

async def nested_context2():
    print(f"Nested context2 value: {ctx.get()}")

async def test_context():
    # Get the current context value
    current_value = ctx.get()

    if current_value is None:
        ctx.set(uuid4())
        current_value = ctx.get()

    print(f"Current context value: {current_value}")

    await nested_context()
    await nested_context2()

await test_context()

코드는 아주 심플합니다. main async 함수인 test_context 에서는 ctx 에 값이 없다면 새롭게 값을 생성하여 넣습니다. nested 함수들은 test_context 에서 생성된 값과 동일한 값을 가지는지 확인하기 위해 로깅을 통해 ctx 값을 출력합니다.

Current context value: c1df51e7-4524-4a6a-bc1a-c91500b33d1e
Nested context value: c1df51e7-4524-4a6a-bc1a-c91500b33d1e
Nested context2 value: c1df51e7-4524-4a6a-bc1a-c91500b33d1e

결과는 하나의 async 세션안에서 동일한 ctx 값이 이용되는 것을 확인할 수 있습니다.

값이 도중에 바뀌는 경우

만약 두번째 nested function 에서 ctx 의 값을 바꾼다면 어떻게 될까요?

async def nested_context():
    ctx.set(uuid4()) # 값 변경 일어남
    print(f"Nested context value: {ctx.get()}")

async def nested_context2():
    print(f"Nested context2 value: {ctx.get()}")

async def test_context():
    # Get the current context value
    current_value = ctx.get()

    if current_value is None:
        ctx.set(uuid4())
        current_value = ctx.get()

    print(f"Current context value: {current_value}")

    await nested_context()
    await nested_context2()

await test_context()
Current context value: da4f260c-337d-4e4b-901d-ed091c0c4474
Nested context value: b0459d83-cb3a-4bb9-a579-ce99d6125cf8
Nested context2 value: b0459d83-cb3a-4bb9-a579-ce99d6125cf8

결과를 보면 두번째 함수 이후에 값이 변경된것을 확인할 수 있습니다. 그렇다면 만약 도중에 Context 값을 바꾼채로 실행하고 싶다면 어떻게 해야할까요? 예를들어 같은 test_context 안에서 실행하지만 하나는 다른 context 에서 실행하고 싶을 경우에 말입니다.

import contextvars
from uuid import uuid4

ctx = contextvars.ContextVar('test_context')

def nested_context():
    print(f"Inside nested_context, before set: {ctx.get()}")
    ctx.set(uuid4())
    print(f"Inside nested_context, after set: {ctx.get()}")

def nested_context2():
    print(f"nested_context2 value: {ctx.get()}")

def test_context():
    initial_value = uuid4()
    ctx.set(initial_value)
    print(f"Initial context value: {initial_value}")

    copy_ctx = contextvars.copy_context()

    print(f"Value in copied context: {copy_ctx[ctx]}")

    copy_ctx.run(nested_context)

    print(f"Context value after copy.run(): {ctx.get()}")

    nested_context2()

일단 동기적인 코드로 먼져 살펴보면, test_context 에서 먼져 context 를 복사한 뒤에 복사한 context 를 통해서 nested_context 를 실행시키는 것을 확인할 수 있습니다. 이렇게 시작된 nested_context 는 내부에서 context 값을 새롭게 uuid 함수를 실행시켜 바꿉니다.

Initial context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Value in copied context: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Inside nested_context, before set: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Inside nested_context, after set: 7157e2f4-57bc-430a-9a21-d55236649a60 # nested 안에서만 바뀜!!
Context value after copy.run(): bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
nested_context2 value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6

예상대로 nested 안에서만 바뀌는 것을 확인할 수 있습니다. 즉 하나의 ctx 안이지만, 내부에서는 다른 상태를 가지게끔 할수 있는 것이죠. 그렇다면 이를 비동기로 전환만 해서 실행해볼까요?

async def nested_context():
    print(f"Copy context value: {ctx.get(ctx)}")
    ctx.set(uuid4())
    print(f"Nested context value: {ctx.get()}")

async def nested_context2():
    print(f"Nested context2 value: {ctx.get()}")

async def test_context():
    # Get the current context value
    current_value = ctx.get()

    if current_value is None:
        ctx.set(uuid4())
        current_value = ctx.get()

    print(f"Current context value: {current_value}")

    original_ctx = contextvars.copy_context()
    copy_ctx = contextvars.copy_context()


    print(f"Original context value: {original_ctx.get(ctx)}")
    await copy_ctx.run(nested_context)

    print(f"Context value after copy: {ctx.get()}")

    await original_ctx.run(nested_context2)

await test_context()

코드는 동일하고 async 로 붙여 실행하는 함수입니다. test_context 를 실행해보면 아까와는 다르게 첫번째 중첩함수에서 바꾼 컨텍스트 값이 다른 컨텍스트에도 영향을 주는 것을 확인할 수 있습니다.

Current context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Original context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Copy context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Nested context value: ffce1062-060e-4c03-aa2f-3ccf8eedaeaa
Context value after copy: ffce1062-060e-4c03-aa2f-3ccf8eedaeaa
Nested context2 value: ffce1062-060e-4c03-aa2f-3ccf8eedaeaa

아마 파이썬을 많이 다루시는 분들은 눈치 채셨겠지만 가장 큰 이유는 async def 함수는 기본적으로 coroutine 을 반환하게끔 되어있습니다.

coro = copy_ctx.run(nested_context)

coro: <coroutine object nested_context at 0x7f1c2ebda670>

await copy_ctx.run(nested_context) 를 실행해도 copy_ctx.run(nested_context) 의 결과가 coroutine 이기 때문에 실행되는 구역은 결국 test_context 함수안에서 실행되는 것입니다. 그렇기 때문에 원본 콘텍스트 값이 바뀔수 밖에 없는것이죠. 그렇다면 이를 어떻게 해결해야 할까요?

Asyncio.create_task

def create_task(coro, *, name=None, context=None):
    """Schedule the execution of a coroutine object in a spawn task.

    Return a Task object.
    """
    loop = events.get_running_loop()
    if context is None:
        # Use legacy API if context is not needed
        task = loop.create_task(coro, name=name)
    else:
        task = loop.create_task(coro, name=name, context=context)

    return task

Asyncio 의 create_task 를 이용하면 됩니다. 기본적으로 asyncio 는 context 를 받기때문에 copy_context 함수를 통해 OS 스레드가 복사해준 context 를 넘겨주기만 하면 우리가 예상한대로 실행됩니다.

import asyncio

async def nested_context():
    print(f"Copy context value: {ctx.get(ctx)}")
    ctx.set(uuid4())
    print(f"Nested context value: {ctx.get()}")

async def nested_context2():
    print(f"Nested context2 value: {ctx.get()}")

async def test_context():
    # Get the current context value
    current_value = ctx.get()

    if current_value is None:
        ctx.set(uuid4())
        current_value = ctx.get()

    print(f"Current context value: {current_value}")

    original_ctx = contextvars.copy_context()
    copy_ctx = contextvars.copy_context()


    print(f"Original context value: {original_ctx.get(ctx)}")
    await asyncio.create_task(nested_context(), context=copy_ctx)
    print(f"Context value after copy: {ctx.get()}")

    await nested_context2()

await test_context()

중간에 copy_ctx.run 부분만 create_task 로 바뀐것을 확인할 수 있습니다. 이렇게 실행하게 되면 새롭게 실행되는 중첩함수는 복사된 context 에서 실행되게 되어 우리가 원하는 결과값을 아래와 같이 얻을 수 있습니다.

Current context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Original context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Copy context value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Nested context value: 5d7a1ed1-316b-41ee-b289-ccd08976d869 # Nested 에서만 바뀐것 확인 가능
Context value after copy: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
Nested context2 value: bdf1fbd7-d454-43fa-b3d9-b37d428f38b6
0
Subscribe to my newsletter

Read articles from roach directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

roach
roach

https://www.linkedin.com/feed/update/urn:li:activity:7092144087058825216/