异步 1 · 事件循环与协程机制¶
第 11 章给了 asyncio 的概览(gather、协程概念),web/02 讲了 FastAPI 的 async 实战。本章深入机制:async/await 到底在做什么、事件循环如何用单线程实现并发、Task 是什么。理解机制,才能写出正确、不踩坑的异步代码。
对照 Java:asyncio ≈
CompletableFuture+ 响应式 + 虚拟线程的混合体,但心智模型不同——它是单线程协作式调度。
1.1 协程函数与协程对象¶
async def 定义的是协程函数,调用它不执行代码,而是返回一个协程对象:
async def fetch(url):
print(f"start {url}")
await asyncio.sleep(0.1) # 模拟 IO 等待
return f"done {url}"
coro = fetch("x") # ✅ 只是创建协程对象,啥都没执行
print(coro) # <coroutine object fetch at 0x...>
⚠️ 最常见的初学者错误
写了 async def 却忘了 await/调度,协程从不执行,Python 还会警告 coroutine was never awaited。协程对象必须交给事件循环(await、create_task、asyncio.run)才会跑。
1.2 await 做了什么¶
await 是让出点:执行到 await <可等待对象> 时,当前协程暂停,把控制权交还事件循环,循环去跑别的就绪任务;等被 await 的东西完成后,再恢复当前协程继续。
关键:await 期间不阻塞线程——事件循环能运行其他协程。这就是单线程并发的来源。对照 Java:Future.get() 会阻塞线程,而 await 是挂起协程、不阻塞线程。
可 await 的对象(awaitable):协程、Task、Future、以及实现了 __await__ 的对象。
1.3 事件循环:单线程的"并发幻觉"¶
事件循环是一个调度器,在单线程里循环执行:取出就绪任务 → 跑到下一个 await 让出 → 取下一个。因为切换极快、IO 等待时让出,宏观上像"同时"做多件事。
事件循环(单线程)
├─ 任务A: await IO ──让出──┐
├─ 任务B: 跑到 await ──让出─┤
├─ 任务C: 跑到 await ──让出─┤
└─ IO 完成 → 恢复任务A ──────┘
对照 Java
- Java 的线程是抢占式多任务(OS 调度,随时切换)。
- Python 协程是协作式多任务(只在
await处切换)。 - 协作式的代价:一个协程若不让出(不 await、跑阻塞代码),整个循环卡死(见第 3 章陷阱)。
1.4 Task:让协程真正并发¶
光 await fetch() 是串行的——等它完才继续。要并发,得用 asyncio.create_task 把协程包装成 Task 丢进循环调度:
import asyncio
async def main():
# ❌ 串行:总共 ~0.3s
a = await fetch("a")
b = await fetch("b")
c = await fetch("c")
# ✅ 并发:总共 ~0.1s
t1 = asyncio.create_task(fetch("a"))
t2 = asyncio.create_task(fetch("b"))
t3 = asyncio.create_task(fetch("c"))
a, b, c = await t1, await t2, await t3
create_task 立即把协程排入循环(开始跑),返回 Task 句柄。后续 await task 等它完成取结果。三个 task 并发,总耗时≈最长那个(0.1s)而非总和(0.3s)。
-
Java(CompletableFuture)
-
Python(Task)
1.5 入口:asyncio.run¶
普通函数不能直接 await——必须有一个运行中的事件循环。asyncio.run 是程序的异步入口,创建循环、运行顶层协程、关闭循环:
⚠️ Java 程序员的陷阱
asyncio.run一个程序通常只调一次(最外层)。不要嵌套调用。- 在已有事件循环里再调
asyncio.run会报RuntimeError。 - 顶层逻辑放
main()协程里,用asyncio.run(main())启动。
1.6 协程的生命周期¶
创建(调用 async def)→ 协程对象(未运行)
↓ asyncio.run / create_task / await
调度中(Task,在循环里跑)
↓ 跑到 await
暂停(让出,等恢复)
↓ 完成 / 异常
完成(结果 / 异常)
- 正常完成:
await task拿到返回值。 - 抛异常:
await task重新抛出协程里的异常(异常不会"丢失",但会传播到 await 处——第 3 章详讲)。 - 取消:
task.cancel()请求取消(第 2 章详讲)。
1.7 协程 vs 线程:心智对照¶
| 线程(Java/Python threading) | 协程(asyncio) | |
|---|---|---|
| 调度 | OS 抢占式 | 事件循环协作式(await 处切换) |
| 阻塞 | 一个线程阻塞,其他仍可跑(Java 并行;Python 受 GIL 限 CPU) | 阻塞 = 卡死整个循环 |
| 数量上限 | 几百~几千 | 几万~几十万(轻量) |
| 切换成本 | 内核切换,较重 | 用户态切换,极轻 |
| 共享状态 | 需锁(竞态) | 单线程内无竞态(但 await 处仍需小心) |
| Python 特殊 | 受 GIL(CPU 不并行) | 单线程,GIL 无影响 |
何时用协程:IO 密集 + 高并发(成千上万连接),如 Web 服务、爬虫、消息消费者。何时不用:CPU 密集(协程不解决 CPU 并行,走多进程)。
本章练习¶
练习 1.1
解释为什么下面代码的协程从不执行,并修复。
参考答案
greet() 只创建协程对象、不执行(且会警告 coroutine was never awaited)。修复:用事件循环调度它——asyncio.run(greet())。
练习 1.2
用 create_task 让三个 asyncio.sleep 协程并发,验证总耗时≈最长那个(而非总和)。
练习 1.3
协程的"协作式"调度意味着什么?如果一个协程里调用 time.sleep(5)(同步阻塞)会发生什么?
参考答案
协作式 = 只在 await 处切换。time.sleep(5) 是同步阻塞、不让出事件循环,会卡死整个循环 5 秒——期间所有其他协程都无法运行。应改用 await asyncio.sleep(5)(让出,循环可跑别的)。
← 回首页 | 下一章:异步 2 · 并发控制