登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python contextvars 为什么能隔离并发请求上下文

来源:17golang原创

时间:2026-10-09 21:13:34 202浏览 收藏

contextvars 能隔离并发请求,不是因为每个请求创建了不同的 ContextVar 对象,而是因为每个 asyncio.Task 默认在创建时取得当前 Context 的副本。相同的 ContextVar 会在不同 Context 中对应不同值,协程恢复执行时又会回到所属 Task 的 Context,所以请求 A 设置的 request_id 不会覆盖请求 B 的绑定。

要点速览
  • ContextVar 是上下文键,值保存在当前 Context 中。
  • asyncio.create_task() 默认复制创建点的当前 Context,此后各 Task 分别修改自己的副本。
  • set() 返回的 Token 应在 finally 中交给 reset(),防止同一 Task 后续代码继承脏值。
  • 复制是浅复制;如果 ContextVar 保存可变容器,容器内部仍可能被多个 Task 共同修改。

先分清 ContextVar 和它保存的值

一个常见误解是“每个请求都有一份 ContextVar”。实际更接近一张按执行上下文切换的映射:模块级只声明一个 ContextVar,当前 Context 保存这个变量在本上下文中的绑定。get() 查询当前 Context,set() 也只修改当前 Context。

因此,ContextVar 适合放请求编号、租户编号、追踪标识和当前语言这类需要被深层调用读取、又不想层层传参的轻量状态。官方建议把 ContextVar 创建在模块顶层,避免 Context 对闭包中的变量保持强引用。官方入口:https://docs.python.org/3/library/contextvars.html。

最小示例:两个请求读取各自的 request_id

下面的例子只保留三件事:进入请求时绑定编号,业务函数跨一次 await 后读取编号,离开请求时恢复旧值。即使两个处理器交错执行,它们读取的仍是各自 Task 中的绑定。

import asyncio
from contextvars import ContextVar

# 模块级只声明一个变量;每个 Context 保存各自的绑定
request_id = ContextVar("request_id", default="-")


async def load_profile() -> str:
    # 模拟一次让出执行权;恢复后仍从当前 Task 的 Context 读取
    await asyncio.sleep(0)
    return f"profile:{request_id.get()}"


async def handle_request(rid: str) -> tuple[str, str]:
    # Token 记录设置前的旧绑定,供 finally 精确恢复
    token = request_id.set(rid)
    try:
        first = request_id.get()
        second = await load_profile()
        return first, second
    finally:
        # 无论正常返回、报错还是任务取消,都恢复进入前的上下文
        request_id.reset(token)


async def main() -> None:
    # gather 会并发调度两个协程,每个 Task 都维护自己的 Context
    results = await asyncio.gather(
        handle_request("req-A"),
        handle_request("req-B"),
    )
    print(results)


# 运行异步入口;预期两组结果分别保持 req-A 与 req-B
asyncio.run(main())

这里最关键的不是 sleep(0),而是它让两个 Task 有机会交错运行。Task 切换时,解释器和事件循环会让协程在它自己的 Context 中继续执行,因此 load_profile() 无需接收 rid 参数,也能读到正确的请求编号。

隔离发生在 Task 持有的 Context 副本里

Python 官方文档说明,asyncio 原生支持上下文变量;创建 Task 时如果没有显式提供 context,会复制当前 Context。PEP 567 进一步解释,每次推进协程都在该 Task 保存的 Context 中进行。于是父任务在创建点提供初始值,子 Task 可以继承它,但子 Task 后续的 set() 不会改写兄弟 Task 的绑定。

父任务创建两个 asyncio Task 后分别持有请求上下文并供日志和数据库调用读取的静态结构图
图1:Task 与 Context 静态关系说明图;它展示创建边界、任务上下文和下游读取之间的绑定,不是运行截图或执行时序图。

快照时机也很重要:复制发生在 Task 创建时,而不是调用 async def 得到协程对象时。若先创建协程对象、修改 ContextVar,之后才调用 create_task(),新 Task 继承的是后一个创建点的上下文。需要完全控制时,可以为支持该参数的 create_task() 显式传入 context。

用 Token 收紧一次请求的上下文边界

隔离并不等于可以不清理。Web 服务器、消费者或后台任务可能在同一个 Task 中继续执行后续逻辑;如果进入请求时只调用 set(),离开时不恢复,后续代码就可能继续看到旧请求编号。最稳妥的写法是保存 Token,并在 finally 中重置。

请求入口通过 ContextVar 和 Token 建立请求作用域并在退出时恢复旧绑定的静态结构图
图2:Token 请求边界说明图;重点是旧值、当前 Context 与 reset 的静态关系,不代表编号步骤或真实运行结果。

reset(token) 会恢复产生该 Token 之前的绑定,而不是简单写回默认值。这一点对嵌套调用尤其重要:内层可以临时覆盖 request_id,退出后仍回到外层请求的值。Token 只能用于创建它的 ContextVar 和对应 Context,也不能重复重置。

它和全局变量、threading.local 有什么区别

方案并发请求中的作用域适用判断
模块全局变量所有请求共享同一个可写值只适合常量或明确受同步保护的共享状态
threading.local()按操作系统线程隔离线程内顺序任务可用,但同一线程上的多个异步 Task 仍可能串值
显式函数参数由调用链直接传递依赖最清楚,短调用链优先采用
ContextVar按当前 Context 读取,asyncio Task 维护自己的 Context适合日志、追踪、租户等横切状态

ContextVar 不是依赖注入的替代品。业务核心数据仍应显式传参;只有大量中间层不关心、但日志或基础设施必须读取的请求级状态,才值得放入上下文。

三个容易误判的边界

直接 await 不会自动创建新 Context

await child() 通常仍在同一个 Task 中执行,所以子协程对 ContextVar 的修改会留在当前 Context,除非使用 Token 恢复。只有创建新 Task,才会形成由该 Task 维护的上下文副本。

浅复制不等于对象深度隔离

copy_context() 的复杂度为 O(1),得到的是浅复制。若变量值是字典、列表或可变业务对象,多个 Context 可能仍引用同一对象;其中一个 Task 原地修改对象内容,其他 Task 可能观察到变化。更安全的做法是保存字符串、整数、不可变元组,或在更新时创建新对象再调用 set()。

切换到线程时要看具体 API

Context 先解决异步 Task 的作用域,并不意味着所有线程池接口都会自动继承当前上下文。手动提交到执行器时,可以使用 copy_context() 取得当前快照,再通过 ctx.run() 执行目标函数;不要把线程传播行为建立在猜测上。

接入请求上下文时的检查清单

  • ContextVar 是否在模块顶层声明,而不是每次请求临时创建?
  • 保存的是否是轻量、最好不可变的标识,而不是连接、会话或巨大对象?
  • 每次 set() 是否保留 Token,并在 finally 中执行 reset()?
  • 需要隔离的工作是否真的被创建为独立 Task,而不是仍在同一 Task 中直接 await?
  • 后台任务是否应该继承当前请求 Context,还是应传入一个清理后的显式 Context?
  • 线程池、回调和第三方框架的上下文传播规则是否来自官方文档,而不是经验假设?

常见问题

ContextVar 会不会在 await 之后丢失?

正常的 asyncio Task 中不会。Task 恢复协程时会在它维护的 Context 中继续执行,因此跨 await 仍能读取原绑定。

两个请求使用同一个 ContextVar 对象安全吗?

可以。隔离的正是“同一个变量在不同 Context 中的值”。但值本身若是共享可变对象,仍要避免原地修改。

为什么子协程改值后父协程也看到了?

很可能是通过直接 await 调用,二者仍处于同一个 Task 和 Context。若确实需要独立作用域,应创建新 Task,或在子协程中用 Token 恢复。

请求结束后一定要 reset 吗?

建议这样做。reset 能处理嵌套绑定,并在异常、取消和复用 Task 的情况下恢复进入请求前的状态,是比手工写默认值更准确的清理方式。

把 contextvars 理解成“按当前 Context 查询的变量映射”,再把 asyncio.Task 理解成“持有并恢复自己 Context 的调度单元”,隔离机制就清楚了:变量对象可以共享,绑定由 Context 区分;Task 可以交错执行,恢复时仍回到自己的上下文。最后用 Token 把绑定限制在请求生命周期内,就能让日志链路既方便读取,又不污染后续任务。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>