本节目标:理解协程函数、协程对象与 await 的关系,会用 asyncio.run 驱动事件循环,并能解释单线程为何能并发、阻塞调用为何会卡死循环。
适用版本:Python 3.12+(实测 3.14.6)
13.1 async/await 与事件循环
第 12 章我们用了线程和进程来对抗等待:网络请求一慢,就把工作丢给别的线程。线程确实能并发,但每个线程都有独立的栈、要参与 GIL 争夺、切换还有开销,成千上万个线程很快就撑不住了。本节换一条完全不同的路——在单个线程里用协程做并发。这条路的核心是三个词:协程、await、事件循环。
13.1.1 async def 定义协程函数,调用它只是「造了个对象」
先看一个最容易被误解的事实。用 async def 定义的函数叫协程函数(coroutine function),调用它并不会执行函数体,而是返回一个协程对象(coroutine object):
import asyncio
async def hello():
print("hello 内部执行了")
return 42
c = hello() # 注意:这里没有打印任何东西
print("type:", type(c))
print("repr:", repr(c)[:60])
type: <class 'coroutine'>
repr: <coroutine object hello at 0x105be4b80>
函数体里的 print("hello 内部执行了") 一行都没跑。协程对象只是「待执行的描述」,需要有人来驱动它,它才会真正运行。如果一直没人驱动,Python 会在垃圾回收时发出警告:
<sys>:0: RuntimeWarning: coroutine 'hello' was never awaited
这就是初学者最常见的第一个坑:写了 async def,调用之后忘了 await,代码静默不执行,程序看起来没报错,但逻辑凭空消失了。记住这条规律——协程不会自己跑。
13.1.2 驱动协程:asyncio.run 与手动事件循环
那谁来驱动协程?答案是事件循环(event loop)。它是一个循环,不断从「待办任务队列」里取出协程、执行到 await 处、挂起、再切换到下一个。
最省心的入口是 asyncio.run(),它一步到位地创建循环、运行协程、收尾关闭:
import asyncio
async def main():
print("running loop:", asyncio.get_running_loop())
return "done"
print(asyncio.run(main()))
running loop: <_UnixSelectorEventLoop running=True closed=False debug=False>
done
在 asyncio.run() 出现之前(以及为了理解底层),人们手动管理循环。下面两段代码效果完全等价,只是第二段把「创建—运行—关闭」三步写开了:
import asyncio
async def main():
print("running loop:", asyncio.get_running_loop())
loop = asyncio.new_event_loop() # 1. 创建
try:
loop.run_until_complete(main()) # 2. 运行到协程结束
finally:
loop.close() # 3. 关闭
print("手动循环已关闭")
running loop: <_UnixSelectorEventLoop running=True closed=False debug=False>
手动循环已关闭
日常代码一律用 asyncio.run();手动 new_event_loop() 只在少数需要自定义循环配置的场合出现。一个线程同一时刻只能有一个正在运行的循环,asyncio.run() 不能嵌套调用。
13.1.3 await 的语义是「让出控制权」
await 不是「等待」,更准确的说法是**「把控制权交还给事件循环,等这个可等待对象就绪后再切回来」**。看这段:
import asyncio
async def work(name, delay):
await asyncio.sleep(delay) # 挂起自己,把控制权交还循环
return f"{name} done"
async def main():
r = await work("A", 0.1)
print(r)
asyncio.run(main())
A done
await 后面必须是一个可等待对象:协程、asyncio.Task 或 Future。await 只能出现在 async def 函数内部,写在普通函数里是语法错误。当协程执行到 await asyncio.sleep(...) 时,它并没有「睡着占用 CPU」,而是登记一个定时器后让出控制权,循环转去跑别的任务——这正是并发的来源。
13.1.4 单线程如何做到并发:三个 1 秒任务只要约 1 秒
如果并发成立,那么「三个各睡 1 秒的任务」总共应该只花约 1 秒,而不是 3 秒。asyncio.gather() 负责把它们同时交给循环:
import asyncio, time
async def work(name, delay):
await asyncio.sleep(delay)
return f"{name} done"
async def concurrent():
t0 = time.perf_counter()
r = await asyncio.gather(work("A", 1), work("B", 1), work("C", 1))
return r, time.perf_counter() - t0
print(asyncio.run(concurrent()))
(['A done', 'B done', 'C done'], 1.0019216660002712)
总耗时约 1.0 秒,而不是 3 秒。这三个协程都卡在「等待」上,而等待期间 CPU 本来就闲着,于是循环把它们的时间片重叠起来,一个线程就顶了三个。反过来,如果你把三个 await 顺序写:
async def serial():
t0 = time.perf_counter()
await asyncio.sleep(1)
await asyncio.sleep(1)
await asyncio.sleep(1)
return time.perf_counter() - t0
3.006s
那就是老老实实的 3 秒——顺序 await 是串行的。并发不是 async def 自动带来的,而是 gather / create_task 这类调度手段换来的。
13.1.5 阻塞调用会卡死整个循环(本节最重要的一张表)
既然只有一个线程,那么任何同步阻塞调用都会让整个循环停摆。我们用「心跳任务」来量化这一点:主逻辑等待期间,一个后台任务每 0.05 秒记一次数,看它能记多少次。
import asyncio, time
async def heartbeat(stop):
n = 0
while not stop.is_set():
n += 1
await asyncio.sleep(0.05)
return n
async def blocking_demo():
stop = asyncio.Event()
hb = asyncio.create_task(heartbeat(stop))
await asyncio.sleep(0)
time.sleep(1) # 同步阻塞,循环完全停摆
stop.set()
return await hb
async def nonblocking_demo():
stop = asyncio.Event()
hb = asyncio.create_task(heartbeat(stop))
await asyncio.sleep(0)
await asyncio.sleep(1) # 让出控制权,心跳照常
stop.set()
return await hb
time.sleep(1) 期间心跳次数: 1
await asyncio.sleep(1) 期间心跳次数: 10
time.sleep(1) 期间心跳只跳了 1 次(刚启动那一下),而 await asyncio.sleep(1) 期间跳了 10 次。这就是「卡死」的直观证据:用 time.sleep 时,其他所有任务都被冻住。把这对函数对照记下来:
| 写法 | 是否阻塞循环 | 其他任务能否推进 | 该用在 |
|---|---|---|---|
time.sleep(1) | 是 | 不能,全部停摆 | 纯同步代码,绝不放进协程 |
await asyncio.sleep(1) | 否 | 能,正常调度 | 协程里的所有等待 |
requests.get(url) | 是 | 不能 | 需配合 to_thread(13.3 讲) |
await httpx_client.get(url) | 否 | 能 | 协程里的 HTTP 请求 |
判断标准只有一条:这个调用在等待期间会不会把 CPU 交还出去。同步库不会,所以它们只能出现在线程池里(见 13.3 节),绝不能裸写进协程。
13.1.6 get_running_loop、get_event_loop 与调试模式
循环本身通过 asyncio 的获取函数访问,但两个函数定位不同:
asyncio.get_running_loop():只返回当前正在运行的循环;没有就抛RuntimeError。这是现代代码应该用的函数。asyncio.get_event_loop():在协程内等价于前者;在协程外,旧版本会「顺手建一个或给个弃用警告」,而 3.14 实测是直接抛异常:
import asyncio
try:
asyncio.get_event_loop()
except RuntimeError as e:
print("get_event_loop:", e)
async def main():
print("两者相同:", asyncio.get_running_loop() is asyncio.get_event_loop())
asyncio.run(main())
get_event_loop: There is no current event loop in thread 'MainThread'.
两者相同: True
在协程内两者指向同一个循环;一旦出了协程,get_event_loop() 在 3.14 会直接抛 RuntimeError(其底层的事件循环 policy 系列 API 已被标记为 3.16 移除)。结论:协程里用 get_running_loop(),别在协程外用 get_event_loop()。
排查「谁把循环卡住了」的利器是调试模式。asyncio.run(main(), debug=True) 会对耗时超过 100ms 的回调发出警告:
import asyncio, time
async def slow():
time.sleep(0.3) # 故意阻塞
return 1
async def demo():
return await slow()
asyncio.run(demo(), debug=True)
Executing <Task finished name='Task-7' coro=<demo() ...> took 0.310 seconds
正常运行时这行警告不会出现,一旦出现,就等于循环在喊「有任务堵了我 310 毫秒」。调试模式是定位阻塞调用的第一手段。
13.1.7 协程与线程:该选哪条路
把第 12 章的线程模型和本节的协程放在一起看:
| 维度 | 线程(threading) | 协程(asyncio) |
|---|---|---|
| 并发单位 | 操作系统线程 | 协程对象 |
| 切换成本 | 高(内核参与) | 低(用户态) |
| 数量上限 | 千级就吃紧 | 十万级很轻松 |
| 谁决定切换 | 操作系统抢占 | 协程自己在 await 处让出 |
| 阻塞调用的后果 | 只阻塞当前线程 | 卡死整个循环 |
| 适用场景 | 少量线程 + 阻塞库 | 海量 I/O 并发 |
一句话总结:协程把「切换权」从操作系统手里拿回到了代码自己手里——代价是程序员必须自觉,绝不能在协程里做同步阻塞的事。第 12 章的线程和本节的协程不是替代关系,而是配合关系(13.3 节会讲怎么把两者接起来)。
小结
async def定义协程函数;调用它只返回协程对象,不await就静默不执行,并触发RuntimeWarning: coroutine ... was never awaited。- 协程要靠事件循环驱动:日常用
asyncio.run(),手动版是new_event_loop()+run_until_complete()+close(),二者等价。 await的语义是让出控制权,只能出现在协程内;顺序await是串行的,并发要靠gather/create_task。- 单线程能并发:三个 1 秒任务用
gather只花约 1 秒;但time.sleep、requests.get这类同步阻塞调用会卡死整个循环(实测心跳从 10 次掉到 1 次)。 - 用
asyncio.get_running_loop()而非get_event_loop()(3.14 在协程外会直接抛RuntimeError);asyncio.run(..., debug=True)能揪出拖慢循环的阻塞调用。
到这里你已经理解了协程和事件循环的地基。但「三个任务一起跑」还只是开始——任务怎么创建、怎么取消、超时怎么办、一个任务炸了其他任务会怎样?这些是下一节的主角。
阅读导航:上一节:socket、HTTP 客户端与 requests/httpx · 下一节:asyncio 任务、并发与超时取消 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。