一次请求经过网关、订单服务、库存服务和数据库时,链路追踪系统需要用 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。
本文先从公开 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) 按以下顺序取值:
- 当前 Context 中的绑定;
- 本次
get(default)传入的默认值; - 创建
ContextVar时给出的默认值; - 都不存在时抛出
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: MISSINGfinally 保证正常返回、异常和取消都能恢复进入请求前的绑定。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 的写入没有回传父 Taskdirect() 的写入立即成为父协程的当前值,因为两者在同一个 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 在 PyThreadState 和 pycore_context.h 中定义了这些对象关系:
PyThreadState.context指向当前 Context,初始值可以是NULL;context_ver在 Context 进入和退出时递增。PyContext.ctx_prev保存进入前的 Context,ctx_vars指向不可变的PyHamtObject映射,ctx_entered防止同一个 Context 被重复进入。PyContextVar保存名称、默认值和哈希值。普通 GIL 构建还带有按线程状态 ID 和 Context 版本校验的读取缓存。PyContextToken保存创建它的 Context、变量、旧值和使用状态。tok_oldval == NULL对应公开 API 的Token.MISSING。
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_FOUNDget() 不会为了读取默认值创建空 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 += 1Context.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)C 实现的 Task.__init__ 把 PyContext_CopyCurrent() 的结果保存为 task_context;task_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 _PyTask 用 self._context、loop.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. 参考资料#
- Python 3.14 文档:
contextvars- Context Variables - Python 3.14 文档:Coroutines and Tasks
- Python 3.14 文档:Event Loop
- Python 3.14 文档:
threading- Thread-based parallelism - Python 3.14 文档:
multiprocessing- Process-based parallelism - PEP 567 - Context Variables
- W3C Trace Context
- CPython v3.14.2 源码树
- CPython v3.14.2:
Python/context.c - CPython v3.14.2:
Python/hamt.c - CPython v3.14.2:
Modules/_asynciomodule.c