为什么异步编程在 Python 中如此重要
写爬虫的人最先体会到同步 IO 的痛苦:依次请求二十个接口,每个等两秒,光网络等待就耗掉四十秒;而其中 CPU 几乎一直在空转,等待网卡把数据送回来。Python 因为全局解释器锁(GIL),多线程在 CPU 密集场景下发挥有限,但在 IO 密集场景下,等待时间才是瓶颈——这时异步编程就成了最自然的选择。
异步编程的核心思想是:在等待 IO 的时候,不要干等着,而是把控制权交出去,去做别的事,等数据到了再回来接着干。它不是多线程,也不是多进程,而是在单个线程内通过事件循环调度大量并发任务,避免了线程切换的开销。
asyncio 不是银弹:它最擅长的是 IO 密集型任务(网络请求、文件读写、数据库查询),CPU 密集型任务用它反而会变慢,因为事件循环本身被算满了。
- 协程用 async def 定义的可暂停函数,遇到 await 让出控制权。
- 事件循环负责调度所有协程,在等待时切换到其他可运行任务。
- Task协程被包装成任务后才会被事件循环调度执行。
- 并发控制信号量限制同时执行的任务数,防止压垮目标服务。
协程与事件循环:asyncio 的核心模型
理解 asyncio 只需要抓住两个角色:协程(coroutine)和事件循环(event loop)。协程是用 async def 定义的函数,它和普通函数最大的区别是——它可以在 await 处暂停自己,把控制权还给事件循环,等等待的结果就绪后再恢复执行。
事件循环就像一个不停转的调度器:它手里维护着一堆就绪的任务,挨个执行;遇到任务说"我要等网络",就把它挂起,转去执行下一个就绪任务;等网络数据到了,再把这个任务标记为就绪,下一轮接着跑。整个过程在一个线程里完成,没有线程切换,没有锁竞争。
| 概念 | 作用 | 类比 |
|---|---|---|
| 协程函数 | 用 async def 定义,调用后返回协程对象 | 一道工序 |
| 事件循环 | 调度所有任务,管理就绪与等待状态 | 车间主任 |
| await | 在等待点暂停当前协程,交出控制权 | 工序里的等待信号 |
| Task | 协程被包装成任务后加入事件循环队列 | 派工单 |
| Future | 表示一个还未完成的异步结果 | 待领取的快递 |
Python 3.7 之后,最常用的入口是 asyncio.run():它创建事件循环、运行指定协程、关闭循环,一步到位。日常写异步脚本,入口永远是 asyncio.run(main()),不要手动去拿事件循环对象。
async / await 语法与任务创建
先看一个最基础的例子,理解 async 和 await 各自做了什么:
import asyncio
import time
async def say_after(delay: float, what: str) -> None:
await asyncio.sleep(delay)
print(what)
async def main():
# 串行:总共约 3 秒
t0 = time.perf_counter()
await say_after(1, "你好")
await say_after(2, "世界")
print(f"串行耗时:{time.perf_counter() - t0:.2f} 秒")
asyncio.run(main())
上面这段代码虽然用了 async,但两个 await 是依次等待的,总耗时约 3 秒,和同步代码没区别。要真正并发,需要把协程包装成 Task,让事件循环同时调度它们:
async def main():
t0 = time.perf_counter()
# 包装成任务,立刻开始执行
task1 = asyncio.create_task(say_after(1, "你好"))
task2 = asyncio.create_task(say_after(2, "世界"))
# 等待两个任务都完成
await task1
await task2
print(f"并发耗时:{time.perf_counter() - t0:.2f} 秒") # 约 2 秒
关键区别在 create_task:调用它的瞬间,协程就被提交到事件循环并开始运行,而不是等到 await 才开始。两个任务几乎同时启动,各自 sleep,谁先睡完谁先打印,总耗时取决于最长的那个,而不是两者之和。
- 裸调用协程不会执行:
say_after(1, "hi")只是创建了协程对象,必须被 await 或包装成 Task 才会跑。 - await 后面必须是可等待对象:协程、Task、Future 都可以,普通函数不行。
- asyncio.sleep 不等于 time.sleep:前者是异步等待,不阻塞事件循环;后者会把整个循环卡死。
并发控制:gather、wait 与信号量
当你要并发执行成百上千个任务时,手工 create_task 再逐个 await 就太啰嗦了。asyncio 提供了两个高层接口来批量管理任务:asyncio.gather() 和 asyncio.wait()。
async def fetch_url(url: str) -> str:
# 模拟一次网络请求
await asyncio.sleep(0.5)
return f"{url} -> 200 OK"
async def main():
urls = [f"https://api.example.com/item/{i}" for i in range(20)]
# gather:把所有协程打包并发执行,结果按顺序返回
tasks = [fetch_url(u) for u in urls]
results = await asyncio.gather(*tasks)
for r in results:
print(r)
gather 会把传入的所有协程并发跑起来,等全部完成后按原始顺序收集结果。如果某个任务抛异常,默认会立即把异常抛出来,其他任务仍在后台跑。想要"即使部分失败也拿到成功的结果",加 return_exceptions=True。
但这里有个大坑:二十个请求同时打出去,目标服务器可能直接限流甚至封你 IP。真实场景里必须控制并发数,这就是信号量(Semaphore)的用途:
async def fetch_with_sem(sem: asyncio.Semaphore, url: str) -> str:
async with sem: # 同时最多 N 个任务进入这里
return await fetch_url(url)
async def main():
sem = asyncio.Semaphore(5) # 限制并发为 5
urls = [f"https://api.example.com/item/{i}" for i in range(100)]
tasks = [fetch_with_sem(sem, u) for u in urls]
await asyncio.gather(*tasks)
信号量就像停车场闸口:总共只有 5 个车位,满了后面的任务就在闸口外等,出来一个进一个。这是写并发爬虫和批量接口调用时的标配,既快又不会把服务打挂。
异步网络请求与并发抓取实战
asyncio 本身只提供事件循环和同步原语,真正发 HTTP 请求需要配合异步 HTTP 客户端。目前最主流的选择是 aiohttp,它和 requests 的思路类似,但所有 IO 方法都是协程。
import aiohttp
import asyncio
async def fetch(session: aiohttp.ClientSession, url: str) -> dict:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
data = await resp.json()
return {"url": url, "status": resp.status, "title": data.get("title", "")}
async def main():
urls = [f"https://jsonplaceholder.typicode.com/posts/{i}" for i in range(1, 21)]
sem = asyncio.Semaphore(5)
async with aiohttp.ClientSession() as session:
async def bounded(u):
async with sem:
return await fetch(session, u)
results = await asyncio.gather(*[bounded(u) for u in urls])
for r in results:
print(r["status"], r["title"][:30])
asyncio.run(main())
这段代码做了三件正确的事:复用同一个 ClientSession(它内部维护连接池,每次新建 session 会浪费 TCP 握手)、用信号量把并发压到 5、用 gather 收集全部结果。二十个请求按 5 个一批跑完,总耗时大约是四批乘以单批延迟,远快于逐个请求。
几个工程上的经验:超时必须显式设置,否则某个接口卡住会拖死整个任务;失败要重试,用 tenacity 之类的库包一层指数退避;不要在协程里写同步阻塞代码,比如 requests 调用、time.sleep、大段 CPU 计算——它们会卡住整个事件循环,让所有并发任务一起停摆。
常见坑与调试技巧
异步编程的 bug 往往不报错,只是"结果不对"或者"程序卡住"。按踩坑频率排:
- 协程写了但没 await:程序瞬间跑完但什么都没输出,因为协程对象被垃圾回收了。Python 会发出 RuntimeWarning,留意它。
- 在协程里调用同步阻塞函数:比如 time.sleep、requests.get,事件循环被堵住,其他任务全部停摆。遇到这种情况,用
loop.run_in_executor把阻塞操作扔到线程池。 - 忘记创建事件循环就用 asyncio 功能:在 Jupyter 或某些框架里环境特殊,脚本里老老实实用
asyncio.run()最稳。 - 异常被 gather 吞掉:默认 gather 遇到异常会抛出,但已经启动的其他任务仍在跑,结果可能不是你预期的顺序。调试时先用
return_exceptions=True看每个任务到底成功没。 - 任务取消的副作用:
cancel()会在 await 处抛出 CancelledError,资源清理要用 finally 或者 async context manager。
调试异步代码最有用的工具是 asyncio.run(debug=True):它会检测事件循环卡顿(单次回调超过 100ms 就告警)、检测未被 await 的协程、给出详细的任务栈。生产环境关掉,开发时强烈建议打开。
从同步到异步的迁移路线
老项目想引入异步,不要一上来就重写。推荐分三步走:
- 第一步:定位瓶颈。先用 cProfile 或日志确认时间到底花在哪——是网络等待、数据库查询,还是 CPU 计算。如果是 CPU 密集,异步帮不上忙,该上多进程就上多进程。
- 第二步:外围包装。把最外层的入口函数改成 async,内部业务逻辑暂时不动,需要并发的地方用 asyncio.gather 同时跑多个同步函数(通过 to_thread 包装),先拿到并发收益。
- 第三步:逐步异步化。把内部的 IO 调用逐个替换成异步版本(requests 换 aiohttp、sqlite 换 aiosqlite、redis 换 redis-py 的异步接口),让整条链路都非阻塞。
# 迁移第一步:用 to_thread 包同步函数,零侵入拿到并发收益
import asyncio
def slow_query(sql: str) -> list:
# 这是一个阻塞的数据库查询,假设耗时 1 秒
time.sleep(1)
return [{"row": 1}, {"row": 2}]
async def main():
# 三个查询并发跑,耗时约等于最慢的那个
results = await asyncio.gather(
asyncio.to_thread(slow_query, "SELECT * FROM users"),
asyncio.to_thread(slow_query, "SELECT * FROM orders"),
asyncio.to_thread(slow_query, "SELECT * FROM logs"),
)
这套路线的好处是每一步都能独立上线、独立验证收益,不会出现"大改了一周还没跑起来"的窘境。异步编程的本质不是炫技,而是让程序在等待的时候别闲着——把这个原则想清楚,语法细节都是可以查文档的。
写异步代码时,时刻问自己一句:这一行如果卡住了,整个事件循环会不会跟着停?答案会帮你避开九成的坑。