Python asyncio事件循环与协程调度深度解析
一、为什么单线程也能"同时"做很多 IO
写爬虫或网关时,常遇到这样的场景:要同时发起上千个网络请求,每个请求大部分时间在等——等 DNS、等 TCP 握手、等对端响应。如果用"一个请求一个线程"的模型,上千线程的上下文切换和内存开销会压垮机器;如果"一个请求一个进程",更不现实。
核心矛盾是:CPU 在等 IO 时其实是闲着的,而线程/进程的切换成本却很高。理想状态是——一个线程在等 A 的回复时,去处理 B、C 的就绪事件,等 A 回来了再回头接着 A 干。
**事件循环(event loop)**就是干这件事的调度器:它在一个线程里维护"谁在等 IO、谁已经就绪、谁该到点执行",按需把控制权交给对应的协程。
本质一句话:事件循环是一个单线程的、基于 IO 多路复用的协作式调度器,靠"协程主动让出"而非"系统抢占"来切换任务。
C++ 对照:C++ 标准库没有内建事件循环。要么自己用 epoll/kqueue/IOCP 手写 reactor,要么引入 boost::asio、libuv、libevent 这类网络库;而 Python 自 PEP 3156(3.4)起就把 asyncio 纳入标准库,开箱即有成熟事件循环。
二、事件循环内部长什么样
剥开 asyncio,一个事件循环本质维护三块结构:就绪回调队列、定时器最小堆、IO 多路复用器(Linux 上默认 epoll,macOS 上 kqueue)。
每一轮 _run_once 的逻辑可以概括成四步:先处理到期的定时器,再跑就绪队列里的回调,然后调用 epoll_wait 看哪些 fd 就绪、把对应回调塞回就绪队列,最后处理超时。整个循环跑在单一线程里,靠协程在 await 处主动让出,从而不阻塞整个循环。
1 | import asyncio, time |
输出(注意顺序由 delay 决定,而非代码书写顺序):
1 | [2] world |
三、协程、Task、Future:三层对象各管什么
asyncio 里最容易混的是这三个概念,理清它们关系就懂了调度:
- 协程(coroutine):用
async def定义的对象,本身不执行,只是一个"可暂停的计算"。 - Future:最底层的占位符,代表"将来某个时刻会有结果"。它只有两个状态——pending / done,完成时通过回调通知等待者。
- Task:
Future的子类,专门包装一个协程,负责在事件循环里驱动协程一步步跑,并在协程await时把控制权交还循环。
1 | async def compute(x): |
await 一个对象时,事件循环会一直向下"穿透"到最底层的 Future:协程 await Task,Task await Future,Future 在 fd 就绪或定时器到期时被置为 done,再一层层把结果传回来。
C++ 对照:C++20 也引入了协程,但它是无栈协程(stackless)——编译器把 co_await 改写成状态机,协程的局部变量存在堆分配的可恢复帧里,没有独立调用栈;而 Python 的协程历史上基于生成器(yield from),是一种有栈式的、由解释器调度的可暂停函数。两者都能"暂停/恢复",但实现机制完全不同:C++20 协程的挂起点由编译器生成,Python 协程的挂起点由 await 显式标记、由事件循环驱动。
四、实战:并发请求、取消与超时
真实工程里,事件循环最常见的用法是"并发 IO + 统一超时 + 优雅取消"。
1 | import asyncio, aiohttp |
关键点:
asyncio.gather并发驱动多个协程,return_exceptions=True让单个失败不连累整体。asyncio.wait_for给协程套超时,超时抛TimeoutError并自动取消内部任务。asyncio.shield可保护某个内部任务不被外部取消——外层wait_for超时取消时,被 shield 包裹的任务继续跑完。- 取消是协作式的:
task.cancel()只是往协程里抛CancelledError,协程必须在finally里释放资源,否则取消不会真正生效。
C++ 对照:等价能力在 boost::asio 里用 io_context + async_* + steady_timer(超时) + awaitable<> 实现,但全部是回调/co_await 风格,需要手动管理 strand 保证线程安全;Python 把"单线程无锁"作为默认约定,省去了大量并发保护代码。
五、和 C++/线程模型的对比与踩坑清单
| 维度 | Python asyncio | C++(epoll + 线程池 / asio) |
|---|---|---|
| 调度模型 | 单线程协作式(协程主动让出) | 多为多线程 + IO 多路复用混合 |
| 并发上限 | 受单核单线程限制,适合 IO 密集 | 可多核多线,CPU 密集也强 |
| 写法 | async/await 扁平顺序写法 |
回调/co_await,样板较多 |
| 取消语义 | 协作式 CancelledError |
需手动实现取消/超时链路 |
| 线程安全 | 默认单线程,无需锁 | 多 worker 需 strand/锁 |
| 真实瓶颈 | 一个 time.sleep 阻塞全循环 |
内核/网络才是瓶颈,CPU 可横向扩 |
最容易踩的坑:
- 在协程里调阻塞函数:
time.sleep(1)、requests.get()会阻塞整个事件循环,所有其他协程一起卡死。必须用await asyncio.sleep()或loop.run_in_executor()把阻塞活丢到线程池。 - 嵌套
asyncio.run:run会新建并关闭一个循环,已在一个循环里时再调会报错;库函数里应接受外部传入的loop而非自行run。 - 忘记
await:create_task后若从不await其返回,任务可能在循环关闭时被取消而静默丢失结果。 - CPU 密集别硬撑 asyncio:单线程跑不动重计算,应交给
ProcessPoolExecutor或多进程。
小结:asyncio 不是"更快的线程",而是用单线程协作调度换取极低的并发开销,专为 IO 密集、高并发连接的场景而生。它的灵魂是"协程在
await处主动让出,事件循环在 fd 就绪时把它唤醒"——一旦在协程里塞进阻塞调用,这套精巧的调度就立刻崩塌。理解事件循环的三块结构(就绪队列 / 定时器堆 / IO 多路复用)与三层对象(协程 / Task / Future),就握住了 asyncio 的全部钥匙。

