Home [Python] Thread-Local과 Context-Local
Post
Cancel

[Python] Thread-Local과 Context-Local

본문에서 다루는 예제 코드는 Python threading.local 와 ContextVar 비교 포스팅을 참고했음을 밝힙니다.


ASGI 애플리케이션에서는 여러 task가 single thread 기반으로 concurrent 하게 수행된다. 따라서 각 task 마다 어떠한 데이터를 안전하게 관리하려면, thread-local이 아닌 context-local한 방법을 사용해야 한다.

thread-local과 context-local이 무엇인지, 그리고 각각을 달성하는 방법은 무엇인지 알아보자.


1. Thread-Local

1-1. Thread-Local이란?

모든 파이썬 프로그램은 기본적으로 main thread라고 불리는 하나의 thread에서 실행되는데, 추가적으로 thread가 필요하다면 threading.Thread 모듈을 통해 지원한다. 이때, 각 thread 별로 관리되는 데이터를 저장할 수 있는데, 이러한 성질을 thread-local 이라고 한다. 또한, 이러한 데이터를 저장하는 공간을 thread-local storage, 줄여서 TLS라고 부른다.


1-2. threading.local()

파이썬에서는 threading 모듈에서 제공하는 local()을 이용하여 각 thread 마다 관리되는 데이터를 저장할 수 있다. 이를 사용하면 multi-threading 환경에서도 안전하게 각 thread에 속하는 데이터를 보존할 수 있다.


이를 활용한 예제 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import threading

# Create threading.local object.
thread_local_data = threading.local()

def thread_set():
    thread_local_data.value = "A"

def thread_get():
    value = getattr(thread_local_data, "value", "none")
    print(f"Thread-local data is {value}")

def thread_execute():
    thread_set()
    thread_get()

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

# output: 'Thread-local data is A'


2. Context-Local

2-1. Context-Local이란?

thread-local과 비슷하지만, context는 thread에 국한되지 않는다. 따라서 context-local에서는 각 thread 뿐만 아니라, 각 asynchronous task(ex. asyncio.Task)별로도 context를 유지할 수 있다.


2-2. contextvars.ContextVar

파이썬에서는 contextvars 모듈의 context variable ContextVar을 통해 context 단위로 관리되는 값을 저장할 수 있다.

이는 같은 context 내에서 연쇄적인 호출을 통해 variable을 넘겨야 할 때 편리하고, multi-threading 환경과 asynchronous 환경에서 사용 가능하다.

contextvarsasyncio를 지원하므로, 추가 설정 없이 async 환경에서 사용할 수 있다.


context variable을 선언할 때는 contextvars.ContextVar(name, default)로 정의하며, 해당 클래스는 다음과 같은 주요 메소드들를 가진다.

  1. get(): 특정 context에서의 context variable 값을 반환한다. 아무 값도 가지고 있지 않은 경우, default 값이 설정되었다면 해당 값을, 아니라면 에러를 발생시킨다.
  2. set(value): 인자로 받은 값을 특정 context의 context variable 값으로 설정한다.
  3. reset(token): context variable의 값을 set(value)가 호출되기 전의 값으로 리셋한다. 이때 사용하는 tokenset(value)를 호출했을 때의 반환 값이다.

contextvars.ContextVar의 실제 구현 코드: 링크


이를 활용한 예제 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from contextvars import ContextVar

# Create ContextVar object. (can set `default`)
context_var = ContextVar("context_var", default="...!")

def context_set():
    context_var.set("B")

def context_get():
    value = context_var.get()
    print(f"Context-local data is {value}")

def context_execute():
    context_set()
    context_get()

context_execute()

# output: 'Context-local data is B'


2-3. asyncio.Task

asyncio 공식 문서에서 Task 항목을 살펴보면 다음과 같은 글을 찾을 수 있다.

An optional keyword-only context argument allows specifying a custom contextvars.Context for the coro to run in. If no context is provided, the Task copies the current context and later runs its coroutine in the copied context.


즉, Taskcontextvars 모듈을 지원하며, Task가 생성될 때 context가 따로 주어지지 않으면 현재 context를 복사(contextvars.copy_context())하고 나중에 해당 context에서 coroutine을 실행한다. 이러한 동작을 통해 Task 별로 context를 가지게 되는 것 같다.


  • asyncio.Task의 실제 구현 코드를 살펴보면, asyncioTask instance 마다 _context attribute를 가지게 되는 것을 확인할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    class Task(futures._PyFuture):  # Inherit Python Task implementation
                                    # from a Python Future implementation.
    
        """A coroutine wrapped in a Future."""
    
        ...
    
        def __init__(self, coro, *, loop=None, name=None, context=None,
                      eager_start=False):
            super().__init__(loop=loop)
    
            ...
    
            if context is None:
                self._context = contextvars.copy_context()
            else:
                self._context = context
    
  • contextvars.copy_context()의 실제 구현 코드를 살펴보면, 다음과 같은 동작을 통해 context의 복사본을 얻을 수 있음을 확인할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    def copy_context():
        return _get_context().copy()
    
    def _get_context():
        ctx = getattr(_state, 'context', None)
        if ctx is None:
            ctx = Context()
            _state.context = ctx
        return ctx
    
    def _set_context(ctx):
        _state.context = ctx
    
    _state = threading.local()
    
  • contextvars.Context 클래스의 실제 구현 코드를 살펴보면 다음과 같다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    class Context(collections.abc.Mapping, metaclass=ContextMeta):
    
        def __init__(self):
            self._data = immutables.Map()
            self._prev_context = None
    
        def run(self, callable, *args, **kwargs):
            if self._prev_context is not None:
                raise RuntimeError(
                    'cannot enter context: {} is already entered'.format(self))
    
            self._prev_context = _get_context()
            try:
                _set_context(self)
                return callable(*args, **kwargs)
            finally:
                _set_context(self._prev_context)
                self._prev_context = None
    
        def copy(self):
            new = Context()
            new._data = self._data
            return new
    


2-4. 사용 시 주의사항

  • ContextVar는 closure에서 생성되면 안 되며, top module level에서 생성되어야 한다.
  • 현재 context에서 새로운 값을 context variable에 set(value)를 통해 설정하려면, 반환된 token 값을 저장하는 것이 좋다. 이는 추후 context variable을 이전 값으로 reset(token)하는 데에 사용할 수 있다.
  • get()reset(token) 시에 예외 처리를 항상 잘 해야 한다.
  • concurrent 환경에서 사용할 때는 다른 코드에 예상치 못한 영향을 미치지 않는지 확인해야 한다.


3. threading.local() vs. contextvars.ContextVar

3-1. 공통점

특정 thread 혹은 context에 local한 데이터를 저장하거나 접근하는 방법을 제공한다.

즉, multi-threading 환경에서는 비슷하게 동작한다.


3-2. 차이점

async 환경인 coroutine에서의 동작이 다르다.

파이썬에서는 여러 coroutine(asynchronous tasks)이 하나의 thread를 공유하기 때문에 thread-local을 보장한다고 해서 coroutine 별로 locality를 보장할 수 없으며, 예측 불가능한 결과를 불러올 수 있다.

  • threading.local()thread-local이기 때문에, 각 thread 마다 storage를 가지며 그곳에 데이터를 저장한다.
  • 반면, contextvarcontext-local이기 때문에, thread-local의 특성을 가질 뿐만 아니라 concurrent 환경에서 각 task 마다 storage를 가진다.

async 환경에서는 contextvar를 사용해야 한다!

ref: https://daco2020.tistory.com/799


3-3. Async 환경에서의 예시

[1] threading.local()

하나의 thread에서 모든 coroutine이 실행되므로 데이터가 안전하게 유지되지 않고 덮어씌워진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import asyncio
import threading

thread_local_data = threading.local()

async def execute(name):
    await asyncio.sleep(0.1)
    print(f"MBTI type of student {name} is {getattr(thread_local_data, 'value', 'none')}")

async def set_A():
    thread_local_data.value = "INFJ"
    print(f"MBTI type of Student A: {thread_local_data.value}")
    await execute("A")

async def set_B():
    thread_local_data.value = "CUTE"
    print(f"MBTI type of Student B: {thread_local_data.value}")
    await execute("B")

async def main():
    await asyncio.gather(set_A(), set_B())

asyncio.run(main())

# MBTI type of Student A: INFJ
# MBTI type of Student B: CUTE
# MBTI type of student A is CUTE <-- 덮어씌워짐!
# MBTI type of student B is CUTE

[2] contextvars.ContextVar

각 coroutine 별로 context variable 값이 안전하게 유지된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import asyncio
from contextvars import ContextVar

context_var_data: ContextVar[str] = ContextVar("context_var_data")

async def execute(name):
    await asyncio.sleep(0.1)
    print(f"MBTI type of student {name} is {context_var_data.get('none')}")

async def set_A():
    context_var_data.set("INFJ")
    print(f"MBTI type of Student A: {context_var_data.get()}")
    await execute("A")

async def set_B():
    context_var_data.set("CUTE")
    print(f"MBTI type of Student B: {context_var_data.get()}")
    await execute("B")

async def main() -> None:
    await asyncio.gather(set_A(), set_B())

asyncio.run(main())

# MBTI type of Student A: INFJ
# MBTI type of Student B: CUTE
# MBTI type of student A is INFJ <-- 값이 덮어씌워지지 않고 안전하게 유지됨
# MBTI type of student B is CUTE


References

This post is licensed under CC BY 4.0 by the author.

[Python] Singleton을 사용하는 다섯 가지 방법

[Python] Coroutine이 Thread 보다 가벼운/빠른 이유