Python 异步编程入门:用 asyncio 把串行 IO 变成并发
一、先分清并发与并行
同步代码里,程序按语句顺序往下走,一个 HTTP 请求没返回,下一句就不会开始。这在本地脚本里没问题,但一旦搬到服务端,线程的大部分时间都花在「等网络」上,CPU 只能空转。
异步的目标不是让单次计算更快,而是把等待时间重叠起来:在 A 接口还在等响应的时候,先去发 B 接口的请求。这两类工作(等待 IO / 计算)被拆开之后,同样的一秒钟里就能同时推进多条链路。
二、协程:async def 与 await
Python 3.5 之后,语言层面原生支持协程。关键只有两个关键字:async def 声明一个协程函数,await 表示「我在这里挂起,把 CPU 让给别人,等结果回来再继续」。
import asyncio
async def fetch(name):
await asyncio.sleep(1) # 模拟一次网络等待
return f'{name} done'
async def main():
print(await fetch('a'))
asyncio.run(main())
asyncio.run() 会创建事件循环、把这个协程丢进去、并在退出前把循环关掉。理解「事件循环」很重要:它是这个模型里唯一真正在跑的东西,协程只是被它调度的一段段代码。
三、并发执行:asyncio.gather
如果只是把几个协程写成 await fetch('a') 一路串起来,实际效果和同步代码没区别——还是等完 a 再等 b。真正的并发要靠 gather 或 create_task 把它们同时排进队列。
import asyncio, time
async def fetch(name, delay):
await asyncio.sleep(delay)
return name
async def main():
start = time.time()
# 串行:总耗时约 3 秒
for n, d in [('a', 1), ('b', 1), ('c', 1)]:
await fetch(n, d)
# 并发:总耗时约 1 秒
result = await asyncio.gather(
fetch('a', 1), fetch('b', 1), fetch('c', 1)
)
print(result, time.time() - start)
asyncio.run(main())
gather 接受一组协程(或 Task),等它们全部完成后按顺序返回一个结果列表;只要有一个抛异常,默认行为是立刻把异常抛出来。想让单个失败不影响其它任务,可以传 return_exceptions=True,此时异常会作为结果元素返回,而不是中断整体。
四、超时控制:别让一次慢请求拖垮整个请求
真实环境里对端可能不响应。裸 await 会永远等下去,生产代码里几乎总要配一个上限。
async def main():
try:
data = await asyncio.wait_for(fetch('slow', 10), timeout=2)
except asyncio.TimeoutError:
data = None
print(data)
wait_for 超时时会取消内层任务并抛出 TimeoutError。如果内层任务不支持取消,调用方可能会等到任务真正结束才拿到控制权——这是异步编程里最常见的一类「幽灵阻塞」。
五、在异步代码里调用阻塞库
现成的第三方库大多还是同步的(比如老的 HTTP 客户端、数据库驱动)。在事件循环里直接调用它们,会把整个循环卡住。Python 3.9 起提供了 asyncio.to_thread,把阻塞调用丢到线程池里执行:
import asyncio
def blocking_read(path):
with open(path) as f:
return f.read()
async def main():
content = await asyncio.to_thread(blocking_read, 'data.txt')
print(len(content))
同理,CPU 密集型任务(如压缩、图像处理)也不该在事件循环里跑,用 run_in_executor 交给进程池往往比线程池更合适。
六、几个容易踩的坑
- 忘了 await:直接调用协程函数只创建了一个协程对象,不会执行。Python 会给出
coroutine was never awaited警告。批量场景请用gather或create_task。 - 在协程里跑阻塞代码:一次
requests.get()就足以让同一事件循环里的其它请求全部排队。要么换异步库,要么用to_thread包一层。 - Task 引用被回收:
create_task返回的 Task 若没有变量持有,事件循环只持有弱引用,可能在执行前就被 GC 掉,结果就是「任务凭空消失」。用set兜一层是常见写法。 - 取消不是强制中断:被取消的协程如果屏蔽了
CancelledError,或者卡在不可中断的同步调用里,取消就会失效。清理逻辑放在finally里,并且不要吞掉CancelledError。
七、小结
异步编程的收益来自「把等待重叠起来」,代价是心智负担上升:多了一层调度、取消语义、还有同步库混用时的坑。判断要不要上异步,可以问三个问题:
- 是不是有大量独立的 IO 等待?
- 是不是需要同时维持很多并发连接?
- 团队能不能接受更高的调试成本?
如果只是偶尔调一两个外部接口,同步代码加线程池往往更简单,也更容易维护;如果是上百个并发连接、或者要长时间维持长连接,异步模型的收益才真正体现出来。
- 点赞
- 收藏
- 关注作者
评论(0)