去年有一批图片采集任务,我尝试在 Python 里用标准库做同步下载,结果面对几千张图直接傻眼:网络差一点的阶段,单张图片要等好几秒,跑完整个任务得像守夜一样盯着进度条。后来改用 asyncio 做异步批量下载,把等待 I/O 的时间让出来给其他任务,整个耗时从几十分钟压到几分钟。这个经历让我意识到,图片下载这类“大量 I/O 等待”场景,天然适合 asyncio 来优化。如果你也在做批量爬图、素材采集、数据集准备,这篇东西应该能帮你少走不少弯路。
文章会从同步模型为什么慢讲起,再给出一套基于aiohttp + aiofiles的异步图片下载器方案,包括并发控制、超时重试、文件落盘这些核心环节,最后把我踩过的坑一块儿抖出来。不需要你事先懂太多异步知识,只要会 Python 基础语法,照着搭起来就行。
1. 先认清问题:图片批量下载慢在哪
1.1 同步请求的时间账
很多人拿到图片下载任务,第一反应是requests.get()一张一张拉。同步模型是“发出请求 → 等响应 → 存文件 → 再发下一个请求”,整个过程中 CPU 几乎都在闲置,真正卡住的是网络往返。拿一个典型图床接口来说,单张图片从发请求到拿到二进制数据,平均耗时可能 0.5 秒到 2 秒不等。如果目标服务器响应慢一些,一次请求能拖到 10 秒以上。假设你有 2000 张图,平均每张 1 秒,单线程同步跑就是 2000 秒,接近 33 分钟,这还没算网络波动和重试时间。
这里还有一个很容易被忽略的细节:DNS 解析、TCP 握手、TLS 握手也要时间。在 HTTPS 场景下,每一次新连接都会重新走一遍 TLS 握手,轻的几十毫秒,重的几百毫秒。同步方式写起来简单,但每一张图都在重复这套“建连成本”。
1.2 线程和异步的取舍
有经验的人会想到用多线程,让多个请求同时飞出去。线程方案确实有效,但 Python 的 GIL(全局解释器锁)决定了线程在 CPU 密集计算时很难并行,不过图片下载是典型 I/O 密集任务,线程在等待网络响应时会释放 GIL,所以并发线程也能跑得不错。问题是线程数量不能随便开,开个三五百线程很容易把目标服务器拖垮,也会消耗不少系统资源。
asyncio 的思路完全不同。它是单线程事件循环,通过协程把“等待”让出来:一个请求挂起时,事件循环立刻去跑下一个协程,直到网络响应回来后恢复之前协程的执行。它的优势在于极低的开销:起一万个任务也远比开一万个线程轻松,协程的内存占用和调度成本小得多,控制并发也只在代码里写一个信号量就行。
1.3 图片下载场景为什么是优选标的
图片下载有鲜明的特征:任务数量大、单个任务逻辑简单、几乎全是网络 I/O 等待。没有复杂的计算依赖,没有线程安全问题,天然适合异步模型。只要有一个可用的 URL 列表,asyncio就能以极高的吞吐量完成下载。在我实测过的场景里,用asyncio搭配aiohttp,控制在 20 个并发连接,从十几分钟的同步耗时压缩到一两分钟,提升非常直观。
当然,异步也不是万能药。如果图片在本地要经过大量 CPU 处理(比如实时缩放、图像识别),那最好把计算部分交给线程池或进程池,避免阻塞事件循环。这点我在后面也会展开。
2. 环境准备与 asyncio 基础
2.1 依赖选型清单
写异步下载器,核心依赖就几个:
| 依赖库 | 作用 | 安装方式 |
|---|---|---|
| aiohttp | 异步 HTTP 客户端,发起请求、读取响应 | pip install aiohttp |
| aiofiles | 异步文件写入,避免磁盘操作阻塞事件循环 | pip install aiofiles |
| asyncio | Python 标准库,负责事件循环和协程调度 | 内置,无需安装 |
有些教程还会用httpx的 AsyncClient,它和aiohttp各有优势。httpx的 API 更像requests,写起来顺一些;aiohttp在连接池和响应处理上更成熟,社区资源也多。我在批量下载场景里一直用aiohttp,下文代码都以它为例。
需要注意的是 Python 版本。3.10 之后的asyncio用起来最舒服,asyncio.run()、asyncio.TaskGroup这些语法糖都齐了。如果还在用 3.7 以前的版本,有些写法会不兼容,建议先把环境升到 3.10 或更高。
2.2 协程、任务与事件循环的速成理解
asyncio 的核心是一个事件循环。你可以把它想象成一台只有一个服务员的餐厅:服务员接到客户点单后,不会一直站在桌边等菜做好,而是先去服务其他桌;菜做好的时候再回来把菜端上桌。对应到代码里,async def定义的协程就是“点单流程”,await就是“服务员去服务别的桌”的切换点,事件循环则负责在协程之间来回切换。
新建一个协程对象并不会马上执行,必须把它包成Task,让事件循环知道要调度它。最常用的入口是asyncio.run(),它负责创建事件循环、运行入口协程、结束后关闭资源。依照下面的写法即可:
import asyncio async def hello(): print("开始下载") await asyncio.sleep(1) print("下载完成") asyncio.run(hello())如果你有一堆协程要并发跑,可以用asyncio.gather()把它们聚合成一个任务:
async def main(): tasks = [hello() for _ in range(10)] await asyncio.gather(*tasks)gather会等待所有协程完成。不过在实际爬图脚本里,我不建议直接把所有 URL 都塞进gather——如果 URL 有几千个,任务列表会非常大,而且没有任何流量控制,后面我会用信号量和分批调度来处理。
2.3 第一个异步下载器骨架
先用一个最小例子跑通全流程,确认环境没问题。核心逻辑是:创建ClientSession,请求图片 URL,读取响应字节,写入本地文件。第一版可以不用考虑并发限制:
import asyncio import aiohttp import aiofiles from pathlib import Path async def download_one(session, url, save_path): async with session.get(url) as resp: if resp.status != 200: print(f"失败: {url} -> {resp.status}") return False data = await resp.read() async with aiofiles.open(save_path, "wb") as f: await f.write(data) return True async def main(): urls = [ "https://example.com/images/01.jpg", "https://example.com/images/02.jpg", ] async with aiohttp.ClientSession() as session: tasks = [download_one(session, url, Path(f"img_{i}.jpg")) for i, url in enumerate(urls)] results = await asyncio.gather(*tasks) print(f"成功: {sum(results)} / {len(results)}") if __name__ == "__main__": asyncio.run(main())这里有个关键点:ClientSession要全局复用,不要每下载一张图就 new 一个 Session。Session 内部管理连接池,复用一个 Session 才能实现 TCP 连接复用,省掉重复握手的开销。每张图单独创建 Session 的话,异步带来的性能优势会大打折扣。
3. 实现一个健壮的异步图片批量下载器
3.1 URL 清单管理和任务去重
真实项目里,URL 来源可能是数据库、爬虫解析结果或者纯文本文件。第一步要做的不是下载,而是清洗 URL 列表。我见过很多人把带查询字符串的 URL、重复 URL 一股脑塞进下载器,导致目标站压力翻倍,本地也存了一堆重复文件。
建议先用集合去重,再过滤掉非图片后缀或不稳定的外链。比如:
def load_urls(path): with open(path, "r", encoding="utf-8") as f: raw_urls = f.read().splitlines() seen = set() clean_urls = [] for url in raw_urls: url = url.strip() if not url or url in seen: continue seen.add(url) clean_urls.append(url) return clean_urls如果 URL 里带签名参数(比如过期时间、token),清洗时要注意别把参数部分整个去掉,否则请求会失败。下载后的文件命名也要提前规划。最简单的是按序号命名,但如果后续要回溯来源,最好把文件名和 URL 的映射关系保存成 JSON 或 CSV,方便出错后排查。
3.2 用信号量控制并发与流量整形
异步下载最忌讳的就是“无限并发”。你可以在一个循环里为 5000 个 URL 全部创建协程,事件循环也能调度,但目标服务器的连接数瞬间飙高,轻则响应变慢,重则触发风控甚至封 IP。控制并发最优雅的方式是asyncio.Semaphore。
信号量可以理解成一个“并发令牌池”。每个协程执行前先acquire(),结束后release()。限制并发数设为 20,就意味着同一时间最多只有 20 个请求在网络上飞。完整代码:
import asyncio import aiohttp import aiofiles from pathlib import Path async def download_one(session, url, target_path, semaphore): async with semaphore: try: async with session.get(url) as resp: if resp.status != 200: return False data = await resp.read() async with aiofiles.open(target_path, "wb") as f: await f.write(data) return True except Exception as exc: print(f"下载失败: {url} -> {exc}") return False async def download_many(urls, out_dir, limit=20): out_dir.mkdir(parents=True, exist_ok=True) semaphore = asyncio.Semaphore(limit) async with aiohttp.ClientSession() as session: tasks = [ download_one(session, url, out_dir / f"{idx}.jpg", semaphore) for idx, url in enumerate(urls) ] results = await asyncio.gather(*tasks) return sum(results)async with semaphore会在进入协程时获取令牌,退出时释放,非常简洁。并发数具体设多少,无法一刀切。如果目标是普通小型站点,控制在 10~20 就很稳;如果是速度不错的对象存储或静态资源 CDN,50~100 也能跑。我的习惯是从 20 起步,观察响应时间和失败率,再逐步调整。
3.3 超时、重试与退避策略
图片下载的失败率往往比想象中高,常见的失败原因包括连接超时、DNS 解析失败、远端主动断开、本地网络抖动。如果不对这些情况做处理,跑到最后可能缺上百张图。可靠的下载器必须重试,但重试不能太“凶”。
aiohttp.ClientSession支持全局超时配置,也可以为单个请求单独设置:
timeout = aiohttp.ClientTimeout(total=60, connect=10) async with session.get(url, timeout=timeout) as resp: ...total是整个请求到响应完成的整体超时,connect是建立连接的超时。图片文件如果偏大或服务器慢,要合理放宽total,否则大图可能还没传完就被中断。
重试建议配合指数退避:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒。这样既给了服务器喘息时间,也不会在失败瞬间集中风暴式重试。我封装过一个带重试的版本:
async def download_with_retry(session, url, target_path, semaphore, retries=3): for attempt in range(retries): try: return await download_one(session, url, target_path, semaphore) except Exception: if attempt == retries - 1: raise await asyncio.sleep(2 ** attempt)这里要注意download_one已经持有信号量,download_with_retry在外面再包一层的话,不要让重试版本再去拿同一个信号量,否则等待重试期间令牌一直被占着,可能拖慢整体进度。上面这个例子里信号量只在download_one内部获取。
3.4 文件落盘与临时文件管理
下载完成后写文件,涉及磁盘 I/O。虽然aiofiles是“异步写文件”,但最终还是要经过操作系统文件系统,并不存在魔法。它解决的是“写大文件时不让事件循环卡住”的问题,推荐在并发场景下使用。
更稳妥的落盘方案是先写临时文件,再改名。原因很直接:如果下载到一半程序崩溃,残留的半个文件会被误认为完整图片;而且多个任务同时写文件时,直接写最终路径可能在文件系统层面产生竞争。伪代码如下:
tmp_path = target_path.with_suffix(".img.tmp") async with aiofiles.open(tmp_path, "wb") as f: await f.write(data) tmp_path.replace(target_path).replace()在同个文件系统内属于原子操作,目标路径一旦出现,就代表文件完整。这个习惯在做大数据集采集时特别有用,后面校验文件完整性时可以放心地只检查最终文件。
4. 性能优化进阶与实测对比
4.1 连接复用与会话生命周期
aiohttp 的ClientSession内部维护了一个连接池。默认情况下,请求同一个域名时会复用现有连接,大幅减少握手开销。要让连接池发挥最大作用,有几点值得注意:
- Session 全局创建一次,所有任务共享。
- 请求时尽量使用同一实例,避免频繁
async with ClientSession()。 ClientSession结束时需要await session.close(),否则连接可能不会被干净地释放。用async with包住整个流程是最省心的。
还有一个容易被忽略的参数TCPConnector。通过它控制连接池大小和开启 keep-alive:
connector = aiohttp.TCPConnector(limit=50, limit_per_host=20, force_close=False) async with aiohttp.ClientSession(connector=connector) as session: ...limit控制整个 Session 最多同时打开多少连接,limit_per_host控制到单个主机的并发连接。这两个值和前面的信号量配合使用,可以更精准地控制流量。举个例子:信号量收成 20,limit_per_host设成 20,就能保证同一时间对同一站点最多 20 个请求。
4.2 并发数如何调优
很多初学者有一个误解:并发数越大速度越快。实际上存在一个拐点,超过拐点后目标服务器响应变慢、请求排队、超时率上升,整体吞吐量反而下降。调优方法很简单:选一段有代表性的 URL 子集,分别用 5、10、20、50、100 跑一轮,记录总耗时、成功数和失败数,做成小表格对比。
我这里给一组参考数据(1000 张图,平均单张 300KB,目标为普通图床):
| 并发数 | 总耗时 | 失败数 | 备注 |
|---|---|---|---|
| 1 | ~480 秒 | 0 | 稳定但太慢 |
| 10 | ~55 秒 | 0 | 推荐起步值 |
| 20 | ~28 秒 | 0 | 最常见选择 |
| 50 | ~20 秒 | 2 | 出现零星超时 |
| 100 | ~18 秒 | 8 | 失败率明显上升 |
从这组数据能看出,并发数从 10 提到 20,收益显著;从 50 提到 100,耗时几乎没降,失败率却涨了上来。如果是面向生产环境的采集任务,我宁可用 20 并发跑久一点,也不想为了那几秒去承担封 IP 和失败重试的代价。
4.3 常见性能瓶颈排查
异步下载跑得慢,不一定是并发不够。常见瓶颈还包括:
- DNS 解析慢:如果域名解析耗时严重,可以提前把 IP 解析好直接请求,但这会失去 CDN 调度的灵活性,不建议轻易尝试。
- 文件写入路径的磁盘性能:大量小文件并发写入机械硬盘时效率很低,最好落到 SSD。如果图片数量巨大,可以考虑按日期或字母分目录散列,避免单个目录塞下几万文件。
- 代理和防火墙:企业网络出口可能对并发连接数有限制,表现就是偶尔超时或重置,这类问题只能靠降低并发解决。
- 目标服务器带宽限制:单看本地带宽没用,目标服务器给单个 IP 的带宽往往受限。这时候再怎么加并发也是事倍功半,换个时间段或降低频率更靠谱。
排查时建议给每个协程记录阶段耗时,比如“请求耗时”“下载耗时”“写盘耗时”,打印或写入日志。看到真实数据后,你往往能一眼定位瓶颈。
5. 我在实际项目中踩过的坑
5.1 事件循环被关闭的诡异报错
用asyncio.run()跑过一次任务后,如果 IDE 或脚本框架内部又创建了新的事件循环,偶尔会遇到RuntimeError: Event loop is closed。这个报错多出现在多次调用asyncio.run()或把asyncio.run()嵌套在另一个事件循环里。
解决办法是尽量让asyncio.run()作为最外层入口,只调用一次。如果服务端框架(比如某些 Web 服务)自己维护了事件循环,你就不要在请求处理函数里再调用asyncio.run(),直接在该事件循环里创建 Task 就好。Jupyter 这类交互环境也有自己的事件循环,同样要注意。
5.2 TCP 连接重置与连接池耗尽
跑大规模下载时,ConnectionResetError是最常见的信息。它通常意味着目标服务器主动断开了闲置连接或过多连接。我第一次跑 5000 张图时就遇到大量 reset,排查后发现是本地并发开到了 200,远超目标服务器承受能力。
后来我把并发降到 30,同时给请求设置了TCPConnector(limit=30),重置概率大幅下降。另外一个技巧是设置heartbeat或者缩短keepalive_timeout,避免长时间占着空闲连接被远端回收。
5.3 内存中堆积了太多未完成的 Task
用列表推导批量创建几千个协程任务时,如果某个 URL 对应的服务器长时间不响应,这些协程会一直挂在事件循环里,占用内存和 socket 资源。尤其当目标服务器整体宕机时,所有任务都在等待超时,内存占用会飙升。
一个有效的优化是分批调度,而不是一次性gather全部任务。比如每批 200 个任务,跑完一批再提交下一批:
BATCH_SIZE = 200 for i in range(0, len(urls), BATCH_SIZE): batch = urls[i:i + BATCH_SIZE] tasks = [download_with_retry(session, url, ...) for url in batch] results = await asyncio.gather(*tasks)分批不仅控制内存,也让错误范围和观测粒度更清晰。某一批失败太多时,可以直接中止任务,而不是让整体陷入长时间卡顿。
5.4 链接失效、防盗链和下载完整性校验
批量下载最容易被忽略的是服务器防盗链机制。有些图床会校验Referer或User-Agent,直接请求会返回 403。处理方式是在 Session 或请求头里带上合理的Referer和User-Agent:
headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": "https://example.com/", } async with session.get(url, headers=headers) as resp: ...至于下载完整性,千万不要只拿resp.status == 200当成功标志。有些服务器会返回一个 200 的 HTML 错误页,你存下来才发现是个 5KB 的文件。建议检查Content-Type是否为image/*,同时校验文件字节数是否大于某个阈值。更严格的做法是保存图片后读取文件头,确认魔数是否为 JPEG、PNG、WebP 等格式。这个过程可以用 Pillow 做,但对大量文件来说速度较慢,我一般只做Content-Type和最小大小双重校验。
6. 给新手的落地建议
异步下载图片本身是个很经典的入门项目,学会之后可以平移到很多场景:批量抓取 PDF、同步多个 API、消息队列消费、音视频素材下载等等。我个人建议新手先不要纠结底层原理,把最小可运行的下载器跑到通,再用信号量加并发,最后逐步加超时、重试、文件校验。每加一个模块就测试一次,不要等到代码全写完再整体调试,否则出了问题根本不知道从哪查起。
还有一个习惯值得培养:在下载脚本里加进度输出。最简单的做法是搞一个计数器,每完成一个任务就打印一次进度。如果不想刷屏,可以用tqdm的异步版本tqdm.asyncio,它和asyncio.gather配合得很好。我会在任务完成回调里更新进度条,看到进度条平稳前进,心里就有底。
关于上线跑大规模任务,我的体会是:先拿 50 张图做冒烟测试,确认文件名、路径、格式都没问题,再跑全量。全量任务最好分批执行,而且给脚本加上断点续传能力——下载前检查目标文件是否已经存在且大小正常,存在就跳过。这样中途崩了或者手动停止,重新跑一次只会补齐缺失部分,不用从头再来。这个特性其实只需要在download_one开头加几行判断,性价比极高。
另外,跑图片下载任务时请一定注意目标网站的访问规则和数据使用边界。下载公开素材是为了学习和研究没问题,但别说我没提醒你:控制并发、设置合理请求间隔是对目标服务器最基本的尊重。真正成熟的方案不是把速度拉到极限,而是在稳定的前提下高效完成自己的需求。
那次 3000 张图下载任务优化完以后,我还顺手把这个异步下载器改成了一个通用的小工具,喂给它 URL 列表就能自动分类存放、生成失败清单、输出统计报告。后续有人问起我 Python 异步实战经验,我都是直接拿这个项目当例子。技术点不多,但每一步踩坑和修正都值钱。