《Python高级编程》6.2 任务、Future 与取消语义

从 CPython 源码出发讲清 Task 与 Future 的关系:Future 的三态机、cancel() 把 CancelledError 注入到哪个挂起点、cancelling() 计数与 uncancel() 的语义、shield 的保护边界,并附取消时序实测。

本节目标:从 CPython 源码看清 Task / Future 的状态机与 cancel() 的注入机制,掌握 cancelling()、uncancel()、shield 的精确语义。
适用版本:Python 3.12+(实测 3.14.6;cancelling() / uncancel() 为 3.11+)

6.2 任务、Future 与取消语义

asyncio 完全指南 给过一张 Task vs 裸协程的对照表,高级异步一篇 则断言 asyncio「取消机制脆弱」。本节不重复这些结论,而是把 cancel() 拆到字节级:它到底改了什么字段、异常在哪一行被抛进协程、为什么 cancel() 返回后 task.done() 还是 False。

6.2.1 Task 就是 Future 的子类

很多人以为 Task 和 Future 是两种东西,其实 Task 只是「会自己推进的 Future」。在本机打印 MRO:

import asyncio
from asyncio import Task, Future

print("Task MRO:")
for c in Task.__mro__:
    print("   ", c.__module__ + "." + c.__name__)
print("issubclass(Task, Future) =", issubclass(Task, Future))

实测输出:

Task MRO:
    _asyncio.Task
    _asyncio.Future
    builtins.object
issubclass(Task, Future) = True

注意 Task 的实际类型是 _asyncio.Task(C 加速版),基类是 _asyncio.Future,两者都不在 asyncio 命名空间里,而是来自 C 扩展模块 _asyncio。纯 Python 版本在 asyncio.tasks.Task(本机 asyncio.Task is asyncio.tasks._PyTask 为 False,说明 C 版已启用)。

这个继承关系决定了三件事:

  • Task 拥有 Future 的全部 API:result()、exception()、add_done_callback()、cancel()。
  • Task 可以被 await,因为 Future.__await__ 已经实现了「挂起直到完成」。
  • Task 比 Future 多出来的,只有「驱动协程」这一步——由 __step 完成。

6.2.2 Future 的三态机

Future 内部用一个字符串字段 _state 表示状态,只有三个取值:PENDING、FINISHED、CANCELLED。实测状态迁移:

import asyncio

async def main():
    loop = asyncio.get_running_loop()
    f = loop.create_future()
    print("新建:", f._state)
    f.set_result(42)
    print("set_result 后:", f._state, "| result:", f.result())
    try:
        f.set_result(1)              # 重复设置
    except Exception as e:
        print("重复 set_result ->", type(e).__name__, e)
    f2 = loop.create_future()
    f2.cancel()
    print("cancel 后:", f2._state, "| cancelled():", f2.cancelled())
    try:
        f2.result()
    except asyncio.CancelledError:
        print("cancelled future 的 result() -> CancelledError")

asyncio.run(main())

实测输出:

新建: PENDING
set_result 后: FINISHED | result: 42
重复 set_result -> InvalidStateError invalid state
cancel 后: CANCELLED | cancelled(): True
cancelled future 的 result() -> CancelledError

三点结论:

操作_state 迁移后续行为
set_result / set_exceptionPENDING → FINISHED再次设置抛 InvalidStateError
cancel()PENDING → CANCELLEDresult() 抛 CancelledError
已完成的 Future 再 cancel()不变返回 False

状态是单向的:一旦离开 PENDING 就再也回不去。done() 等价于 _state != 'PENDING',所以 FINISHED 和 CANCELLED 都算「完成」。

6.2.3 cancel() 把异常注入到哪里

Task.cancel() 和 Future.cancel() 是两个不同的实现。Future.cancel() 只是把状态改成 CANCELLED;Task.cancel() 要复杂得多。以下是纯 Python 版 _PyTask.cancel 的核心(asyncio/tasks.py,与 _asynciomodule.c 逐行对应):

def cancel(self, msg=None):
    self._log_traceback = False
    if self.done():
        return False
    self._num_cancels_requested += 1          # ← cancelling() 计数器
    if self._fut_waiter is not None:
        if self._fut_waiter.cancel(msg=msg):
            return True                        # 取消成功传递给内层 Future
    self._must_cancel = True                    # ← 否则记一个「待注入」标志
    self._cancel_message = msg
    return True

关键在 _fut_waiter 和 _must_cancel 这两个字段:

  • _fut_waiter:当前协程正 await 的那个 Future。若存在,cancel() 会顺着链条往下取消——递归地把取消请求传给内层。
  • _must_cancel:若协程当前不在等 Future(比如刚被调度、还没跑到 await),就设这个标志,等下一次 __step 时再注入。

而 __step 的入口负责真正把异常抛进协程:

def __step(self, exc=None):
    if self.done():
        raise InvalidStateError(...)
    if self._must_cancel:                       # 上一轮设的待注入标志
        if not isinstance(exc, CancelledError):
            exc = self._make_cancelled_error()  # 换成一个 CancelledError
        self._must_cancel = False
    self._fut_waiter = None
    ...
    # 内部最终调用:
    #   result = coro.send(None)    # 正常推进
    #   result = coro.throw(exc)    # 把 CancelledError 抛进挂起点

所以「取消」的本质是:把一个 CancelledError 通过 coro.throw() 扔进协程上次 await 的位置。协程的 try / except / finally 都能拦到它——这正是「取消是协作式」的根源:任务可以选择捕获、清理,甚至拒绝取消。

6.2.4 实测:取消到底在哪一刻生效

cancel() 的注释里有一句关键话:「调用后立刻 cancelled() 不会返回 True」。实测时序:

import asyncio

T0 = None
def now():
    return round(asyncio.get_running_loop().time() - T0, 4)

async def worker():
    try:
        print(f"{now()}  worker: 进入 sleep(1)")
        await asyncio.sleep(1)                 # ← 取消注入点
    except asyncio.CancelledError:
        print(f"{now()}  worker: 捕获 CancelledError")
        raise                                  # 规范:继续向上传播
    finally:
        print(f"{now()}  worker: finally 清理")

async def main():
    global T0
    T0 = asyncio.get_running_loop().time()
    t = asyncio.create_task(worker())
    await asyncio.sleep(0.1)
    print(f"{now()}  main: 调用 t.cancel()")
    t.cancel()
    print(f"{now()}  main: cancel() 返回,此刻 t.done()={t.done()}")
    try:
        await t
    except asyncio.CancelledError:
        print(f"{now()}  main: await t 收到 CancelledError")
    print(f"{now()}  main: 最终 _state={t._state} cancelled={t.cancelled()}")

asyncio.run(main())

实测输出:

0.0    worker: 进入 sleep(1)
0.1024 main: 调用 t.cancel()
0.1037 main: cancel() 返回,此刻 t.done()=False
0.1038 worker: 捕获 CancelledError
0.1038 worker: finally 清理
0.104  main: await t 收到 CancelledError
0.104  main: 最终 _state=CANCELLED cancelled=True

把时间轴拆开看:

  1. cancel() 在 0.1024 被调用,0.1037 就返回了——它不做任何等待,只是改了字段。
  2. 返回瞬间 t.done() 仍是 False:异常还没注入。
  3. 0.1038,worker 在 await asyncio.sleep(1) 处被 throw 了 CancelledError,立刻跳进 except 和 finally。
  4. 0.104,main 的 await t 才收到 CancelledError。

一句话:cancel() 是「请求」,异常注入发生在下一个事件循环迭代。这也是为什么 cancel() 之后必须 await 任务(或用 TaskGroup),否则清理逻辑可能还没跑完程序就退出了。

6.2.5 shield:保护边界在哪里

asyncio.shield() 用来「让外层取消不了内层」。实测:

import asyncio

async def inner():
    try:
        await asyncio.sleep(0.3)
        print("inner: 完整跑完(被 shield 保护)")
        return "ok"
    except asyncio.CancelledError:
        print("inner: 被取消(不该出现)")
        raise

async def main_shield():
    task = asyncio.create_task(inner())
    try:
        await asyncio.wait_for(asyncio.shield(task), timeout=0.1)
    except TimeoutError:
        print("外层: 超时,但 task 仍在跑,done()=", task.done())
    res = await task          # 外层超时不影响内层
    print("外层: 之后拿到结果 =", res)

asyncio.run(main_shield())

实测输出:

外层: 超时,但 task 仍在跑,done()= False
inner: 完整跑完(被 shield 保护)
外层: 之后拿到结果 = ok

shield 的语义要精确理解:它不阻止取消,只是拦在外层和 task 之间。外层取消 shield 包装出来的那个 Future 时,task 本身不受影响;但 shield 的文档明确写着——外层被取消后,被保护的任务最终仍需由你负责,否则会变成孤儿任务。所以 shield 只适合「关键收尾不能被打断」,不适合当万能保险。

6.2.6 uncancel():撤销一次取消请求

Task.cancel() 每次调用都会把 _num_cancels_requested 加一。3.11 起新增两个配套 API:cancelling() 读这个计数,uncancel() 把它减一。实测:

import asyncio

async def child():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("child: cancelling() =", asyncio.current_task().cancelling())
        asyncio.current_task().uncancel()      # 撤销一次取消请求
        print("child: uncancel 后 cancelling() =", asyncio.current_task().cancelling())
        return "cleaned"                        # 正常返回,不重新抛出

async def main():
    t = asyncio.create_task(child())
    await asyncio.sleep(0.05)
    t.cancel()
    res = await t
    print("main: child 正常返回 =", res, "| cancelled() =", t.cancelled())

asyncio.run(main())

实测输出:

child: cancelling() = 1
child: uncancel 后 cancelling() = 0
main: child 正常返回 = cleaned | cancelled() = False

这套计数器的价值在于区分「真取消」和「取消被吞掉」。TaskGroup、asyncio.timeout 等结构化 API 内部都会先看 cancelling() 计数:如果子任务捕获了 CancelledError 却没 uncancel(),框架就知道它在偷偷吞取消,会重新抛出。手写「清理后继续」的代码时,正确姿势就是先 uncancel() 再 return,而不是裸 except CancelledError: pass。

小结

  • Task 是 Future 的子类(C 版为 _asyncio.Task → _asyncio.Future),多出来的只有「驱动协程」的 __step。
  • Future 状态是单向三态机:PENDING → FINISHED / CANCELLED,重复 set_result 抛 InvalidStateError。
  • Task.cancel() 只是改字段:有 _fut_waiter 就向下传递取消,否则设 _must_cancel;异常由下一次 __step 用 coro.throw() 注入。
  • 取消是协作式的:cancel() 返回后任务仍 PENDING,异常在下一次循环迭代才到达挂起点。
  • shield 只拦截外层取消,不负责被保护任务的收尾;uncancel() 用于「清理后拒绝取消」,配合 cancelling() 计数区分真取消与吞取消。

理解了单任务的取消,下一节看多个任务怎么被组织成一个「结构化」的整体——TaskGroup、anyio 与异常组传播。

阅读导航:上一节:事件循环的实现与调度 · 下一节:结构化并发与调试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时