事情发生在去年给内部运维平台加一个“批量检测域名存活”功能的时候。需求很简单:定时轮询 1000+ 个域名,检查 HTTP 状态码,超时 5 秒就算挂了。我心想这不就是 IO 密集型任务嘛,asyncio 一亮,几分钟就能跑完。于是一顿 async def、await、gather 操作,自信满满点下运行。结果呢?1000 个域名跑了整整四分多钟,几乎跟同步顺序请求没差别。我盯着屏幕,感觉被 Python 当众打脸。
接下来的三个小时,我经历了一次对 asyncio 认知的彻底刷新。如果你也曾在 async 函数里“一不小心”阻塞了事件循环,或者在 gather 里丢了异常却浑然不知,这篇踩坑记录应该能帮你省下不止三小时。
一、问题复现:并发了但又没完全并发
先看我当时写的“并发”代码(你猜问题出在哪):
import asyncio
import time
import requests
async def check_domain(url: str) -> dict:
"""检测单个域名的状态码和耗时"""
start = time.monotonic()
try:
# 注意这里用的是 requests,同步库
resp = requests.get(url, timeout=5, allow_redirects=True)
status = resp.status_code
except Exception as e:
status = str(e)
elapsed = time.monotonic() - start
return {"url": url, "status": status, "elapsed": elapsed}
async def main():
urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)] # 模拟慢速接口
t_start = time.monotonic()
# 希望全部并发
results = await asyncio.gather(*[check_domain(url) for url in urls])
t_end = time.monotonic()
print(f"总耗时 {t_end - t_start:.2f} 秒,完成 {len(results)} 个检测")
# 打印前 3 个结果
for r in results[:3]:
print(r)
if __name__ == "__main__":
asyncio.run(main())
你一眼可能就看出毛病了:在 async 协程里调用了同步阻塞的 requests.get。但当时我满脑子都是“用了 async 定义的就是协程,用 gather 就会并发”,完全忽略了事件循环的底层规则。50 个 URL,每个 delay 1 秒,总耗时超过 50 秒——活生生的排队请求。
这个坑的根源在于 asyncio 是单线程事件循环模型。async def 本身不会让你的代码自动并发,它只是告诉解释器“这个函数可能会 yield 控制权”。真正让出控制权的动作是 await ——但前提是 await 后面的对象必须是一个真正的异步实现(例如 aiohttp 的请求)。requests.get 底层的 socket 操作全是同步阻塞的,一个协程在等待它时,整个线程都被卡住,事件循环根本没机会切换到其他任务。你写了 gather,它依然是一个接一个执行。
解法很简单:用异步 HTTP 库,比如 aiohttp:
import aiohttp
import asyncio
import time
async def check_domain_async(session: aiohttp.ClientSession, url: str) -> dict:
"""真正的异步检测"""
start = time.monotonic()
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
status = resp.status
except Exception as e:
status = str(e)
elapsed = time.monotonic() - start
return {"url": url, "status": status, "elapsed": elapsed}
async def main_async():
urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)]
t_start = time.monotonic()
async with aiohttp.ClientSession() as session:
tasks = [check_domain_async(session, url) for url in urls]
results = await asyncio.gather(*tasks)
t_end = time.monotonic()
print(f"总耗时 {t_end - t_start:.2f} 秒,完成 {len(results)} 个检测")
换成 aiohttp 后,50 个请求大约 1.5 秒完成(只要目的服务器扛得住),速度直接起飞。这个常识性的坑花了我至少一小时,因为它太反直觉:“定义成 async 就以为异步了” 是初学者乃至三年经验的人都可能犯的错。
二、更深一层的坑:gather 吞掉你的异常
你以为这就结束了?更隐蔽的坑还在后面。在后续迭代中,我加了一条健康检查逻辑:如果某个域名连不上,就触发报警。但后来发现,某些域名明明已经挂了,代码却毫无动静。又排错一个小时,才发现是 asyncio.gather 的异常处理在捣鬼。
默认情况下,如果传给 gather 的某一个协程抛出异常,gather 不会立即取消其它任务,而是会在它 await 的地方抛出该异常。我当时的代码结构大致是:
try:
results = await asyncio.gather(*tasks)
except Exception:
logger.error("批量检测出错")
这导致:只要任何一个域名 aiohttp 连接失败(比如超时),gather 就会向上抛异常,执行 except 分支,但剩余还在运行的任务会被继续执行直到结束(然后被静默丢弃)。更糟的是,我根本没拿到那些成功的结果,因为异常一抛出,results 变量都没被赋值。
正确做法是使用 return_exceptions=True 参数,让 gather 把异常也当作“正常返回值”收集起来,然后逐个判断:
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"任务 {urls[i]} 异常: {result}")
else:
print(f"任务 {urls[i]} 成功: {result}")
这样既能拿到所有任务的状态,又不会丢异常信息。如果你想在第一个失败时就立即取消所有剩余任务,可以用 asyncio.wait 配合 FIRST_EXCEPTION 策略,但那就更复杂了,需要手动管理 Task 句柄。
三、真实场景的“必杀技”:把同步代码丢进线程池
踩了前两个坑之后,还有一个现实难题:不是所有库都有异步版本。比如你要用 PyMySQL 读数据库,或者调一个黑盒 SDK,它们全是同步阻塞的。怎么办?硬塞进 async 函数显然又会阻塞事件循环。此时就该 loop.run_in_executor 出场了:
import asyncio
import time
import requests # 依然用同步 requests
def sync_check(url: str) -> dict:
"""这个函数是同步的,可以被放到线程池中执行"""
start = time.monotonic()
resp = requests.get(url, timeout=5)
elapsed = time.monotonic() - start
return {"url": url, "status": resp.status_code, "elapsed": elapsed}
async def main_executor():
urls = [f"https://httpbin.org/delay/1?n={i}" for i in range(50)]
loop = asyncio.get_running_loop()
tasks = [
loop.run_in_executor(None, sync_check, url) # None 表示用默认线程池
for url in urls
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# results 中的每一项就是 sync_check 的返回值或异常
print(f"完成 {len(results)} 个检测")
这里 run_in_executor 把同步函数丢到线程池中执行,主线程的事件循环不受阻塞。它返回的是一个 asyncio.Future,可以当协程一样 await。对于无法改造为异步的代码,这招就是救命稻草。注意默认线程池大小是 CPU 核心数+4,如果并发量大可以自己创建 concurrent.futures.ThreadPoolExecutor 传入。
踩坑 & 注意事项
-
“async 定义 ≠ 异步执行”
只有await了真正的异步对象(如aiohttp、aiomysql、asyncio.sleep)才会让出控制权。同步调用(time.sleep、requests.get、socket.recv)会彻底阻塞整个事件循环。 -
gather的异常行为要显式指定
默认抛出异常会导致部分成功结果丢失。加上return_exceptions=True,把所有结果拿回来,再统一处理。千万不要裸写await gather(...)而不接异常。 -
不要在 async 函数里做 CPU 密集计算
即便没有 IO,一个耗时 3 秒的 for 循环同样会卡死事件循环。这种场景要么扔进run_in_executor,要么用asyncio.to_thread(Python 3.9+),或者干脆换多进程。 -
小心
create_task的“后台任务”丢失
用asyncio.create_task创建的任务如果未被await,当父协程结束时可能被取消。记得保存 Task 引用并在合适处等待。 -
测试环境与生产环境的事件循环策略差异
Windows 上默认用ProactorEventLoop,读写管道时行为与 Linux 不同。如果依赖于add_reader等低层 API,务必在目标平台上测试。
总结
asyncio 不是魔法,理解它单线程事件循环的本质,才能真正写出并发高性能的代码。
#Python #异步编程 #asyncio #性能优化 #踩坑日记