Python 的上下文管理

一次请求经过网关、订单服务、库存服务和数据库时,链路追踪系统需要用 trace ID、span ID 等信息把各段调用关联起来。跨服务时,这些信息通常放在 HTTP traceparent、gRPC metadata 或消息头中;请求进入 Python 服务后,日志、指标和下游客户端仍要随时读到当前链路信息。

最直接的办法是显式传参:

def handle_request(trace_id: str) -> None:
    load_user(trace_id)
    create_order(trace_id)

调用链一长,trace_id 就会穿过许多本来不关心它的函数。全局变量也不合适,因为并发请求会覆盖彼此的值。threading.local() 能隔离线程,却分不清同一事件循环线程上的多个 asyncio Task。

Python 3.7 根据 PEP 567 引入 contextvars,用来保存“属于当前执行上下文”的状态。日志函数可以直接读取当前 trace ID,调用链不必层层转发它;asyncio 在切换 Task 时也会切换对应的 Context,避免请求之间串值。

contextvars 的作用域止于当前 Python 进程。跨进程、跨服务的传播仍要由追踪库把完整的 W3C Trace Context 注入载体,在接收端提取后再写入本地 Context。

Python frame 与当前 Context 相互独立;普通调用沿用当前 Context,线程、asyncio Task 和进程在各自边界选择复制、恢复或显式传递

本文先从公开 API 建立行为模型,再用 CPython 3.14.2 解释不同并发场景为何表现不同。源码固定在 CPython commit df793163d5821791d4e7caf88885a2c11a107986。对象字段、缓存和 C 函数属于这一版 CPython;其他 Python 实现只需遵守公开语义。

1. contextvars 的设计语义#

ContextVar 不是保存当前值的容器,而是 Context 映射中的键。这一点决定了后面的所有行为。

对象 作用
ContextVar 定义一个上下文变量,保存名称和可选默认值
Context 保存 ContextVar -> value 的映射,并可通过 run() 临时成为当前 Context
Token 记录一次 set() 前的绑定,用于精确恢复

ContextVar.get(default) 按以下顺序取值:

  1. 当前 Context 中的绑定;
  2. 本次 get(default) 传入的默认值;
  3. 创建 ContextVar 时给出的默认值;
  4. 都不存在时抛出 LookupError

两种默认值都只影响读取,不会被写进 Context。set() 改变当前 Context 中的绑定,并返回一个 Token;reset(token) 恢复写入前的状态。如果变量原先不存在,reset 会删除绑定,而不是写入 None 或默认值。

1.1 简单同步场景#

下面用 trace ID 模拟一次请求。业务函数没有接收 trace_id 参数,但日志仍能读到当前请求的值:

from contextvars import ContextVar

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


def write_log(message: str) -> None:
    print(f"[{trace_id.get('unknown')}] {message}")


def load_order() -> None:
    write_log("load order")


def handle_request(incoming_trace_id: str) -> None:
    token = trace_id.set(incoming_trace_id)
    try:
        write_log("request started")
        load_order()
    finally:
        trace_id.reset(token)


handle_request("4bf92f3577b34da6")
print(f"outside: {trace_id.get('MISSING')}")
[4bf92f3577b34da6] request started
[4bf92f3577b34da6] load order
outside: MISSING

finally 保证正常返回、异常和取消都能恢复进入请求前的绑定。Python 3.14 起,Token 支持上下文管理器协议,因此也可以写成 with trace_id.set("..."):,语义与 Token 配合 finally 相同。

调用 Context.run(callable, ...) 时,指定的 Context 会在 callable 执行期间成为当前 Context,返回或抛出异常后再恢复先前的 Context。run() 只负责进入和退出,不会回滚 callable 对该 Context 做出的写入。

普通函数调用不会创建或复制 Context。只要代码仍在同一个执行流中,嵌套函数就沿用当前 Context。

1.2 多线程场景#

每个线程有自己的当前 Context。要让新线程看到调用方的值,需要在启动边界明确选择复制还是隔离:

from contextvars import Context, ContextVar, copy_context
from threading import Thread

trace_id: ContextVar[str] = ContextVar("trace_id", default="MISS")


def show_trace(name: str) -> None:
    print(f"{name}: {trace_id.get()}")


trace_id.set("req-42")

# 场景一:复制主线程的当前 Context,让新线程继承 trace_id。
copied_context = copy_context()
copied = Thread(
    target=copied_context.run,  # target:新线程启动后调用的函数
    args=(show_trace, "copied"),  # args:传给 Context.run 的位置参数
)
copied.start()
copied.join()

# 场景二:从空 Context 启动,新线程看不到主线程的 trace_id。
empty_context = Context()
isolated = Thread(
    target=empty_context.run,
    args=(show_trace, "isolated"),
)
isolated.start()
isolated.join()
copied: req-42  # 复制的 Context 已包含主线程的 trace_id
isolated: MISS  # 空 Context 没有绑定,返回 ContextVar 的默认值

Thread 启动后会执行 target(*args)。因此 copied 实际调用 copied_context.run(show_trace, "copied"):先进入复制出的 Context,再执行 show_trace("copied")isolated 的调用结构相同,只是进入的是空 Context。

Python 3.14 的 Thread 构造器 可以直接接收 Context:

Thread(target=show_trace, args=("copied",), context=copy_context())
Thread(target=show_trace, args=("isolated",), context=Context())

如果 context=None,初始 Context 由 sys.flags.thread_inherit_context 决定。这个标志在普通 GIL 构建和 free-threaded 构建中的默认值不同,因此库和生产代码不应依赖隐式默认值。

线程池还多一层复用。ThreadPoolExecutor.submit() 不会为每个任务自动捕获提交方的 Context,可以在提交时包一层:

from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from typing import Any, Callable


def submit_with_context(
    executor: ThreadPoolExecutor,
    func: Callable[..., Any],
    /,
    *args: Any,
    **kwargs: Any,
):
    context = copy_context()
    return executor.submit(context.run, func, *args, **kwargs)

每次提交都要创建自己的 Context 副本。同一个 Context 不能被两个线程同时进入。

1.3 asyncio 场景#

asyncio 的传播边界是 Task,而不是协程对象或 await 关键字。直接 await 仍在当前 Task 中运行;asyncio.create_task() 默认在创建 Task 时复制当前 Context:

import asyncio
from contextvars import ContextVar

value: ContextVar[str] = ContextVar("value", default="MISS")


async def direct() -> None:
    print(f"direct before: {value.get()}")
    value.set("direct")  # 故意不 reset,用来观察与调用方共享的绑定
    print(f"direct after: {value.get()}")


async def child(start: asyncio.Event) -> None:
    await start.wait()  # 等父 Task 写入 parent-2 后再读取
    print(f"child before: {value.get()}")
    value.set("child")
    print(f"child after: {value.get()}")


async def main() -> None:
    # 场景一:直接 await 不创建新 Task,direct() 沿用当前 Context。
    value.set("parent")
    await direct()
    print(f"parent after direct: {value.get()}")

    # 场景二:create_task() 在此处复制当前 Context,其中 value 为 direct。
    start = asyncio.Event()
    task = asyncio.create_task(child(start))
    value.set("parent-2")  # 只修改父 Task 的 Context
    start.set()  # 放行子 Task,让它读取创建时捕获的 Context
    await task
    print(f"parent after task: {value.get()}")


asyncio.run(main())
direct before: parent       # direct() 读取父 Task 当前的绑定
direct after: direct        # direct() 在同一个 Context 中写入 direct
parent after direct: direct # 直接 await 没有复制,父协程读到新值
child before: direct        # 子 Task 读取创建时捕获的 direct
child after: child          # 子 Task 只修改自己的 Context
parent after task: parent-2 # 子 Task 的写入没有回传父 Task

direct() 的写入立即成为父协程的当前值,因为两者在同一个 Task 中。子 Task 创建时捕获到 direct,随后父 Task 写入 parent-2、子 Task 写入 child,双方的绑定不再同步。

常见 asyncio API 的行为如下:

边界 Context 行为
直接 await coroutine 沿用当前 Task 的 Context
asyncio.create_task() 创建 Task 时复制;显式 context= 时直接使用给定 Context
loop.call_soon() 注册回调时捕获;也可显式传 context=
asyncio.to_thread() 协程开始执行时复制当前 Context,并通过 Context.run() 进入
loop.run_in_executor() 不为每次提交传播调用方的 Context

asyncio.to_thread() 本身是异步函数。仅创建 to_thread(...) 的协程对象还没有捕获 Context;当这个协程被 await 或调度、开始执行时才会复制。需要精确控制捕获时机时,应立即把它交给 create_task(),或自行调用 copy_context()

1.4 多进程场景#

进程之间没有共享的当前 Context。下面强制使用 spawn,可以看到子进程只有显式传入的值:

from concurrent.futures import ProcessPoolExecutor
from contextvars import ContextVar
from multiprocessing import get_context

trace_id: ContextVar[str] = ContextVar("trace_id", default="MISS")


def read_implicit() -> str:
    # spawn worker 重新导入模块,本地 Context 中没有父进程的绑定。
    return trace_id.get()


def read_explicit(incoming_trace_id: str) -> str:
    # 用普通任务参数在 worker 的 Context 中重建绑定。
    token = trace_id.set(incoming_trace_id)
    try:
        return trace_id.get()
    finally:
        trace_id.reset(token)


def main() -> None:
    trace_id.set("req-42")
    with ProcessPoolExecutor(
        max_workers=1,
        mp_context=get_context("spawn"),  # 启动全新的 Python 解释器
    ) as executor:
        # 场景一:任务没有接收 trace_id,只能读取 worker 的默认值。
        implicit = executor.submit(read_implicit).result()

        # 场景二:把 trace_id 作为可序列化参数传入 worker。
        explicit = executor.submit(read_explicit, trace_id.get()).result()

    print(f"implicit: {implicit}")
    print(f"explicit: {explicit}")


if __name__ == "__main__":
    main()
implicit: MISS   # 没有传播父进程 Context,返回默认值
explicit: req-42 # worker 用任务参数重建了本地绑定

Context 不是可移植的 pickle 载荷。fork 可能让子进程暂时看到父进程内存的快照,但它取决于平台、启动方式和 fork 时机,也不会继续同步,不能当成传播协议。

跨进程和跨服务应传递普通、可序列化的数据。链路追踪还应使用 W3C Trace Context 等标准格式传递完整的 span 上下文,而不是只复制一个 trace ID 字符串。

2. CPython 如何实现这些语义#

前面的差异来自两层设计:contextvars 在 CPython 运行时维护当前 Context,线程库和 asyncio 再决定何时进入、复制或恢复它。多进程没有共享运行时,只能走显式的数据边界。

2.1 共同底座:Context、ContextVar 与 HAMT#

CPython 3.14.2 在 PyThreadStatepycore_context.h 中定义了这些对象关系:

  • PyThreadState.context 指向当前 Context,初始值可以是 NULLcontext_ver 在 Context 进入和退出时递增。
  • PyContext.ctx_prev 保存进入前的 Context,ctx_vars 指向不可变的 PyHamtObject 映射,ctx_entered 防止同一个 Context 被重复进入。
  • PyContextVar 保存名称、默认值和哈希值。普通 GIL 构建还带有按线程状态 ID 和 Context 版本校验的读取缓存。
  • PyContextToken 保存创建它的 Context、变量、旧值和使用状态。tok_oldval == NULL 对应公开 API 的 Token.MISSING
CPython 中 PyThreadState 指向当前 PyContext,PyContext 引用不可变 HAMT 映射;ContextVar 带读取缓存,Token 保存创建时的 Context、变量和旧值

PyContextVar_Get() 的主要路径可以简化为:

def get(var, argument_default=NO_ARGUMENT):
    tstate = current_thread_state()
    context = tstate.context

    # 普通 GIL 构建先用线程状态 ID 和 Context 版本校验缓存。
    if cache_matches(var, tstate):
        return var.cached_value

    if context is not None and var in context.ctx_vars:
        value = context.ctx_vars[var]
        refresh_cache(var, tstate, value)
        return value

    # 两级默认值只参与本次读取,不会写入 Context。
    if argument_default is not NO_ARGUMENT:
        return argument_default
    if var.has_default:
        return var.default

    return NOT_FOUND

get() 不会为了读取默认值创建空 Context。Python 层在拿到 NOT_FOUND 后才抛出 LookupError。进入或退出 Context 时递增 context_ver,旧缓存便自然失效,不需要扫描所有 ContextVar。free-threaded 构建不使用这组缓存字段,应用代码不应依赖缓存细节。

PyContextVar_Set()PyContextVar_Reset()PyContext_CopyCurrent() 则共享另一条路径:

def set(var, value):
    context = context_get()  # 没有当前 Context 时,在此处延迟创建。
    old = context.ctx_vars.get(var, MISSING)
    token = Token(context, var, old)

    # assoc() 返回新的 HAMT,未变化的节点仍与旧映射共享。
    context.ctx_vars = context.ctx_vars.assoc(var, value)
    return token


def reset(var, token):
    context = context_get()
    validate_token(token, var, context)
    token.used = True

    if token.old is MISSING:
        # set() 前没有绑定,reset() 应删除键,而不是写入默认值。
        context.ctx_vars = context.ctx_vars.without(var)
    else:
        context.ctx_vars = context.ctx_vars.assoc(var, token.old)


def copy_context():
    context = context_get()

    # 新 Context 只共享不可变 HAMT,不逐项复制其中的绑定。
    return new_context(shared_vars=context.ctx_vars)

ctx_vars 是不可变 HAMT。copy_context() 只创建新的 Context 外壳并共享映射,因此公开文档给出的复杂度是 O(1)。某份 Context 后续写入时,HAMT 通过路径复制生成新映射,其他快照仍引用旧映射。

这里复制的是绑定结构,不是值对象。两个 Context 如果都绑定到同一个 list 或 dict,看到的仍是同一个可变对象。

2.2 同步代码:进入和退出当前 Context#

_PyContext_Enter()_PyContext_Exit() 维护一个与 Python frame 栈分离的 Context 栈:

def enter(ctx):
    tstate = current_thread_state()
    if ctx.ctx_entered:
        raise RuntimeError("Context 已经进入")

    # 保存旧 Context,再把 ctx 压入当前线程的 Context 栈。
    ctx.ctx_prev = tstate.context
    tstate.context = ctx
    ctx.ctx_entered = True

    # 版本变化会使 ContextVar 上的旧读取缓存失效。
    tstate.context_ver += 1


def exit(ctx):
    tstate = current_thread_state()
    if not ctx.ctx_entered:
        raise RuntimeError("Context 尚未进入")

    # 弹出 ctx,恢复进入前保存的 Context。
    tstate.context = ctx.ctx_prev
    ctx.ctx_prev = None
    ctx.ctx_entered = False
    tstate.context_ver += 1

Context.run() 在调用前 enter,结束后 exit,所以异常不会把指定 Context 留在当前线程上。普通函数调用不做 enter,也不复制 Context;函数里的 get() 仍从同一个 PyThreadState.context 开始查找。

set()reset() 也不操作 Python frame。Token 记录的是“哪个 Context 中的哪个变量原来绑定了什么”,所以跨 Context reset、拿错变量或重复使用 Token 都会报错。

2.3 多线程:每个 PyThreadState 有自己的入口#

每个活跃线程都有自己的 PyThreadState,当前 Context 指针也随之隔离。这就是两个线程可以给同一个 ContextVar 绑定不同值的原因。

Python 3.14 的 Thread.start() 根据显式 context=thread_inherit_context 选择线程的初始 Context,_bootstrap_inner() 再通过该 Context 运行线程目标。旧版本没有构造器参数时,调用方可以手动提交 copy_context().run,公开语义相同。

线程池的 worker 会长期复用自己的 PyThreadState。一次 executor.submit() 只是把函数放进队列,不会自动替 worker 进入提交方的 Context;用 copy_context().run 包装任务,才会在执行期间切换并在结束后恢复 worker 原来的 Context。

ctx_entered 还解释了另一个限制:同一个 Context 不能同时进入两个线程。每个并发工作单元必须使用自己的 copy_context() 结果。

2.4 asyncio:Task 保存,Handle 恢复#

asyncio 没有改变 contextvars 的底层存储。它在调度层给每个 Task 保存 Context,并在推进协程前进入它。

以默认 Task factory、内置 C Task 和标准事件循环为例,asyncio.create_task() 的主要路径是:

def create_task(coro, context=None):
    task = allocate_task(coro)

    # 未显式传 context 时,只在 Task 创建阶段复制一次。
    task.task_context = (
        copy_current_context()
        if context is None
        else context
    )

    # Handle 保存 task_context,等事件循环调度时再进入它。
    loop.call_soon(task_step, task, context=task.task_context)
    return task


def run_handle(handle):
    # 标准 Handle._run() 通过 Context.run() 恢复注册时的 Context。
    handle.context.run(handle.callback, *handle.args)


def task_step(task, error=None):
    if error is None:
        # C 加速路径在这里调用 PyIter_Send(task.coro, None)。
        task.coro.send(None)
    else:
        # Future 的异常或取消会通过 throw() 注入协程。
        task.coro.throw(error)
asyncio 创建 Task 时复制当前 Context,Handle 进入 Task 保存的 Context 后推进协程;Future 完成时携带同一 Context 调度唤醒回调

C 实现的 Task.__init__PyContext_CopyCurrent() 的结果保存为 task_contexttask_call_step_soon() 将它传给 loop.call_soon()。标准 Handle._run() 通过 Context.run() 进入该 Context,然后 Task 才调用 PyIter_Send() 推进协程。

Task 等待 Future 时,同一个 task_context 会跟随完成回调:

def wait_for_future(task, future):
    # Task 挂起时,把唤醒回调和同一个 task_context 一起注册。
    future.add_done_callback(
        task.wakeup,
        context=task.task_context,
    )


def schedule_future_callbacks(future):
    for callback, context in future.callbacks:
        # Future 完成后,Context 会随回调进入事件循环队列。
        loop.call_soon(callback, future, context=context)


def task_wakeup(task, future):
    # Handle 已进入 task_context,此处只需检查结果并恢复 Task。
    try:
        future.result()
    except BaseException as error:
        task_step(task, error)
    else:
        task_step(task, None)

Task 的 Context 只在创建时复制一次,之后每次调度都恢复同一个对象。因此 Task 内部的写入能跨过 await,但不会反向更新父 Task 创建后继续使用的 Context。直接 await 没有创建 Task,自然也没有复制步骤。

纯 Python _PyTaskself._contextloop.call_soon(..., context=self._context)Context.run() 完成同样的工作。eager_start 会让第一次推进发生在 Task 构造期间,但仍先进入 Task 保存的 Context,所有公开语义不变。

2.5 多进程:没有可以恢复的共享运行时#

新进程有自己的解释器状态、PyThreadState、Context 对象和地址空间。父进程的 tstate->context 指针以及 HAMT 节点对它都没有可用意义,asyncio 的 Task 集成也只存在于各自进程内。

因此 CPython 没有在 ProcessPool 边界复制 Context。启动方式为 spawn 时,worker 重新导入模块;使用 fork 时,worker 得到的是某一时刻的内存副本。两种方式都不构成后续传播。任务参数、消息头或 RPC metadata 才是进程间的稳定接口。

3. 最佳实践与使用限制#

服务端通常在请求入口绑定,在唯一的退出点恢复。下面假设追踪库已经从入站载体中验证并提取了 trace ID:

from contextvars import ContextVar
from typing import Awaitable, Callable, TypeVar

Response = TypeVar("Response")
trace_id: ContextVar[str] = ContextVar("trace_id")


async def run_request(
    incoming_trace_id: str,
    handler: Callable[[], Awaitable[Response]],
) -> Response:
    token = trace_id.set(incoming_trace_id)
    try:
        return await handler()
    finally:
        trace_id.reset(token)

3.1 在并发边界明确选择#

边界 默认行为 建议
普通调用 / 直接 await 沿用当前 Context 用 Token 限定临时写入
asyncio.create_task() 创建 Task 时复制 需要覆盖时显式传 context=
loop.call_soon() 注册回调时捕获 需要覆盖时显式传 context=
asyncio.to_thread() 每次执行复制并传播 asyncio 中卸载阻塞调用时优先使用
threading.Thread 3.14 context= 或运行时标志决定 显式传 copy_context() 或空 Context()
loop.run_in_executor() / ThreadPool 不按任务自动传播 每个任务使用新的 copy_context().run
ProcessPool / 多进程 / RPC 没有可移植的自动传播 序列化所需元数据,并在接收端重新绑定

3.2 实践准则#

  • 在模块顶层定义 ContextVar,不要在闭包或每次请求中动态创建。Context 会强引用映射中的 ContextVar 键,动态创建可能让它们长期无法回收。
  • set() 返回的 Token 与 finally 中的 reset() 配对。不要用 set(old_value) 冒充恢复,因为“原先没有绑定”和“原先绑定了某个默认值”不是同一状态。
  • Context 中只放请求范围的环境信息,例如当前 span、trace ID、租户 ID、认证主体或日志字段。业务必需参数、全局配置和大块可变状态继续显式传递。
  • 优先绑定字符串、数字、不可变记录等值。copy_context() 不会深拷贝值;若绑定 list 或 dict,多个 Context 仍可能修改同一个对象。
  • 在线程池的每次提交、Task 创建、进程任务和 RPC 边界决定一次传播策略。不要依赖线程创建时机、fork 快照或执行器内部状态。
  • 使用追踪库提供的注入和提取 API 处理跨服务 Trace Context。只传 trace ID 会丢失父 span、采样标志和厂商状态。
  • 并发测试应同时运行至少两个使用不同值的工作单元,并用 Event 或 Barrier 控制切换点。单请求测试发现不了串值问题。

3.3 使用限制#

  • Token 只属于创建它的 ContextVar 和当时的 Context,而且只能 reset 一次。
  • 同一个 Context 不能在多个线程中并发进入;退出后可以再次进入。
  • Task 默认得到创建时的快照。父子 Task 后续不会持续同步绑定。
  • 显式把同一个 context= 交给多个 Task 会让它们共享后续绑定,应只在确实需要共享时使用。
  • Context 的复制是浅层结构共享。可变值仍需自行复制或同步。
  • contextvars 不负责网络传播、进程通信、访问控制或资源生命周期。它只是让当前执行上下文中的代码能够找到同一份绑定。
  • 自定义调度器如果要支持 contextvars,必须在调度边界捕获 Context,并在回调执行期间通过 Context.run() 进入,异常路径也要正确退出。

ContextVar 负责命名,Context 保存绑定,调度器在并发边界复制或恢复。同步调用没有 Context 切换;线程各自持有 PyThreadState;asyncio Task 显式保存 Context;进程之间只能传数据。这四种执行边界,正是使用方式不同的原因。

4. 参考资料#

访问量 访客数