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() 先查自己上下文,没有才看默认值。

三条使用规则(都是实战踩出来的):

  1. 模块顶层定义一次,全程序共享这个 ContextVar 对象——它是「变量名」的注册处,值存在各上下文里;
  2. set() 影响当前及之后新建的子任务;已经在跑的子任务看不见。所以要在「任务的最开始」set(HTTP 进程在鉴权中间件,后台任务在 worker 函数开头);
  3. 跨进程必失效——进程切了,上下文没了。必须显式传参(这就是「投递函数带 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 覆盖」严谨)。

五、可迁移的套路

  1. 「请求作用域」的数据(用户、租户、request_id、trace_id)→ ContextVar,别传参钻透十几层,也别用全局;
  2. set 的位置 = 上下文边界处:HTTP 进程在鉴权中间件/依赖注入,后台任务进程在任务函数入口;
  3. 跨进程/跨队列边界必须显式序列化传递,消费端第一件事回填 ContextVar——「投递函数带 user_id=.../request_id=... 参数 + 消费函数开头 set」就是完整范本。

↓下一步:01 章 · 线程安全单例与 frozen dataclass

进入 keel 阅读