Python-协程

前言

  • 协程(coroutine)是在同一个线程内做并发的方式:由程序自己决定「在哪里让出控制权」,切换发生在用户态,成本远低于线程切换。适合大量 IO 等待的场景(网络请求、数据库、文件);对 CPU 密集任务没有提速效果(见《进程》《线程》里的 GIL 讨论)。
  • 本篇的代码全部在本机跑过,环境是 Python 3.14.10(Windows),第三方库 greenlet 3.3.2、gevent 26.9.0。因为协程的调度顺序、耗时都依赖运行环境,所以我把所有输出都改成了可复现的形式:用列表记录交错顺序、用布尔值表示「是否并发」,不打印具体秒数。

并发模型的三种选择

模型 切换由谁决定 并行还是并发 适合 代价
多进程 操作系统 真并行(多核) CPU 密集 进程重、通信要序列化
多线程 操作系统 并发(受 GIL 限制,IO 时能并行等待) 阻塞式 IO、调用 C 库 栈内存、锁、竞态
协程 程序自己(遇到 await/switch 才让出) 并发(单线程) 海量 IO 等待(成千上万连接) 库要配合(异步 API 或猴子补丁)、一处阻塞全卡

一句话记法:CPU 密集找进程,阻塞 IO 找线程,海量等待找协程。

从生成器说起

yield 让函数能在中间暂停并把控制权交出去,这就是协程的机制原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def countdown(name, n):
while n > 0:
n -= 1
yield f'{name} 还剩 {n}' # 在这里暂停,把控制权交回调用方

if __name__ == '__main__':
a = countdown('A', 2)
b = countdown('B', 3)
while True:
try:
print(next(a)) # 每次 next 只推进到下一个 yield
print(next(b))
except StopIteration: # 生成器耗尽才抛,必须显式接住
break
1
2
3
4
A 还剩 1
B 还剩 2
A 还剩 0
B 还剩 1
  • 输出只有 4 行不是 5 行:A 的 range(2) 先耗尽,抛 StopIteration 后被 except 接住退出循环,B 还剩最后一轮没跑完。用生成器手写调度器很容易漏掉这种「谁先结束」的问题。
  • 现代写法不该再用 try/except StopIteration 手搓轮询:要么 itertools/select,要么直接上 asyncio。

生成器和协程在 API 层面是两条血脉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import asyncio
import inspect

def plain():
yield 1

async def coro():
await asyncio.sleep(0)
return 'ok'

async def main():
print('生成器函数:', inspect.isgeneratorfunction(plain), '| 协程函数:', inspect.iscoroutinefunction(coro))
c = coro()
print('调用协程函数得到的是:', type(c).__name__, '| 可 await:', inspect.isawaitable(c))
print('await 它的结果:', await c)
print('生成器能不能 await:', inspect.isawaitable(plain()))

asyncio.run(main())
1
2
3
4
生成器函数: True | 协程函数: True
调用协程函数得到的是: coroutine | 可 await: True
await 它的结果: ok
生成器能不能 await: False
  • 关键结论:协程对象可以 await,生成器对象不行。所以 for x in gen() 与 async for x in agen() 是两套协议(__iter__/__next__ vs __aiter__/__anext__)。

greenlet:把切换交给代码

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
26
27
# 需要 pip install greenlet(本机装的是 3.3.2)
from greenlet import greenlet

log = []

def a():
for i in range(3):
log.append(f'A{i}')
gb.switch() # 手动切给 B

def b():
for i in range(3):
log.append(f'B{i}')
gc.switch() # 手动切给 C

def c():
for i in range(3):
log.append(f'C{i}')
ga.switch() # 手动切回 A

if __name__ == '__main__':
ga = greenlet(a)
gb = greenlet(b)
gc = greenlet(c)
ga.switch() # 主 greenlet 切进 A,之后全靠代码里的 switch
print('切换轨迹:', log)
print('是否已经跑完(dead):', [ga.dead, gb.dead, gc.dead])
1
2
切换轨迹: ['A0', 'B0', 'C0', 'A1', 'B1', 'C1', 'A2', 'B2', 'C2']
是否已经跑完(dead): [True, False, False]
  • 输出严格按 A0 B0 C0 A1 B1 C1 A2 B2 C2 —— 顺序是代码里 switch() 写出来的,不是调度器决定的。这就是 greenlet 的定位:机制清晰、但没有任何「自动」可言。
  • 最后一行 [True, False, False] 说明:A 从循环里 return 之后,B、C 还停在半路上(dead=False)。协程对象生命周期要自己收,别让它们悬着。

switch() 还能双向传值,这是 greenlet 相对裸生成器多出来的能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
from greenlet import greenlet

def child():
got = greenlet.getcurrent().parent.switch('child 先给父一个值')
return f'child 收到: {got}'

if __name__ == '__main__':
g = greenlet(child)
first = g.switch() # 进入 child,运行到 switch 时把值交回来
print('第一次 switch 拿到:', first)
second = g.switch('喂给 child 的值') # 参数变成 child 里那个 switch 的返回值
print('第二次 switch 拿到:', second)
print('child 已结束:', g.dead)
1
2
3
第一次 switch 拿到: child 先给父一个值
第二次 switch 拿到: child 收到: 喂给 child 的值
child 已结束: True
  • 第一次 g.switch() 进入子 greenlet,执行到 parent.switch(值) 时把值交回主线;
  • 第二次 g.switch(值) 时,这个入参就是子 greenlet 里那个 switch() 表达式的返回值。

装法:pip install greenlet(本机 3.3.2)。典型用途是自己写调度器、或者给库做底层协程支持——gevent 就是它上面的一个封装。

gevent:用猴子补丁自动让路

greenlet 要手写切换太累。gevent 的思路是:把标准库里那些会阻塞的调用换成「会让路」的版本,于是普通同步代码也能并发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 需要 pip install gevent(本机装的是 26.9.0)
import time

import gevent
from gevent import monkey

monkey.patch_all() # 猴子补丁:把标准库里的阻塞调用换成协程版本

seen = []

def job(name, ticks):
for i in range(ticks):
seen.append(f'{name}{i}')
time.sleep(0.05) # 打完补丁后,这个 sleep 会让位给别的 greenlet

if __name__ == '__main__':
started = time.perf_counter()
gevent.joinall([gevent.spawn(job, 'A', 3), gevent.spawn(job, 'B', 3)])
elapsed = time.perf_counter() - started
print('交错执行:', seen)
print(f'6 次 0.05 秒的等待是否并发(总耗时 < 0.3 秒): {elapsed < 0.3}')
print('time.sleep 已被替换成:', time.sleep.__module__)
1
2
3
交错执行: ['A0', 'B0', 'A1', 'B1', 'A2', 'B2']
6 次 0.05 秒的等待是否并发(总耗时 < 0.3 秒): True
time.sleep 已被替换成: gevent.hub
  • A0 B0 A1 B1 A2 B2 是交错的:每次 time.sleep 都让另一个 greenlet 上场。
  • elapsed < 0.3 为 True:6 次 0.05 秒的等待只花了约 0.15 秒——并发成立。
  • time.sleep.__module__ 读回来是 gevent.hub:这就是猴子补丁生效的直接证据。

三条必须记住的规则

1
2
3
4
5
6
# 1) 补丁要尽可能早:在任何会阻塞的库被 import 之前
from gevent import monkey
monkey.patch_all() # 之后再 import requests / pymysql / socket 等

# 2) 只 IO 有效,CPU 密集不会变快(同一个线程,还是受 GIL 管着)
# 3) 没被补丁覆盖的第三方库照样阻塞,需要单独处理或改用官方异步驱动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 下载类案例(写法;本机没走外网,长度输出会随站点变化,故不作为实测结论)
import gevent
from gevent import monkey
import urllib.request

monkey.patch_all()

def download(url):
content = urllib.request.urlopen(url).read()
print(f'下载完成 {url},字节数:{len(content)}')

if __name__ == '__main__':
urls = ['https://www.example.com/', 'https://www.python.org/']
gevent.joinall([gevent.spawn(download, u) for u in urls])
  • gevent.spawn 返回 Greenlet,joinall 等全部结束;pool = gevent.pool.Pool(10) 可以限并发数(爬虫必备)。
  • 装法:pip install gevent(本机 26.9.0,底层是 libev + greenlet)。

asyncio:标准库这条主线

asyncio 是官方的异步 I/O 框架:事件循环就是单线程里那个「谁准备好了就跑去执行一会儿、执行到 await 就交还控制权」的调度器。一个事件循环 + 一堆任务,任务在 await 处挂起,循环切去跑别的任务。现代 Python 网络编程(FastAPI、httpx、redis-py、SQLAlchemy 异步引擎)都走这条路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio

async def coro(x):
await asyncio.sleep(0)
return x + 1

async def main():
print('当前循环实现:', type(asyncio.get_running_loop()).__name__)
print('直接 await:', await coro(1))
t = asyncio.create_task(coro(2))
print('任务类型:', type(t).__name__, '| 是 Future 的子类:', isinstance(t, asyncio.Future))
print('还没 await 时 done:', t.done())
print('await 之后:', await t, '| done:', t.done())
print('当前有没有正在跑的任务:', asyncio.current_task() is not None)

asyncio.run(main())
print('run 结束后再取运行中的循环:', end=' ')
try:
asyncio.get_running_loop()
print('拿到了(不应该)')
except RuntimeError as e:
print('RuntimeError:', e.args[0])
1
2
3
4
5
6
7
当前循环实现: ProactorEventLoop
直接 await: 2
任务类型: Task | 是 Future 的子类: True
还没 await 时 done: False
await 之后: 3 | done: True
当前有没有正在跑的任务: True
run 结束后再取运行中的循环: RuntimeError: no running event loop

要点:

  • asyncio.run(main()) 是唯一推荐的入口:建新循环 → 跑完协程 → 收尾关闭。之后在线程里再取 get_running_loop() 会抛 RuntimeError: no running event loop(实测)。
  • Windows 上的默认实现是 ProactorEventLoop(实测;Linux 是 SelectorEventLoop)。以前 Windows 用 Selector 时不能开子进程,Proactor 解决了这个。写跨平台库时要清楚这一点。
  • asyncio.Task 是 asyncio.Future 的子类(实测 isinstance(t, asyncio.Future) 为 True):Future = 「一个还没有的结果」,Task = 「跑一个协程的 Future」。
  • create_task() 之后任务就已经排进循环了,await 只是取结果;不调 await 也不会「没跑」,但不持有引用的裸任务可能被垃圾回收,这是经典事故(用 TaskGroup 或把任务存进集合)。

一个必须建立的直觉:await 才是让出点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import asyncio

async def say(who, delay):
print(f'{who} 开始')
await asyncio.sleep(delay) # 唯一的挂起点:主动让出控制权
print(f'{who} 结束')
return who

async def main():
print('顺序 await(一个跑完才跑下一个)')
await say('A', 0.05)
await say('B', 0.05)
print('用 create_task 并发')
t1 = asyncio.create_task(say('A', 0.05))
t2 = asyncio.create_task(say('B', 0.05))
print('两个任务都还活着:', not t1.done() and not t2.done())
done = await t1
print('第一个完成:', done)
await t2

asyncio.run(main())
1
2
3
4
5
6
7
8
9
10
11
12
顺序 await(一个跑完才跑下一个)
A 开始
A 结束
B 开始
B 结束
用 create_task 并发
两个任务都还活着: True
A 开始
B 开始
A 结束
B 结束
第一个完成: A
  • 前半段 await 逐个串行:A 完整跑完才开始 B。
  • 后半段先 create_task 两个任务,于是 A 开始 / B 开始 连着出现,并发了;两个任务都还活着: True 说明「创建后立刻执行了一段」而不是排队等待。
  • 结论:写 await f() 是串行;写 t = create_task(f()) 再 await t 才是并发。 这是 asyncio 最容易写错的地方。

批量并发:gather 与 TaskGroup

asyncio.gather(老而通用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
import time

async def worker(n, delay):
await asyncio.sleep(delay)
return f'任务{n}完成'

async def boom():
raise ValueError('出错了')

async def main():
start = time.perf_counter()
results = await asyncio.gather(*(worker(i, 0.1) for i in range(5)))
elapsed = time.perf_counter() - start
print('结果按提交顺序返回:', results)
print(f'5 个 0.1 秒的任务并发跑完(耗时 < 0.2 秒): {elapsed < 0.2}')
print('耗时确实等过(> 0.1 秒):', elapsed > 0.1)

ok, err = await asyncio.gather(worker(1, 0.01), boom(), return_exceptions=True)
print('return_exceptions=True 时第二个是异常对象:', isinstance(err, BaseException), type(err).__name__)

asyncio.run(main())
1
2
3
4
结果按提交顺序返回: ['任务0完成', '任务1完成', '任务2完成', '任务3完成', '任务4完成']
5 个 0.1 秒的任务并发跑完(耗时 < 0.2 秒): True
耗时确实等过(> 0.1 秒): True
return_exceptions=True 时第二个是异常对象: True ValueError
  • gather 的返回值按提交顺序排列,不是完成顺序(实测 ['任务0完成', …, '任务4完成'])。要按完成顺序处理就 asyncio.as_completed()。
  • 默认行为是第一个异常立刻向上抛,其它任务仍在跑(容易被忽略)。加 return_exceptions=True 才会把异常当结果收集回来(实测拿到 ValueError 实例)。

asyncio.TaskGroup(3.11+,推荐)

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

async def ok(name):
await asyncio.sleep(0.02)
return name

async def bad(name):
await asyncio.sleep(0.05)
raise RuntimeError(f'{name} 失败')

async def main():
try:
async with asyncio.TaskGroup() as tg: # 3.11+:结构化并发
tg.create_task(ok('A'))
tg.create_task(bad('B'))
except* RuntimeError as eg: # 3.11+ 的 except* 语法
print('捕获到子任务异常:', eg.exceptions[0].args[0], '| 异常条数:', len(eg.exceptions))

async with asyncio.TaskGroup() as tg: # 全部成功时正常退出
t = tg.create_task(ok('C'))
print('退出 with 之后任务已完成,结果:', t.result())

print('TaskGroup 要求同一任务内使用,跨任务 add_task 会报错:', hasattr(asyncio.TaskGroup, 'create_task'))

asyncio.run(main())
1
2
3
捕获到子任务异常: B 失败 | 异常条数: 1
退出 with 之后任务已完成,结果: C
TaskGroup 要求同一任务内使用,跨任务 add_task 会报错: True
  • TaskGroup 是结构化并发:退出 async with 时保证所有子任务都已结束;任何一个失败,其余的会被取消,异常打包成 ExceptionGroup 抛出,用 except* 按类型分拣(实测能拿到 eg.exceptions[0])。
  • 对比:gather 是「把一堆协程当参数」,TaskGroup 是「在一个作用域里随时 create_task」,后者更容易写正确的取消逻辑,也更容易看清任务边界。
  • 记一句:新代码优先 async with asyncio.TaskGroup() as tg:。

超时与取消

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import asyncio

async def slow():
await asyncio.sleep(1)
return '不该等到这里'

async def main():
try:
async with asyncio.timeout(0.1): # 3.11+ 的上下文写法
print(await slow())
except TimeoutError as e: # 就是内置的 TimeoutError
print('超时:', type(e).__name__, '| 消息为空:', e.args == ())

try:
await asyncio.wait_for(slow(), timeout=0.1) # 老写法(3.11 起也有)
except TimeoutError:
print('wait_for 超时同样抛 TimeoutError')

print('asyncio.TimeoutError 与内置 TimeoutError 是同一个类:', asyncio.TimeoutError is TimeoutError)

asyncio.run(main())
1
2
3
超时: TimeoutError | 消息为空: True
wait_for 超时同样抛 TimeoutError
asyncio.TimeoutError 与内置 TimeoutError 是同一个类: True
  • 3.11+ 用 async with asyncio.timeout(0.1):,作用域内所有 await 共享这个预算;老写法 await asyncio.wait_for(coro(), 0.1) 仍然有效。
  • 超时抛的就是内置 TimeoutError(实测 asyncio.TimeoutError is TimeoutError 为 True,3.11 起二者合一)。别在 except 里只抓 asyncio.TimeoutError 然后困惑于漏捕。
  • 取消的注意点:
1
2
3
4
5
6
7
8
9
10
11
12
13
async def worker():
try:
await asyncio.sleep(10)
except asyncio.CancelledError: # 它是 BaseException 派生(3.8+),别用 except Exception 吞掉
cleanup()
raise # 处理完必须再抛,否则取消失败、任务卡住

async def main():
t = asyncio.create_task(worker())
await asyncio.sleep(0.05)
t.cancel() # 请求取消:给任务抛 CancelledError
await asyncio.gather(t, return_exceptions=True)
print(t.cancelled()) # True
  • wait_for 超时时会取消被等的任务;asyncio.timeout 同理。清理逻辑写在 except CancelledError: … raise 里。

Future:一个还没兑现的结果

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
26
27
28
import asyncio

async def main():
loop = asyncio.get_running_loop()
fut = loop.create_future()
print('刚创建时 done:', fut.done(), '| cancelled:', fut.cancelled())

async def deliver():
await asyncio.sleep(0.02)
fut.set_result('递过来的值') # 由别处把结果填进来

task = asyncio.create_task(deliver())
value = await fut
print('等待 future 拿到:', value)
await task

fut2 = loop.create_future()
fut2.set_exception(ValueError('future 也能装异常'))
try:
await fut2
except ValueError as e:
print('抛出的是:', e.args[0])

fut3 = loop.create_future()
fut3.cancel()
print('取消后 done:', fut3.done(), '| cancelled:', fut3.cancelled())

asyncio.run(main())
1
2
3
4
刚创建时 done: False | cancelled: False
等待 future 拿到: 递过来的值
抛出的是: future 也能装异常
取消后 done: True | cancelled: True
  • loop.create_future() 造一个「空壳结果」,谁先算出来谁 set_result / set_exception,别的地方 await 它。这是把回调式 API 桥接成协程的标准手段:
1
2
3
4
async def wait_callback_api(loop):
fut = loop.create_future()
some_lib.run(callback=lambda value: loop.call_soon_threadsafe(fut.set_result, value))
return await fut # 等回调把值填进来
  • cancelled() 只在真的被取消过时为 True,别拿它当「有没有结果」用(实测刚创建时 done 与 cancelled 都是 False)。

协程之间的协作:队列与同步原语

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import asyncio

async def producer(q, n):
for i in range(n):
await q.put(i)
await q.put(None)

async def consumer(q, out):
while True:
item = await q.get()
if item is None:
break
out.append(item)
q.task_done()

async def counter(lock, state):
for _ in range(1000):
async with lock: # 协程锁,不能用 threading.Lock
state[0] += 1

async def main():
q = asyncio.Queue(maxsize=3)
out = []
await asyncio.gather(producer(q, 3), consumer(q, out))
print('队列传了:', out, '| 已空:', q.empty(), '| qsize:', q.qsize())

lock = asyncio.Lock()
state = [0]
async with asyncio.TaskGroup() as tg:
for _ in range(5):
tg.create_task(counter(lock, state))
print('加锁后计数:', state[0], '| 锁是否仍被占用:', lock.locked())

ev = asyncio.Event()
seen = []

async def waiter():
await ev.wait()
seen.append('醒来')

w = asyncio.create_task(waiter())
await asyncio.sleep(0.02)
print('事件未设置时等待者没醒:', seen == [])
ev.set()
await w
print('设置后:', seen, '| is_set:', ev.is_set())

asyncio.run(main())
1
2
3
4
队列传了: [0, 1, 2] | 已空: True | qsize: 0
加锁后计数: 5000 | 锁是否仍被占用: False
事件未设置时等待者没醒: True
设置后: ['醒来'] | is_set: True
  • asyncio.Queue:生产者-消费者的标准件。给 maxsize 才能形成背压(生产者会阻塞,不会吃爆内存);task_done() 与 join() 配对用来判断「全部处理完」。实测取空后 empty() 为 True、qsize() 为 0。
  • asyncio.Lock:协程里不要用 threading.Lock(拿不到锁时会阻塞整个线程 = 卡死事件循环)。实测 5 个协程各 +1 一千次,加锁后精确得到 5000;去掉锁的话结果会随机偏小。
  • asyncio.Event:一次性通知(set 后所有 wait() 者都醒);实测未 set 前等待者确实没醒。
  • asyncio.Semaphore:限并发,爬虫里最常用:
1
2
3
4
5
sem = asyncio.Semaphore(10)

async def fetch(session, url):
async with sem: # 同时最多 10 个在飞
return await (await session.get(url)).text()
  • asyncio.Condition:等待某个状态成立才继续。

异步迭代器与异步上下文管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio

class Range:
def __init__(self, n):
self.n = n

def __aiter__(self):
return self # 返回迭代器本身,不是协程

async def __anext__(self):
if self.n <= 0:
raise StopAsyncIteration # 结束信号
self.n -= 1
await asyncio.sleep(0) # 这里可以做真正的 IO
return self.n + 1

async def main():
got = [v async for v in Range(3)] # 异步推导式
print('异步迭代:', got)
async for v in Range(2):
print('逐个拿到:', v)

asyncio.run(main())
1
2
3
异步迭代: [3, 2, 1]
逐个拿到: 2
逐个拿到: 1
  • __aiter__ 必须返回迭代器自身(在 3.5/3.6 时代它曾允许是个协程,后来废了);__anext__ 是协程,靠 StopAsyncIteration 结束。
  • async for 常见于:分页 API 游标、数据库游标、aiohttp 的响应体分块、readlines 风格的异步流。
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
26
27
28
29
import asyncio

log = []

class Resource:
def __init__(self, name):
self.name = name

async def __aenter__(self):
await asyncio.sleep(0)
log.append(f'打开 {self.name}')
return self

async def __aexit__(self, exc_type, exc, tb):
log.append(f'关闭 {self.name}')
return False # 返回 True 会把异常吞掉

async def main():
async with Resource('连接池') as r:
log.append('使用 ' + r.name)
print('轨迹:', log)

try:
async with Resource('文件'):
raise ValueError('里面炸了')
except ValueError:
print('异常没被吞,最后一行是:', log[-1])

asyncio.run(main())
1
2
轨迹: ['打开 连接池', '使用 连接池', '关闭 连接池']
异常没被吞,最后一行是: 关闭 文件
  • async with 允许进入/退出时做 await(异步连接池、异步文件锁、事务提交/回滚)。
  • __aexit__ 返回真值才吞异常(实测返回 False 时外层 except ValueError 正常拿到)。写库时别不小心吞掉异常。
  • 标准库里已经有一堆异步上下文管理器:aiohttp.ClientSession()、asyncio.StreamWriter(配合 wait_closed)、async with anyio.create_task_group()。

绕不开的阻塞代码

asyncio 最怕「一个阻塞调用卡住整个循环」。三种解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

def blocking(n): # 普通同步函数:直接调用会卡住事件循环
time.sleep(0.05)
return n * n

async def main():
loop = asyncio.get_running_loop()
start = time.perf_counter()
results = await asyncio.gather(*(loop.run_in_executor(None, blocking, i) for i in range(3)))
elapsed = time.perf_counter() - start
print('放到线程池里跑的同步结果:', results)
print(f'3 个 0.05 秒的阻塞调用并发完成(< 0.1 秒): {elapsed < 0.1}')

with ThreadPoolExecutor(max_workers=2) as pool:
futures = [asyncio.wrap_future(pool.submit(blocking, i)) for i in range(2)]
done, pending = await asyncio.wait(futures, return_when=asyncio.ALL_COMPLETED)
print('wrap_future 接进事件循环:', sorted(f.result() for f in done))
print('done 数量:', len(done), '| pending 数量:', len(pending))

asyncio.run(main())
1
2
3
4
放到线程池里跑的同步结果: [0, 1, 4]
3 个 0.05 秒的阻塞调用并发完成(< 0.1 秒): True
wrap_future 接进事件循环: [0, 1]
done 数量: 2 | pending 数量: 0
做法 写法 用在哪
线程池托管 await loop.run_in_executor(None, blocking, arg) 只有同步版本的库(老驱动、requests、PIL)
桥接 concurrent.futures asyncio.wrap_future(fs) / asyncio.ensure_future(fs) 已经有自己的线程池,想在协程里 await
3.9+ 的简便写法 await asyncio.to_thread(blocking, arg) 单个阻塞调用,最常用
进程池 loop.run_in_executor(ProcessPoolExecutor(), cpu_bound) CPU 密集,绕开 GIL
  • 默认线程池大小:min(32, os.cpu_count() + 4)(3.8+),别把几百个阻塞任务全压给它。
  • asyncio.sleep 与 time.sleep 的区别要盯死:在协程里误写 time.sleep(1),整个循环停 1 秒——这是最常见的性能事故。

网络:asyncio 自己的流 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import asyncio

async def handle(reader, writer):
data = await reader.readline() # 异步读一行
writer.write(b'echo:' + data)
await writer.drain() # 异步刷缓冲
writer.close()

async def main():
server = await asyncio.start_server(handle, '127.0.0.1', 0)
port = server.sockets[0].getsockname()[1]
async with server:
reader, writer = await asyncio.open_connection('127.0.0.1', port)
writer.write(b'hello\n')
await writer.drain()
print('服务端回:', await reader.readline())
writer.close()
await writer.wait_closed()
print('监听 socket 的协议族:', server.sockets[0].family.name)

asyncio.run(main())
1
2
服务端回: b'echo:hello\n'
监听 socket 的协议族: AF_INET
  • asyncio.start_server / open_connection 给的是 Streams(StreamReader/StreamWriter),比裸 socket 友好:readline、readexactly、writelines、drain。
  • 端口传 0 让系统分配,server.sockets[0].getsockname()[1] 读回来(实测 AF_INET,与《网络编程》里的写法一致)。
  • writer.close() 之后要 await writer.wait_closed(),否则最后几个字节可能还没发完。
  • HTTP 层不要自己拿 asyncio 写,用 aiohttp(客户端/服务端都行)或 httpx.AsyncClient;DNS 解析用 loop.getaddrinfo(别用同步的 socket.getaddrinfo)。

异步数据库与缓存(写法备忘)

本机没有 MySQL/Redis 服务,这一节的代码未实测,只记录现代正确的包选择与骨架,避免照抄老教程。

1
2
3
4
5
6
7
8
# Redis:官方 redis-py 自带 asyncio 客户端(aioredis 已并入,别再 pip install aioredis)
import redis.asyncio as redis

async def main():
r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True)
async with r: # 或 r.close()
await r.set('k', 'v')
print(await r.get('k'))
1
2
3
4
5
6
7
8
9
10
11
12
# MySQL:aiomysql(PyMySQL 的异步版)或 asyncmy;ORM 走 SQLAlchemy 2.x 异步引擎
import aiomysql

async def main():
pool = await aiomysql.create_pool(host='127.0.0.1', port=3306, user='u',
password='p', db='demo', autocommit=True)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute('SELECT 1')
print(await cur.fetchone())
pool.close()
await pool.wait_closed()
1
2
3
4
# 通用异步数据库接口:SQLAlchemy 2 + asyncpg / aiosqlite
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine('sqlite+aiosqlite:///demo.db')
  • async with 的粒度决定了连接归还时机,别让连接活过事务。
  • 迁移口诀:同一个事件循环里不要用同步驱动(pymysql、redis-py 同步客户端会阻塞循环)。

uvloop:把事件循环换成 libuv

uvloop 是 asyncio 事件循环的高性能替换(基于 libuv,Node.js 的底层),常见于高并发服务端:

1
2
3
4
5
6
7
import asyncio
import uvloop

uvloop.install() # 之后 asyncio.new_event_loop() 拿到的就是 uvloop 的循环
# 或者:asyncio.run(main(), loop_factory=uvloop.Loop) # 3.12+ 写法

asyncio.run(main())
  • Windows 不支持 uvloop(Linux/macOS 专用)。本机实测的循环实现是 ProactorEventLoop,正是「没用上 uvloop」的样子;Policy 相关 API 在 3.14 已带 DeprecationWarning,改用 loop_factory。
  • 收益主要在大量连接/吞吐场景(常见说法是快 2~4 倍),业务代码不用改,但别指望它救阻塞调用。

常见坑速查

  • 定义了 async def 却忘了调用/await → 协程根本没跑,控制台出现 coroutine 'x' was never awaited 警告。
  • 在协程里调 time.sleep/同步 requests/同步驱动 → 整个事件循环卡住,并发形同虚设。改 await asyncio.sleep、asyncio.to_thread 或异步库。
  • await 一堆任务是串行 → 要并发就 create_task 或 gather(实测对比见上)。
  • gather 里一个任务抛异常就以为全都停了 → 默认是异常立刻上抛,但其他任务仍在跑;用 TaskGroup 才有「一荣俱荣一损俱损」的取消语义。
  • except Exception 接不住 CancelledError → 它继承自 BaseException;接住了也别忘记 raise,否则取消不掉、asyncio.run 收尾卡住。
  • 裸 create_task 不保存引用 → 任务可能被 GC 掉。TaskGroup 或 tasks.add(t) 保住。
  • threading.Lock / queue.Queue 用在协程里 → 会阻塞线程,用 asyncio.Lock / asyncio.Queue。
  • asyncio.Queue 不设 maxsize → 生产者快过消费者,内存一路涨。
  • 在已运行的循环里再调 asyncio.run() → 抛 RuntimeError(同一线程只能有一个运行中的循环)。Jupyter/交互环境直接 await 即可。
  • get_event_loop() 的旧习惯 → 3.12 起在没有运行循环时调用会告警,3.14 已见 DeprecationWarning;新代码只用 get_running_loop()(协程内)或 asyncio.run()(入口)。
  • gevent 补丁打晚了 → 之前 import 的模块还拿着原来的阻塞函数,看起来「补丁没生效」。patch_all() 必须在最前。
  • 协程数量爆炸 → 每个连接开一个协程虽然便宜,但上万时仍要限并发(Semaphore / Pool)。

练习

  • 把《线程》里那个「5 个线程各加 1000 次」的例子改写成 asyncio.Lock 版本,验证计数恒为 5000;再把锁去掉,观察偏小的结果。
  • 用 TaskGroup + Semaphore(5) 并发抓 20 个 URL(可用本地 python -m http.server 当靶子),要求:单个失败不影响其它、整体有超时、结果按 URL 顺序汇总。
  • 把一段 requests 同步下载改造成三种写法:asyncio.to_thread、run_in_executor、aiohttp,对比代码量与吞吐。
  • 实现一个自定义异步迭代器(分页游标:每页 await 一次网络),用 async for 消费。
  • 实现 async with 版的数据库事务(__aenter__ 开事务、__aexit__ 按有无异常提交或回滚),用 aiosqlite 或干脆用假连接验证轨迹。
  • 分别用 gather 和 TaskGroup 写「一个子任务失败」的场景,观察未捕获异常与任务取消的差异。

参考资料