2. 协程基础:Python asyncio 概念、async/await 语法、事件循环原理
2.1 协程到底是什么
协程,说白了就是可以暂停的函数。
你调用一个普通函数,它从头跑到尾,一气呵成。但协程不一样——它跑到一半可以停下来,等会儿再接着跑。这个暂停和恢复的能力,就是协程实现并发的关键。我最早接触协程是在 Python 3.4 的 @asyncio.coroutine 时代,那时候语法还没现在这么清爽。
有人问我:"协程和线程有啥区别?" 嗯,简单来说:
- 线程是操作系统内核调度的,切换要进内核态,开销在毫秒级
- 协程是程序自己在用户态调度的,切换开销在微秒级
- 一个线程里可以跑成千上万个协程,但线程一多上下文切换就成了噩梦
我在项目中遇到过不少团队,一上来就开几百个线程做网络请求,结果 CPU 全耗在切换上了。换成协程后,同样的机器吞吐量翻了 5 倍。你想想看,这就是协程的核心价值——用极轻量的方式做并发。
2.2 async/await 语法
Python 3.5 正式引入了 async 和 await 关键字。定义一个协程函数很简单:
async def fetch_data(url):
# 这是一个协程函数
pass
注意,调用它不会立即执行函数体,而是返回一个协程对象:
coro = fetch_data("http://example.com")
# coro 是协程对象,函数体还没跑
要让协程真正跑起来,得把它交给事件循环。我习惯用 asyncio.run(),它帮你搞定创建循环、运行、关闭的全流程:
import asyncio
async def main():
print("hello")
await asyncio.sleep(1)
print("world")
asyncio.run(main())
await 是挂起点。协程遇到 await 就暂停当前操作,把控制权交还给事件循环,等条件满足了再回来接着跑:
async def fetch_data(url):
response = await http_get(url) # 挂起,等网络响应
return response.text
另外,await 后面跟的对象必须是可等待对象(awaitable)。常见的可等待对象有:协程、Task、Future。我刚开始学时总忘记把协程包成 Task,结果发现多个协程是串行跑的……嗯,后来养成了习惯,需要并发的地方用 asyncio.create_task()。
2.3 事件循环原理
事件循环是协程的调度中心,是整个 asyncio 的心脏。它的工作流程其实不复杂:
- 从任务队列里取出一个待执行的协程
- 运行它直到遇到
await(挂起点) - 把协程挂起,注册回调到 IO 多路复用上
- 去处理下一个就绪的任务
- IO 完成后,通过回调把协程放回就绪队列
- 重复以上步骤,直到所有任务完成
Python 里获取事件循环的方式:
# Python 3.7+ 推荐
asyncio.run(main())
# 底层一点的方式(我调试时偶尔会用)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()
我个人习惯用 asyncio.run(),省心。但有一次在 GATT 回调里调试协程泄漏时,我不得不手动控制循环,因为回调触发时机和事件循环的 tick 对不上,那叫一个折腾……
事件循环内部维护了几个关键队列:
- 就绪队列(Ready Queue): 存放可以立即执行的协程
- 等待队列(Waiting Queue): 存放等待 IO 或超时的协程
- 回调列表(Callback List): 存放
call_soon、call_later注册的回调
你想想看,这个模型其实特别像一个超级简约的操作系统调度器,只不过它只跑在一个线程上。
2.4 协程生命周期与泄漏隐患
一个协程从创建到结束,会经历这几个状态:
| 阶段 | 说明 | 代码表现 |
|---|---|---|
| 创建 | 调用 async 函数,生成协程对象 | coro = func() |
| 挂起 | 遇到 await,暂停执行 | await xxx |
| 恢复 | await 条件满足,继续往下跑 | 回调触发,恢复执行 |
| 结束 | 函数体执行完毕或抛出异常 | return 或 raise |
泄漏是怎么发生的? 说白了就是协程对象没有被正确清理,永远留在某个队列里,导致内存只增不减。
我遇到过的典型场景:
- 创建了协程但忘记
await,协程对象悬空,GC 无法回收 - 协程内部持有外部对象的引用(比如 self),形成循环引用
- 事件循环关闭了,但还有协程在等待队列里挂着
- 回调触发生成大量协程,处理速度跟不上创建速度——这在 GATT 回调里尤其常见
create_task 处理数据,但回调频率太高(每秒几十次),任务堆积在事件循环里,内存直线飙升。后来加了限流和超时控制才解决。这个案例我会在后面的章节详细拆解。
2.5 协程 vs 线程 vs 进程
我把三者的核心区别整理成了一张表,方便你快速对比:
| 特性 | 协程 | 线程 | 进程 |
|---|---|---|---|
| 调度方式 | 用户态协作式 | 内核态抢占式 | 内核态独立调度 |
| 切换开销 | ~1μs | ~1ms | ~10ms |
| 内存占用 | ~2KB | ~8MB | ~50MB+ |
| 数据共享 | 直接共享,无锁 | 需要锁/同步 | IPC 通信 |
| 适合场景 | IO 密集型 | 中等并发 | CPU 密集型/隔离 |
你看,协程在 IO 密集型场景下优势非常明显。但注意——它不是万能的,如果你的任务是 CPU 密集计算,协程帮不上忙,因为它在单线程里跑,一个协程卡住,其他协程都得等着。