KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · ContextVar:async 时代的「请求级全局变量」 — keel 龙骨
问题:一个 Web 请求进来,后面几十层函数都要用 user_id(写日志、打审计、查库)。一层层传参太啰嗦;用全局变量又会在并发下把 A 用户的数据串到 B 用户头上。ContextVar 是标准答案——它像全局变量一样随处可取,却每个请求/任务各持一份,互不干扰。
问题:一个 Web 请求进来,后面几十层函数都要用
user_id(写日志、打审计、查库)。一层层传参太啰嗦;用全局变量又会在并发下把 A 用户的数据串到 B 用户头上。ContextVar 是标准答案——它像全局变量一样随处可取,却每个请求/任务各持一份,互不干扰。
一、先看它长什么样(很短)
一个典型的 request_context.py
from contextvars import ContextVar
_user_id_ctx: ContextVar[str] = ContextVar("user_id", default="")
_request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
def set_user_id(uid: str) -> None:
"""在请求入口设置当前用户;之后任何函数都能 get,不用层层传参。"""
_user_id_ctx.set(str(uid))
def get_user_id() -> str:
"""取当前用户;未设置时返回空字符串。"""
return _user_id_ctx.get()
def set_request_id(rid: str) -> None:
_request_id_ctx.set(rid)
def get_request_id() -> str:
return _request_id_ctx.get()
任何函数想拿当前用户,直接 get_user_id(),不用层层传参。日志函数打日志时自动带上——所以工程上强调「跨进程要显式传参再回填」(下游进程入口先 set_user_id(...)),就是因为 ContextVar 管不了进程边界。
二、为什么不能用全局变量?
_current_user = None # 全局变量版
def set_user_id(uid):
global _current_user
_current_user = uid
def get_user_id():
return _current_user
单线程顺序执行时没问题。但 async 并发下:
任务 A:set_user_id("alice")
任务 A:await 调模型(让出控制权)
任务 B:set_user_id("bob") ← 全局变量被覆盖!
任务 A:恢复,get_user_id() → "bob" ← A 的日志记成了 B 的用户!
await 的每次让出/恢复,都是别的任务插进来的机会。全局变量是进程级的,而你需要的是任务级的存储。
线程版同理:两个线程各处理各的请求,全局变量互踩。传统解法是 threading.local()——但 asyncio 里一个线程上跑着几百个任务,threading.local 的粒度是线程,还是串。
三、ContextVar 的隔离粒度:每个「上下文」一份独立值
import asyncio
from contextvars import ContextVar
user = ContextVar("user", default="none")
async def task(name, uid):
user.set(uid) # 只影响自己这个上下文
await asyncio.sleep(0.1) # 让出——另一个任务 set 也不影响我
print(name, "看到:", user.get())
async def main():
await asyncio.gather(
task("A", "alice"),
task("B", "bob"),
)
asyncio.run(main())
# A 看到: alice
# B 看到: bob ← 互不干扰
心智模型:每个任务(准确说每个 Context)持有这些变量的私有快照。set() 只改自己上下文里的值;get() 先查自己上下文,没有才看默认值。
三条使用规则(都是实战踩出来的):
- 模块顶层定义一次,全程序共享这个 ContextVar 对象——它是「变量名」的注册处,值存在各上下文里;
set()影响当前及之后新建的子任务;已经在跑的子任务看不见。所以要在「任务的最开始」set(HTTP 进程在鉴权中间件,后台任务在 worker 函数开头);- 跨进程必失效——进程切了,上下文没了。必须显式传参(这就是「投递函数带
user_id=.../request_id=...参数 + 消费函数开头set_user_id/set_request_id」的原因)。
四、进阶:不 set 也能隔离——set 返回的 Token 与 copy_context
还有两个高级用法,框架代码里常见:
token = user.set("alice") # set 返回一个 Token
...
user.reset(token) # 恢复到 set 之前的值(中间件/finally 里用)
# 或者:复制当前上下文并在副本里运行(隔离更彻底)
ctx = contextvars.copy_context()
ctx.run(handle_request) # 在副本上下文里跑,外面的 set 不影响它
reset(token) 的典型场景:中间件包裹请求——请求开始 set,请求结束 reset,保证上下文不被本次请求污染(比依赖「下次 set 覆盖」严谨)。
五、可迁移的套路
- 「请求作用域」的数据(用户、租户、request_id、trace_id)→ ContextVar,别传参钻透十几层,也别用全局;
- set 的位置 = 上下文边界处:HTTP 进程在鉴权中间件/依赖注入,后台任务进程在任务函数入口;
- 跨进程/跨队列边界必须显式序列化传递,消费端第一件事回填 ContextVar——「投递函数带
user_id=.../request_id=...参数 + 消费函数开头 set」就是完整范本。