近日,Python 开发者社区爆出一则令人警惕的技术新闻:在 Python 3.12 中,同时使用 asyncio.TaskGroupasyncio.to_thread()、嵌套任务取消以及 contextvars 时,程序可能发生间歇性死锁。该问题由多位用户在多线程/异步混合场景中报告,并已引起 CPython 核心团队的关注与调查。

诡异的“假死”:并非每次都能复现

据最早在 GitHub 上提交 issue 的开发者描述,他在一个使用 asyncio.run() 驱动的异步应用中,通过 TaskGroup 管理多个并发任务。其中部分任务通过 asyncio.to_thread() 将阻塞操作(如文件 I/O、数据库查询)委托给线程池执行,同时使用了 contextvars 传递请求级上下文(例如用户 ID 或追踪 ID)。当外部代码发起对 TaskGroup 的取消操作(例如超时或用户中断),再重新创建新的任务组时,程序会随机陷入死锁——所有协程停止响应,但进程并未崩溃,也没有异常抛出。

进一步的测试表明,死锁仅在以下四个条件同时满足时出现: 1. 任务被包含在 asyncio.TaskGroup 中; 2. 至少有一个任务调用了 asyncio.to_thread(); 3. 在 to_thread() 执行期间,任务组被取消(例如通过 asyncio.timeout 或手动调用 task.cancel()); 4. 任务内部使用了 contextvars 来设置或复制上下文。

更令人困扰的是,该问题并非 100% 复现。在相同代码下,有时运行数万次不触发,有时却连续出现。这种间歇性特征极大增加了调试难度。

技术剖析:锁竞争与上下文传播的“完美风暴”

CPython 核心开发者在对代码进行审查后,初步定位了问题所在的三个关键环节。

1. 取消与 to_thread() 的竞争条件

asyncio.to_thread() 的实现本质上是将协程封装成一个 Future,然后在线程池中执行回调。当任务组被取消时,TaskGroup 会尝试取消其内部所有尚未完成的任务。对于通过 to_thread() 创建的任务,取消操作会设置一个 CancelledError 标志,但不一定会立即终止线程中的工作——线程内的阻塞操作可能仍在执行。

问题发生在线程完成工作并准备将结果传回事件循环的那一刻:此时,任务组内部的取消状态已经改变,但事件循环可能在处理“取消完成”与“线程结果回调”之间出现竞态。更具体地说,TaskGroup 在取消后尝试重新安排内部任务,而 to_thread() 的回调依然持有对任务组内部锁的引用,双方形成死锁等待。

2. 嵌套取消导致状态不一致

第二个关键点是“嵌套取消”。当 TaskGroup 自身被取消,且组内某些任务已经启动了自己的子任务组(例如通过 asyncio.gather 或另一个 TaskGroup),取消会沿父层向子层传播。在 Python 3.12 中,TaskGroup 引入了更严格的取消语义,要求子任务必须响应父组的状态。但在 to_thread() 场景下,这种传播路径可能跨越线程边界,导致锁的获取顺序出现循环依赖。

3. ContextVars 的隐式锁加剧问题

contextvars 本是为避免全局变量而设计的线程安全上下文机制,但它的复制与传播涉及内部锁。当 to_thread() 将当前上下文复制到工作线程时,会触发 ContextVar_copy_context() 方法。如果恰好在取消发生的瞬间执行此操作,且事件循环线程与工作线程同时持有部分锁,就很容易陷入经典的 ABA 问题锁顺序倒置

据 Python 性能核心开发者考证,这种死锁的典型模式是: - 线程 A(事件循环)持有 TaskGroup._lock,等待 ContextVar._context_lock; - 线程 B(工作线程)持有 ContextVar._context_lock,等待 TaskGroup._lock

影响范围与用户应对

受影响的版本为 Python 3.12.0 ~ 3.12.2(最新稳定版也包含此 bug),Python 3.11 及更早版本由于 TaskGroup 的取消实现不同,未报告此类问题。对于依赖异步 I/O 的高并发服务(如 web 框架、API 网关、爬虫),该死锁可能导致连接池耗尽、任务积压,甚至整个服务不可用。

目前,官方尚未发布补丁,但 CPython 核心团队已在 bpo-47654 中展开讨论。一个临时的缓解方案是避免在 TaskGroup 中同时使用 to_thread() 和 contextvars,或者将 to_thread() 调用封装到单独的 asyncio.create_task() 中(不使用 TaskGroup 管理)。此外,可通过设置 contextvars.copy_context() 后手动传递上下文,绕过内部锁冲突。

行业反思:新特性组合的风险

此次事件再次提醒开发者:异步框架的多个新特性在独立使用时表现良好,一旦组合,底层锁机制可能产生难以预料的交互。Python 3.12 的 TaskGroup 本意是提供结构化并发,但取消、线程间通信与上下文传播的复杂度远超预期。

对于依赖异步特性的生产环境,专家建议在 Python 官方发布修复前,暂时锁定 TaskGroup 的使用范围,并对关键路径添加超时及重试逻辑。毕竟,一次间歇性死锁,就可能让线上服务陷入无尽的“等待”。

后续进展,我们将持续关注。