查看: 286|回复: 3

Python asyncio异步爬虫提速:gather并发与超时取消

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
同步 requests 循环抓取时,真正耗时的部分通常不是计算,而是等待网络响应。原文的批量图片下载脚本从几百个链接跑近十分钟,改成 asyncio 并发后压到约二十秒。这个差距来自单线程事件循环:遇到 I/O 等待时协程主动让出控制权,让其他协程先执行。需要先说明适用边界:asyncio 适合爬虫、API 调用、数据库读写等 I/O 密集型任务;CPU 密集型计算应交给 multiprocessing 或 C 扩展,协程本身不会让计算变快。

一、环境与最小可运行验证

asyncio 在 Python 3.4 就已存在,但真正省去手动创建和关闭事件循环的 asyncio.run() 从 Python 3.7 才引入。建议至少使用 Python 3.8,原文更推荐 3.10 或 3.11,性能和错误提示更友好。Windows 安装时如果没勾选 Add Python to PATH,终端会提示 Python was not found;可重新运行安装包选择 Modify,在 Optional Features 中确认 Add Python to environment variables。Linux 和 macOS 先用 python3 --version 检查版本,必要时用系统包管理器升级,例如 Ubuntu 执行:
  1. sudo apt update
  2. sudo apt install python3 python3-pip
复制代码

VSCode 中先安装官方 Python 扩展,再用 Ctrl+Shift+P 执行 Python: Select Interpreter,选中刚装好的解释器。项目内建议建立虚拟环境:
  1. python -m venv venv
  2. source venv/bin/activate
复制代码

Windows 下激活命令为 venv\Scripts\activate。解释器选错是异步代码报错的常见来源,务必确认 VSCode 右下角路径与终端中的 Python 一致。环境就绪后,用下面的最小脚本验证:
  1. import asyncio
  2. async def main():
  3.     print('hello')
  4.     await asyncio.sleep(1)
  5.     print('world')
  6. asyncio.run(main())
复制代码

运行后应先输出 hello,约 1 秒后输出 world。这里 asyncio.sleep(1) 与 time.sleep(1) 的关键区别是:前者会释放事件循环,让其他协程获得运行机会。

二、协程、事件循环与 Task 的关系

async def 定义的是协程函数。调用它不会立即执行函数体,而是返回协程对象;只有被事件循环调度后,函数体才真正运行。await 用来等待可等待对象,包括协程对象、Task、Future 以及 asyncio.sleep() 的返回值。执行到 await 时当前协程暂停,事件循环转去处理其他任务,等对象就绪后再恢复。

asyncio.run() 会创建事件循环、执行传入的协程并关闭循环。它每次调用都会创建新循环,因此不要在同一个程序里反复创建循环并期望状态保留。调试多协程代码时,最好只在入口调用一次 asyncio.run(),把所有协程组织进一个 main 协程。

要让多个协程真正并发,需要用 Task 包装。asyncio.create_task() 会把协程注册到事件循环,等待调度:
  1. import asyncio
  2. async def worker(name, delay):
  3.     print(f'{name} start')
  4.     await asyncio.sleep(delay)
  5.     print(f'{name} end')
  6. async def main():
  7.     task1 = asyncio.create_task(worker('task1', 2))
  8.     task2 = asyncio.create_task(worker('task2', 3))
  9.     await task1
  10.     await task2
  11. asyncio.run(main())
复制代码

由于 task1 睡眠期间事件循环会调度 task2,所以 task2 start 往往会在 task1 结束前打印。create_task 必须在事件循环运行中调用,也就是只能在协程里创建,不能在最外层直接调用。多个独立任务还可以用 asyncio.gather() 聚合等待,并拿到返回值列表。

三、同步下载与异步下载实测对比

原文用 httpbin.org/delay/1 模拟每个请求等待 1 秒,发起 10 个请求。同步版本如下:
  1. import requests
  2. import time
  3. urls = ['https://httpbin.org/delay/1'] * 10
  4. def fetch_sync(url):
  5.     resp = requests.get(url)
  6.     return resp.status_code
  7. start = time.time()
  8. for url in urls:
  9.     fetch_sync(url)
  10. print(f'同步耗时: {time.time() - start:.2f}s')
复制代码

10 个请求串行完成,总耗时约 10 秒。换成 asyncio 加 aiohttp,因为 requests 是同步库,不能直接塞进 asyncio:
  1. import asyncio
  2. import aiohttp
  3. import time
  4. urls = ['https://httpbin.org/delay/1'] * 10
  5. async def fetch_async(session, url):
  6.     async with session.get(url) as resp:
  7.         return resp.status
  8. async def main():
  9.     async with aiohttp.ClientSession() as session:
  10.         tasks = [fetch_async(session, url) for url in urls]
  11.         results = await asyncio.gather(*tasks)
  12.         print(results)
  13. start = time.time()
  14. asyncio.run(main())
  15. print(f'异步耗时: {time.time() - start:.2f}s')
复制代码

原文实测同步版为 10.03 秒,异步版为 2.17 秒。异步版不是严格的 1 秒,是因为 DNS 解析、连接建立、TLS 握手等仍有额外开销。这里的 async with 用于管理 aiohttp.ClientSession(),退出时会释放连接池资源;如果只打开不关闭,长时间运行可能出现 Too many open files。任务数量特别大时不要一次性创建几千个请求,可以每批 200 个分批处理,避免内存和文件描述符被打满。

四、异步上下文管理器、异步迭代器与超时取消

async with 依赖 __aenter__ 和 __aexit__ 两个方法,适合封装需要异步清理的资源。自定义类的结构如下:
  1. import asyncio
  2. class AsyncConnection:
  3.     async def __aenter__(self):
  4.         print('opening connection')
  5.         await asyncio.sleep(0.1)
  6.         return self
  7.     async def __aexit__(self, exc_type, exc, tb):
  8.         print('closing connection')
  9.         await asyncio.sleep(0.1)
  10. async def main():
  11.     async with AsyncConnection() as conn:
  12.         print('using connection')
复制代码

即使 with 块内抛出异常,__aexit__ 也会被调用,比手写零散的 close 语句更稳妥。异步迭代器则用于逐条获取异步数据,例如分页抓取:
  1. async def fetch_pages():
  2.     for page in range(1, 6):
  3.         await asyncio.sleep(0.5)
  4.         yield f'page {page} data'
  5. async def main():
  6.     async for data in fetch_pages():
  7.         print(data)
复制代码

async for 要求对象实现 __aiter__ 和 __anext__。实际开发中更多是使用异步 Redis 的 scan_iter 等库自带迭代器,此时 async for 是正确写法。

超时控制用 asyncio.wait_for() 给协程或任务设置最大等待时间:
  1. try:
  2.     result = await asyncio.wait_for(slow_operation(), timeout=3)
  3. except asyncio.TimeoutError:
  4.     print('超时了')
复制代码

超时后 wait_for 会尝试取消任务,但取消只在协程执行到下一个 await 点时抛出 CancelledError。如果协程内部调用同步阻塞库,就无法优雅取消。需要清理资源时,可在协程中捕获 asyncio.CancelledError,完成清理后再 raise:
  1. async def controlled_task():
  2.     try:
  3.         await asyncio.sleep(10)
  4.     except asyncio.CancelledError:
  5.         print('cleanup')
  6.         raise
复制代码

另外,asyncio.gather() 默认不会因为某个任务抛异常就取消其他任务。如果需要异常不影响其他任务,可传入 return_exceptions=True,让异常作为结果条目返回,再统一判断。

五、五个常见错误及排查思路

1. RuntimeError: no running event loop。常见于普通同步函数里直接调用 asyncio.create_task()。create_task 需要当前线程存在正在运行的事件循环,解决方式是把创建动作放进协程,或用 asyncio.run_coroutine_threadsafe() 把协程投递给另一个线程的事件循环。排查时先确认这行代码是在哪条协程、哪个线程中执行。

2. 协程忘记 await。coro() 只创建协程对象,函数体不会执行,必须 await coro()。asyncio.gather(coro(), coro()) 会内部包装成 Task,所以不会报错,但把协程对象传给其他函数时可能一直不执行。排查时可在每个协程第一行打印 enter 日志,确认哪些协程真正进入。

3. 在协程里写 time.sleep。time.sleep 是同步阻塞,休眠期间不会让出事件循环,其他协程全部无法运行,多个任务总耗时仍会变成 1 秒乘任务数。检查所有可能阻塞的调用,包括 time.sleep、requests.get、普通 open().read()。没有异步版本的库可以用 asyncio.to_thread() 丢到线程池,避免阻塞循环。

4. gather 返回值顺序和异常处理。gather 返回列表顺序与传入任务顺序一致,不是完成时间顺序。需要按完成顺序处理结果时应使用 asyncio.as_completed()。默认情况下任一任务抛异常,gather 会立即向上传播,其他任务仍会继续执行,但结果已经拿不到;需要保留全部结果时传 return_exceptions=True。

5. asyncio.run 与 loop.run_until_complete 混用。老教程常见 loop = asyncio.get_event_loop() 后 loop.run_until_complete(main())。新代码推荐 asyncio.run(main()),它会自动创建和关闭循环。两种方式混用会遇到事件循环已关闭的报错。排查思路是只在程序最顶层调用一次 asyncio.run(),轮询任务和监听信号都放到 main 协程内部完成。

六、爬虫与数据采集中的落地结构

批量抓取时应限制并发数,避免把目标站点打崩或触发封禁。可以用 asyncio.Semaphore(20) 控制同时打开的连接数,把单次请求包在 async with sem 中。原文给出的启动框架如下:
  1. async def main():
  2.     sem = asyncio.Semaphore(20)
  3.     async with aiohttp.ClientSession() as session:
  4.         tasks = [fetch_with_retry(session, sem, url) for url in urls]
  5.         results = await asyncio.gather(*tasks, return_exceptions=True)
复制代码

这个结构便于后续加入限速、重试和超时。爬虫场景可以按批构造任务,用异步队列去重和限速;量化行情采集场景可以异步并发请求数百只股票的分钟级数据,数据回来后再统一进入策略计算。如果策略计算是纯 CPU 密集,就把重计算交给多进程,形成“异步拿数 + 多进程算数”的组合。

调试异步代码不要只靠 print。可以用调试器给每个 Task 单独打断点,或用 asyncio.all_tasks() 打印当前所有未完成任务,观察是否有协程被卡住。理解协程的挂起与恢复机制后,再看 asyncio 生态中的异步数据库、异步 HTTP 客户端和异步队列,会更容易判断哪些调用会阻塞事件循环。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python asyncio异步爬虫提速:gather并发与超时取消

感谢分享,写得很扎实,尤其是把协程、事件循环和 Task 的关系拆开讲,还有那个 hello/world 的最小验证,对刚上手的人很友好。同步和异步的实测对比也挺有说服力,10 秒压到 2 秒多这个数字很直观。 补充几个我踩过的坑,供后面调试的人参考。一是 gather 并发数别一把梭,链接一多很容易把目标站点打挂或者被限流,实际项目里我会加个 asyncio.Semaphore 控制同时在飞的请求数,比如 20 到 50 比较稳妥。二是 aiohttp 的 ClientSession 本身有连接池上限,默认我记得是一百,超过之后其实会自动排队,不是真的无限并发,所以看到耗时没按比例下降不用奇怪,可以调 connector 的 limit。三是超时和取消这块楼主标题提到了但正文好像还没展开,实际一定要给 session.get 或整个任务加 asyncio.wait_for,不然某个链接卡住会拖住整批 gather;如果用了 wait_for 或者手动 cancel,记得在协程里处理 CancelledError,做好清理,async with 的 session 退出这块也要注意别在取消时泄漏连接。四是 gather 默认遇到一个异常就整体抛出,其他任务还在跑,想要全部结果可以加 return_exceptions=True,再逐个判断。 另外楼主说的适用边界我很认同,协程只解决
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python asyncio异步爬虫提速:gather并发与超时取消

这篇整理很实用。先把 I/O 密集和 CPU 密集的适用边界讲清楚,能避免一上来就迷信协程;最小验证、协程与 Task 的关系也解释得比较顺,同步和 aiohttp 的实测对比很直观。实际落地时我会特别注意两点:aiohttp 不是标准库,需要单独安装;并发数最好用信号量限制,别一次把几百个请求全打出去。标题里的超时取消如果后续能结合 ClientTimeout 和 gather 的异常、取消处理再展开,就更完整了。另外 Windows 下虚拟环境激活和解释器选择那段对新手挺友好。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python asyncio异步爬虫提速:gather并发与超时取消

这篇整理得挺实在,尤其是开头把 asyncio 的适用边界讲清楚了,I/O 密集和 CPU 密集分开看,能避免不少新手误以为协程能加速所有任务。最小可运行验证、协程与 Task 的关系、同步和异步的对比这几段也足够直观,实测数据很有说服力。 一个小建议:标题里提到了超时取消,但正文目前还没展开。可以补上 asyncio.wait_for 或 asyncio.timeout 的用法,并说明 gather 默认遇到异常会向外传播,必要时用 return_exceptions 或逐个任务处理异常;取消任务后还要注意清理和捕获 CancelledError。实际爬虫里也建议加上并发上限,比如用 Semaphore 控制同时请求数,再配合 aiohttp 的 ClientTimeout,避免目标站点压力过大或本地连接数爆掉。整体思路没问题,继续把超时取消和异常处理补完整会更适合直接拿去改脚本。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-10 03:50 , Processed in 0.022422 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部