跳转至

调用了 async 函数,为什么什么都没发生?

我们把课程目录 adapter 改成了 async def,然后像普通函数一样调用:

async def load_catalog() -> list[str]:
    print("开始读取")
    return ["Python", "FastAPI"]

result = load_catalog()
print(result)

“开始读取”没有打印,result 也不是列表,而是 coroutine object。async 函数的 调用不是一次已经开始的后台任务,更像创建了一份尚未被执行的异步计算描述。

上一章留下了两个只给了一句话的名字:等待发生时决定推进哪个任务的调度者叫 event loop;可以中途暂停的函数叫 coroutine。当时只需要它们各自的一条性质, 就能完成“该不该用 async”的判断。本章把这两个名字连同第三个角色 Task 一起 打开:各自负责什么,以及在什么条件下才会真正运行。

运行环境

Python 3.12、asyncio 标准库。示例关注稳定的执行关系,不依赖警告文本或精确 耗时。

1. coroutine 必须被驱动

上一章只说 coroutine 是“可以中途暂停的函数”,现在补上它的另一半性质:它还是 惰性的。async def 定义 coroutine function;调用它产生 coroutine object。要让 函数体执行,必须在运行中的 event loop 里 await 它,或把它调度为 Task:

import asyncio

async def load_catalog() -> list[str]:
    print("开始读取")
    await asyncio.sleep(0.05)
    return ["Python", "FastAPI"]

async def main() -> None:
    result = await load_catalog()
    print(result)

asyncio.run(main())

await 一方面驱动目标 coroutine;另一方面,当目标等待尚未完成的 I/O 时,挂起 当前 Task,把执行机会交回 event loop。等待完成后,当前 Task 恢复,并取得结果。 如果目标马上返回、期间没有真正挂起,event loop 也没有可利用的等待区间。

2. event loop 调度 Task,不是给每个 Task 一条线程

上一章对 event loop 只给了一句“等待时由它决定推进哪个任务”,现在可以补上它靠 什么做到。event loop 维护就绪、等待和完成的 Task。一个 Task 执行到可挂起的 await 后,loop 才能选择另一个就绪 Task。在典型单线程 event loop 中,同一时刻 只有一个 Task 执行 Python 代码;多个 I/O 等待可以交错推进,所以表现为并发。

这是一种协作式调度:任务要走到可以让出的地方。若某段代码长时间不返回也不 await,其他 Task 不会被 event loop 强行抢占。这个后果会在阻塞边界章节展开。

3. 连续 await 仍然是串行

async def serial() -> tuple[str, str]:
    profile = await load("profile", 0.1)
    catalog = await load("catalog", 0.1)
    return profile, catalog

第二次调用在第一次完成后才开始。要让两个已确认独立的等待重叠,可以创建 Task:

async def concurrent() -> tuple[str, str]:
    profile_task = asyncio.create_task(load("profile", 0.1))
    catalog_task = asyncio.create_task(load("catalog", 0.1))
    profile = await profile_task
    catalog = await catalog_task
    return profile, catalog

create_task 把 coroutine 包装为 Task 并安排运行。创建之后仍必须收回结果。 只创建一个 Task 后立刻 await 它,并不会产生有意义的并发;并发需要多个工作在 同一时间段共存。

4. 每个 Task 都要有人负责

以下写法看似“更异步”,其实丢失了所有权:

async def start_and_forget() -> None:
    asyncio.create_task(load("catalog", 0.1))

函数返回时,谁等待这个 Task?谁接收结果或异常?它是否允许超出本次请求继续? 若这些问题没有答案,Task 就逃逸了。当前课程中的 service operation 不是后台 任务系统:创建的 Task 应在操作返回前被等待,结果或异常由明确调用方接收。

可以给每个 Task 写一张最小“身份证”:创建者、等待者、结果去向、完成条件。 这比只数代码里有多少个 create_task 更能发现控制流问题。

边界与常见误区

  • coroutine object 不是结果,也不是已经运行的 Task。
  • await a(); await b() 通常是先完成 a 再开始 b,不因出现两个 await 就并发。
  • Task 并不等于线程;event loop 在挂起点之间协作切换。
  • 创建 Task 后丢弃引用不是可靠的后台执行方案。
  • 本章只建立所有权基础,不展开 TaskGroup 的异常和取消传播。

本章小结

coroutine 描述异步计算,Task 是 event loop 中受调度的执行对象,await 负责 驱动或等待并在未完成时让出控制权。直接逐个 await 保持顺序;显式 Task 可以让 等待重叠,但每个 Task 都必须有所有者和完成条件。

练习

  1. coro = load_catalog() 执行了函数体吗?怎样让它执行?
  2. 预测 task = create_task(load_a()); b = await load_b(); a = await task 的关系。
  3. 审查 create_task(save_log()) 后立即 return 的代码,列出三个未回答的问题。

练习解析

  1. 没有。它只创建 coroutine object;应在 event loop 中 await,或调度为 Task 并 由所有者等待。
  2. load_a 被调度后可与 load_b 的等待重叠;随后 await task 收回 a 的结果。 前提仍是二者业务上独立。
  3. 谁等待完成、谁处理异常、请求结束后是否允许继续。若没有明确托管边界,就不应 用 fire-and-forget 掩盖问题。

参考资料