2. 协程基础:Python asyncio 概念、async/await 语法、事件循环原理

2.1 协程到底是什么

协程,说白了就是可以暂停的函数。

你调用一个普通函数,它从头跑到尾,一气呵成。但协程不一样——它跑到一半可以停下来,等会儿再接着跑。这个暂停和恢复的能力,就是协程实现并发的关键。我最早接触协程是在 Python 3.4 的 @asyncio.coroutine 时代,那时候语法还没现在这么清爽。

有人问我:"协程和线程有啥区别?" 嗯,简单来说:

  • 线程是操作系统内核调度的,切换要进内核态,开销在毫秒级
  • 协程是程序自己在用户态调度的,切换开销在微秒级
  • 一个线程里可以跑成千上万个协程,但线程一多上下文切换就成了噩梦

我在项目中遇到过不少团队,一上来就开几百个线程做网络请求,结果 CPU 全耗在切换上了。换成协程后,同样的机器吞吐量翻了 5 倍。你想想看,这就是协程的核心价值——用极轻量的方式做并发。

适用场景: IO 密集型任务(网络请求、文件读写、BLE 通信)、高并发连接(WebSocket、长轮询)、需要协作式多任务的地方。不适合 CPU 密集计算,那种场景还是交给多进程吧。

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
避坑指南: 我曾经见过有人写了 async 函数,但里面完全没有 await。这就失去了协程的意义——没有挂起点,函数会一气呵成执行完,跟普通函数没区别。记住,没有 await 的 async 函数,本质上就是个普通函数。

另外,await 后面跟的对象必须是可等待对象(awaitable)。常见的可等待对象有:协程、Task、Future。我刚开始学时总忘记把协程包成 Task,结果发现多个协程是串行跑的……嗯,后来养成了习惯,需要并发的地方用 asyncio.create_task()。

2.3 事件循环原理

事件循环是协程的调度中心,是整个 asyncio 的心脏。它的工作流程其实不复杂:

  1. 从任务队列里取出一个待执行的协程
  2. 运行它直到遇到 await(挂起点)
  3. 把协程挂起,注册回调到 IO 多路复用上
  4. 去处理下一个就绪的任务
  5. IO 完成后,通过回调把协程放回就绪队列
  6. 重复以上步骤,直到所有任务完成

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 对不上,那叫一个折腾……

核心要点: 事件循环是单线程的,它通过 IO 多路复用(select/epoll/kqueue)同时监听多个文件描述符。协程的切换全在用户态完成,没有内核参与,所以特别快。

事件循环内部维护了几个关键队列:

  • 就绪队列(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 回调里尤其常见
曾经踩过的坑: 我在调试一个 BLE 设备的 GATT 回调时,发现每次收到通知都会 create_task 处理数据,但回调频率太高(每秒几十次),任务堆积在事件循环里,内存直线飙升。后来加了限流和超时控制才解决。这个案例我会在后面的章节详细拆解。

2.5 协程 vs 线程 vs 进程

我把三者的核心区别整理成了一张表,方便你快速对比:

特性协程线程进程
调度方式用户态协作式内核态抢占式内核态独立调度
切换开销~1μs~1ms~10ms
内存占用~2KB~8MB~50MB+
数据共享直接共享,无锁需要锁/同步IPC 通信
适合场景IO 密集型中等并发CPU 密集型/隔离

你看,协程在 IO 密集型场景下优势非常明显。但注意——它不是万能的,如果你的任务是 CPU 密集计算,协程帮不上忙,因为它在单线程里跑,一个协程卡住,其他协程都得等着。

我的建议: 在嵌入式或移动端开发中,协程特别适合处理异步回调(比如 GATT 回调、传感器数据流)。把回调转成协程,用同步的方式写异步逻辑,代码可读性会好很多。但一定要留心协程的生命周期管理,不然泄漏就会找上门。
协程基础架构 async/await 语法定义 任务调度 Task Queue 事件循环 Event Loop IO多路复用 Selector 协程生命周期 状态管理 Future/Callback 回调恢复 ⚠ 协程泄漏风险 未 await / 循环引用 / 队列堆积 注册 调度 IO等待 回调