三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Python contextvars 实战:在 asyncio 里安全传递请求上下文,告别全局变量

Python contextvars 实战:在 asyncio 里安全传递请求上下文,告别全局变量

Python contextvars 实战:在 asyncio 里安全传递请求上下文,告别全局变量

写异步服务时,几乎一定会碰到这个需求:每个请求进来带一个request_id,后面记日志、调下游、报错时都想把它带上,方便串起一整条链路。但你又不想在每个函数签名里都塞一个request_id参数,一路透传下去——那太丑,而且中间某个第三方函数根本不给你留参数位。

很多人第一反应是「用个全局变量存起来」。在多线程或 asyncio 里,这会直接串号:A 请求的日志打上了 B 请求的 id。这篇讲清为什么全局变量和ThreadLocal都不行,以及标准库contextvars怎么优雅解决它。

朴素写法:全局变量在并发下串号

先看会出事的版本:

importasyncio current_request_id=None# 全局变量存当前请求 iddeflog(msg):print(f"[{current_request_id}]{msg}")asyncdefhandle(req_id):globalcurrent_request_id current_request_id=req_idawaitasyncio.sleep(0.1)# 模拟 IO,期间事件循环去处理别的请求log("处理完成")# 期望打印自己的 req_idasyncdefmain():# 两个请求「并发」跑awaitasyncio.gather(handle("A"),handle("B"))asyncio.run(main())

你期望输出[A] 处理完成[B] 处理完成,实际却是:

[B] 处理完成 [B] 处理完成

原因:handle("A")执行到await时把控制权交回事件循环,事件循环启动handle("B"),current_request_id被覆盖成"B"。等 A 醒来时,全局变量早就是"B"了。单线程的 asyncio 一样会串号——因为并发的多个协程共享同一个全局变量。

为什么 ThreadLocal 也救不了

有人会说:那用threading.local()?它能隔离不同线程,但 asyncio 是单线程内多协程——所有协程跑在同一个线程里,共享同一份 thread-local。所以ThreadLocal在纯异步场景下和普通全局变量没区别,照样串。

我们需要的是「跟着协程走」的隔离,而不是「跟着线程走」。这正是contextvars的定位。

正确写法:用 contextvars 隔离每个协程

contextvars.ContextVar是标准库(Python 3.7+)专门为异步设计的上下文变量。关键特性:每个 asyncio Task 有自己独立的上下文副本,互不干扰

importasyncioimportcontextvars# 定义一个上下文变量,default 是没设置时的兜底值request_id=contextvars.ContextVar("request_id",default="-")deflog(msg):print(f"[{request_id.get()}]{msg}")# .get() 读当前协程的值asyncdefhandle(req_id):request_id.set(req_id)# .set() 只影响当前 Task 的上下文awaitasyncio.sleep(0.1)log("处理完成")asyncdefmain():awaitasyncio.gather(handle("A"),handle("B"))asyncio.run(main())

这回输出如预期:

[A] 处理完成 [B] 处理完成

为什么就对了?asyncio.gather会用asyncio.ensure_future把每个协程包成独立的 Task,而创建 Task 时会自动复制一份当前上下文。A 和 B 各自set的值落在各自的上下文副本里,井水不犯河水。

进阶:set 的返回值可以精确还原

ContextVar.set()会返回一个Token,用它能把变量恢复到 set 之前的状态。这在「临时改一下上下文,用完还原」的场景很有用,比如中间件里嵌套调用:

defwith_id(new_id):token=request_id.set(new_id)# 记住改之前的状态try:log("干活")finally:request_id.reset(token)# 精确还原,不是简单置空

为什么不直接request_id.set("-")还原?因为如果外层本来就有值,置成"-"会把外层的值抹掉。reset(token)恢复的是「这次 set 之前」的确切值,嵌套调用也不会乱。

一个真实的坑:run_in_executor 里上下文会丢

contextvars跟着 Task 走,但一旦你把活儿甩到线程池(比如用loop.run_in_executor跑同步阻塞代码),新线程默认不会继承当前上下文:

importasyncio,contextvars request_id=contextvars.ContextVar("request_id",default="-")defblocking_work():# 在线程池里执行,读不到外面 set 的值returnf"线程里看到:{request_id.get()}"asyncdefhandle(req_id):request_id.set(req_id)loop=asyncio.get_running_loop()# 直接扔进 executor,上下文丢失,打印 "-"result=awaitloop.run_in_executor(None,blocking_work)print(result)asyncio.run(handle("A"))# 输出:线程里看到:-

修法:用contextvars.copy_context()抓当前上下文,再在目标线程里ctx.run(...)执行,把上下文「背」过去:

asyncdefhandle(req_id):request_id.set(req_id)loop=asyncio.get_running_loop()ctx=contextvars.copy_context()# 抓当前上下文快照# functools.partial 也行,这里用 lambda 让 ctx.run 携带上下文执行result=awaitloop.run_in_executor(None,lambda:ctx.run(blocking_work))print(result)# 输出:线程里看到:A

记住这条界线:同一事件循环里的 await 链会自动带上下文,跨到线程池就得手动 copy_context 背过去。

小结

  • 全局变量在 asyncio 里必串号:协程在await处交出控制权,共享的全局变量被别的协程覆盖。
  • ThreadLocal 救不了纯异步:asyncio 是单线程多协程,所有协程共享同一份 thread-local。
  • contextvars 按 Task 隔离:gather/create_task创建 Task 时自动复制上下文副本,各协程set的值互不影响。
  • set()返回 Token,配合reset(token)能精确还原到 set 之前,嵌套场景不丢外层值。
  • 跨线程池会丢上下文:run_in_executor前用copy_context()抓快照,ctx.run(fn)背过去。

一句话记忆:想在 async 里透传请求上下文,别用全局变量、别用 ThreadLocal,用contextvars.ContextVar——它跟着协程走,跨线程记得 copy_context。

← 返回列表