事情是这样的,上周老板丢过来一个需求:从 50 个第三方 API 拉数据做聚合报表。我心想小事一桩,写个循环 Requests 不就完了。结果跑起来直接傻眼——全是同步阻塞,50 个接口轮一圈要将近 80 秒。这时候我自然想到了 asyncio,Python 里专门解决 IO 密集型并发的神器。谁知兴冲冲地开干之后,一连串诡异现象让我在工位上排查了整整 3 个小时。
以为理解了 asyncio,其实只理解了皮毛
事件循环:单线程里的“时间管理大师”
asyncio 的核心是一个事件循环(Event Loop),它在单线程内调度所有协程。当一个协程在等网络、磁盘这种慢 IO 时,不会阻塞整个线程,而是把控制权交还给事件循环,由它去唤醒下一个就绪的协程。
用 async def 定义协程,用 await 主动让出执行权:
import asyncio
async def fetch_api(url: str) -> str:
print(f"开始请求 {url}")
await asyncio.sleep(1) # 模拟网络 IO,实际会用 aiohttp
return f"data from {url}"
真正的并发:gather 与 create_task
顺手把 50 个任务一起丢进去,用 asyncio.gather 并发执行,总耗时看最慢的那个,而不是所有请求时间相加:
async def main():
urls = [f"https://api.example.com/item/{i}" for i in range(50)]
tasks = [fetch_api(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"获取 {len(results)} 条数据")
asyncio.run(main())
从 80 秒直接降到 2 秒不到,我差点高兴得拍桌子。但坑,也在这时候排着队来了。
完整对比:同步 vs 异步,差距有多大
下面两段代码可以直接复制运行,强烈建议你跑一下感受区别。
同步版本(慢得离谱)
import time
import requests
def fetch_sync(url: str) -> str:
resp = requests.get(url, timeout=5)
return resp.status_code
def main():
urls = ["https://httpbin.org/delay/1"] * 10 # 10个模拟慢接口
start = time.perf_counter()
results = [fetch_sync(url) for url in urls]
elapsed = time.perf_counter() - start
print(f"同步耗时: {elapsed:.2f}s, 结果数: {len(results)}")
if __name__ == "__main__":
main()
异步版本(正确姿势)
import asyncio
import time
import aiohttp
async def fetch_async(session: aiohttp.ClientSession, url: str) -> int:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
return resp.status
async def main():
urls = ["https://httpbin.org/delay/1"] * 10
start = time.perf_counter()
async with aiohttp.ClientSession() as session:
tasks = [fetch_async(session, url) for url in urls]
results = await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
print(f"异步耗时: {elapsed:.2f}s, 结果数: {len(results)}")
if __name__ == "__main__":
asyncio.run(main())
同步 10 个接口耗时约 12 秒,异步直接压到 1 秒出头,差距肉眼可见。
我踩过的坑,一个比一个隐蔽
1. 忘记 await,协程变成了僵尸
tasks = [fetch_async(session, url) for url in urls] # 只返回协程对象,不会运行!
没有 await 或 asyncio.gather 包裹,这些协程压根没被调度。代码跑完时间几乎是 0,结果列表全是协程对象。解决方法就是老老实实用 gather 或 create_task。
2. 在协程里调用 time.sleep,整个循环卡死
async def buggy_fetch(url):
import time
time.sleep(1) # 阻塞了线程,事件循环被冻结
return "data"
time.sleep 是同步阻塞调用,会霸占唯一的线程,事件循环完全无法切换。必须用 await asyncio.sleep(n),或者把同步调用丢给 loop.run_in_executor。
3. 并发数不加限制,被目标 API 拉黑
50 个协程同时发起请求,直接把对方服务器打崩,返回一堆 429。解决方法是 Semaphore 信号量:
sem = asyncio.Semaphore(10) # 最多同时 10 个并发
async def rate_limited_fetch(session, url):
async with sem:
return await fetch_async(session, url)
4. Windows 上 asyncio.run() 报错
在 Windows 使用 ProactorEventLoop,某些场景下会提示 RuntimeError。换成 asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 或在顶层用 if __name__ == "__main__" 里调用 asyncio.run(),避免 spawn 子进程时循环冲突。
5. aiohttp 连接器耗尽
默认连接池只有 100 个连接,复用不够时请求会排队。如果域名不同还好,相同域名高并发必须调整 connector:
conn = aiohttp.TCPConnector(limit=200)
async with aiohttp.ClientSession(connector=conn) as session:
...
总结
异步编程的本质是“不让 IO 等待浪费 CPU”,但 Python 的 asyncio 把状态切换的主动权交给了开发者,一个不留神就是隐蔽的阻塞。你能写出 2 秒跑完 50 个 API 的优雅代码,也能写出死锁不动还不报错的僵尸程序。
#Python #异步编程 #asyncio #并发爬虫 #踩坑