一、为什么单线程也能"同时"做很多 IO

写爬虫或网关时,常遇到这样的场景:要同时发起上千个网络请求,每个请求大部分时间在等——等 DNS、等 TCP 握手、等对端响应。如果用"一个请求一个线程"的模型,上千线程的上下文切换和内存开销会压垮机器;如果"一个请求一个进程",更不现实。

核心矛盾是:CPU 在等 IO 时其实是闲着的,而线程/进程的切换成本却很高。理想状态是——一个线程在等 A 的回复时,去处理 B、C 的就绪事件,等 A 回来了再回头接着 A 干。

**事件循环(event loop)**就是干这件事的调度器:它在一个线程里维护"谁在等 IO、谁已经就绪、谁该到点执行",按需把控制权交给对应的协程。

本质一句话:事件循环是一个单线程的、基于 IO 多路复用的协作式调度器,靠"协程主动让出"而非"系统抢占"来切换任务。

C++ 对照:C++ 标准库没有内建事件循环。要么自己用 epoll/kqueue/IOCP 手写 reactor,要么引入 boost::asiolibuvlibevent 这类网络库;而 Python 自 PEP 3156(3.4)起就把 asyncio 纳入标准库,开箱即有成熟事件循环。

二、事件循环内部长什么样

剥开 asyncio,一个事件循环本质维护三块结构:就绪回调队列定时器最小堆IO 多路复用器(Linux 上默认 epoll,macOS 上 kqueue)。

事件循环内部结构(单线程 + IO 多路复用) Event Loop(单线程调度) 协程 await (主动让出) 就绪回调队列 (ready queue) 定时器最小堆 (timers) _run_once 每轮执行: 1. 跑到期定时器回调 2. 跑就绪队列里的回调 3. 问 IO 多路复用器:哪些 fd 就绪? 4. 就绪 fd 对应回调入队 epoll_wait (阻塞等待 fd) 内核通知 fd 就绪 (回调重新入队)

内核通知后,就绪回调重新进入「就绪回调队列」,循环回到顶部

每一轮 _run_once 的逻辑可以概括成四步:先处理到期的定时器,再跑就绪队列里的回调,然后调用 epoll_wait 看哪些 fd 就绪、把对应回调塞回就绪队列,最后处理超时。整个循环跑在单一线程里,靠协程在 await 处主动让出,从而不阻塞整个循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import asyncio, time

async def say(seq, word, delay):
await asyncio.sleep(delay) # 主动让出,循环去跑别的协程
print(f"[{seq}] {word}")

async def main():
# 三个协程"同时"开始,谁先 sleep 完谁先打印
await asyncio.gather(
say(1, "hello", 0.3),
say(2, "world", 0.1),
say(3, "async", 0.2),
)

asyncio.run(main())

输出(注意顺序由 delay 决定,而非代码书写顺序):

1
2
3
[2] world
[3] async
[1] hello

三、协程、Task、Future:三层对象各管什么

asyncio 里最容易混的是这三个概念,理清它们关系就懂了调度:

  • 协程(coroutine):用 async def 定义的对象,本身不执行,只是一个"可暂停的计算"。
  • Future:最底层的占位符,代表"将来某个时刻会有结果"。它只有两个状态——pending / done,完成时通过回调通知等待者。
  • TaskFuture 的子类,专门包装一个协程,负责在事件循环里驱动协程一步步跑,并在协程 await 时把控制权交还循环。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def compute(x):
await asyncio.sleep(0.1)
return x * x

async def main():
# asyncio.create_task 把协程包成 Task,立即"挂"到循环上并发执行
t1 = asyncio.create_task(compute(3))
t2 = asyncio.create_task(compute(4))
print("task 已创建,循环继续跑别的")
r1 = await t1 # 在这里等 t1 的结果
r2 = await t2
print(r1, r2) # 9 16

asyncio.run(main())

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import asyncio, aiohttp

async def fetch(session, url):
async with session.get(url) as resp:
return url, resp.status

async def main():
urls = ["https://example.com", "https://httpbin.org/get",
"https://www.python.org"]
timeout = asyncio.wait_for( # 整体超时保护
_gather_all(urls), 5.0)
try:
results = await timeout
print(results)
except asyncio.TimeoutError:
print("整体超时,部分请求未完成")

async def _gather_all(urls):
async with aiohttp.ClientSession() as session:
# gather 并发发起,return_exceptions 避免一个失败拖垮全部
return await asyncio.gather(
*[fetch(session, u) for u in urls],
return_exceptions=True)

asyncio.run(main())

关键点:

  1. asyncio.gather 并发驱动多个协程return_exceptions=True 让单个失败不连累整体。
  2. asyncio.wait_for 给协程套超时,超时抛 TimeoutError 并自动取消内部任务。
  3. asyncio.shield 可保护某个内部任务不被外部取消——外层 wait_for 超时取消时,被 shield 包裹的任务继续跑完。
  4. 取消是协作式的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.runrun 会新建并关闭一个循环,已在一个循环里时再调会报错;库函数里应接受外部传入的 loop 而非自行 run
  • 忘记 awaitcreate_task 后若从不 await 其返回,任务可能在循环关闭时被取消而静默丢失结果。
  • CPU 密集别硬撑 asyncio:单线程跑不动重计算,应交给 ProcessPoolExecutor 或多进程。

小结:asyncio 不是"更快的线程",而是用单线程协作调度换取极低的并发开销,专为 IO 密集、高并发连接的场景而生。它的灵魂是"协程在 await 处主动让出,事件循环在 fd 就绪时把它唤醒"——一旦在协程里塞进阻塞调用,这套精巧的调度就立刻崩塌。理解事件循环的三块结构(就绪队列 / 定时器堆 / IO 多路复用)与三层对象(协程 / Task / Future),就握住了 asyncio 的全部钥匙。