上周要给公司做一个舆情监控工具,需要从 200 多个站点并发抓取页面,原来的同步脚本跑一趟要 40 多分钟,老板说“这太慢了,能不能快一点”。我心想,这不就是典型的 IO 密集型任务吗,上 asyncio 就完事了。结果代码写出来一跑,耗时不但没缩短,反而比同步版本还慢了十几秒。盯着屏幕 debug 了整整 3 小时,最后发现是自己把同步的思维硬套进了异步框架里。这个坑,今天必须写出来。
为什么会更慢?先看那段让我崩溃的代码
当时我的第一版“异步”代码大概是这样的:
import asyncio
import requests
import time
async def fetch(url: str):
# 想当然地在协程里用 requests.get
resp = requests.get(url, timeout=10)
return resp.text[:100]
async def main():
urls = ["https://httpbin.org/delay/1"] * 20
start = time.time()
# 用 asyncio.gather 并发执行
results = await asyncio.gather(*[fetch(url) for url in urls])
print(f"耗时: {time.time() - start:.2f}s, 结果数: {len(results)}")
asyncio.run(main())
这段代码表面上开了 20 个协程,但实际跑出来的耗时和串行几乎一样。原因是 requests 是同步阻塞的,它在等待网络 IO 时会卡住整个线程,而 asyncio 的事件循环偏偏跑在同一个线程里。当一个协程调用 requests.get(),事件循环就被它硬生生堵死了,其他 19 个协程只能原地罚站,等前面这一个完成。所谓的“并发”变成了一个接一个的排队,只不过队列叫协程而已。
正确的打开方式:把阻塞还给阻塞,把异步还给异步
asyncio 的核心是事件循环 + 协作式调度。协程遇到 await 时主动交出控制权,事件循环趁机把等待 IO 的任务挂起,切换去执行其他已就绪的协程。但这一切的前提是:你用到的 IO 操作必须原生支持异步,也就是返回一个 awaitable 对象。只要有一个同步阻塞调用混进来,整个事件循环就会被污染。
下面才是正确的写法,配合 aiohttp 搞定异步 HTTP:
import asyncio
import aiohttp
import time
async def fetch(session: aiohttp.ClientSession, url: str):
try:
async with session.get(url, timeout=10) as resp:
return await resp.text()
except Exception as e:
return f"ERROR: {e}"
async def main():
urls = ["https://httpbin.org/delay/1"] * 20
start = time.time()
# 创建共享的 session,复用连接池,大幅降低开销
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"耗时: {time.time() - start:.2f}s, 结果数: {len(results)}")
asyncio.run(main())
改动点不多,但每一点都踩在关键上:
- 用
aiohttp替代requests。aiohttp的session.get()返回的是协程,配合await在等待数据时不阻塞事件循环。 - 共享
ClientSession。在实际项目里,千万不要每次请求都新建 session,那样 TCP 连接无法复用,延迟和资源消耗都会飙升。async with能自动管理生命周期。 - 把任务统一交给
asyncio.gather,总耗时近似等于最慢的那个请求,而不是所有请求之和。
测试一下:20 个目标,每个 delay/1 延迟 1 秒,正确版本只需约 1 秒出头,而错误的阻塞版本要 20 多秒。就这两步,性能直接翻了几十倍。
再加一个信号量,防止“好心办坏事”
如果换成 200 个乃至 2000 个 URL,无限制并发会出现两个问题:目标服务器可能受不了,本机文件描述符也可能耗尽。最佳实践是引入 asyncio.Semaphore 做并发控制,既保持高速又保证稳定:
import asyncio
import aiohttp
async def fetch(session, url, sem, max_retries=2):
async with sem: # 控制同时只有 N 个协程进入
for attempt in range(max_retries + 1):
try:
async with session.get(url, timeout=10) as resp:
resp.raise_for_status()
return await resp.text()
except Exception as e:
if attempt == max_retries:
return f"FAILED({url}): {e}"
await asyncio.sleep(2 ** attempt) # 指数退避
return None
async def main():
urls = ["https://httpbin.org/delay/1"] * 200
sem = asyncio.Semaphore(30) # 最多同时 30 个请求
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, sem) for url in urls]
results = await asyncio.gather(*tasks)
print(f"完成: {len(results)} 个")
这样一来,不管列表多大,并发量始终受限,同时重试机制也避免了因偶发网络抖动丢失数据。这才是生产级的写法。
踩坑 & 注意事项
- 同步库污染事件循环:除了
requests,像time.sleep()也会阻塞整个线程。异步环境里应该用await asyncio.sleep()。如果实在要调用老同步库,可以用loop.run_in_executor()丢到线程池去执行。 - 忘记
await:写了协程却不await,它永远不会执行,IDE 只会淡淡地提醒一句“coroutine is not awaited”。一个常见错误是asyncio.gather(fetch(url) for url in urls),忘记加*解包,结果传进 gather 的是一个生成器对象,直接报错。 - 无限制并发打穿连接池:不限制并发数,轻则触发对方限流封 IP,重则耗尽本机 sockets。
Semaphore是你的安全带。 - 异常悄悄吃掉任务:
gather默认情况下如果一个任务抛异常,会直接抛出asyncio.CancelledError之类的错误,但其余任务可能被取消。加上return_exceptions=True可以把异常当作返回值处理,避免互相影响。 - 调试困难:异步栈帧不像同步代码那样直观。可以用
PYTHONASYNCIODEBUG=1或者asyncio.run(coro, debug=True)开启慢回调探测,帮你找到“不务正业”的阻塞调用。
总结
异步并发不是把同步代码套上 async/await 就完事了,真正起决定作用的,是底层 IO 实现是否与事件循环协作。
#Python #AsyncIO #异步编程 #爬虫 #掘金技术