news 2026/10/20 17:26:47

Python异步爬虫实战:从8分钟到40秒的优化笔记

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python异步爬虫实战:从8分钟到40秒的优化笔记

今天是2026年3月29日,周日。这篇学习日记要记的东西有点特别——我没有背单词,也没有看网课,而是花了一整个下午,把之前写的一个同步爬虫脚本改成了Python异步版本。起因很简单:一个每天要跑七八分钟的数据处理脚本,让我实在忍无可忍了。这次学习Python异步编程的经历很有代表性,踩的坑不算少,但最终效果也足够直观:同样是抓取320多个API接口,运行时间从8分多钟压到了40秒左右。如果你也写过类似的脚本,或者对async/await一直处于“好像懂了又好像没懂”的状态,这篇日记应该能给你一些参考。

1. 为什么今天学这个:那个跑了八分钟的脚本

1.1 同步脚本到底慢在哪:I/O等待的真相

先说背景。我手上有一个每天要跑的数据处理脚本,逻辑很简单:读取一份包含320个URL的CSV文件,依次对每个URL发HTTP请求,拿回JSON数据,解析出需要的字段,最后汇总成一份Excel表格。最初这份脚本是用requests库同步串行写的,核心逻辑大概长这样:

import requests import pandas as pd urls = load_urls_from_csv("urls.csv") results = [] for url in urls: resp = requests.get(url, timeout=5) data = resp.json() results.append(parse_item(data)) pd.DataFrame(results).to_excel("output.xlsx", index=False)

代码看是很简单,但跑起来真的要命。每个请求发出去之后,程序就傻傻地站在原地等服务器响应。我观察过,单个请求通常在0.5到2秒之间,偶尔会有慢到4秒的。按平均1.5秒估算,320个请求串行跑,就是480秒,整整8分钟。这期间看一眼任务管理器,CPU利用率低得离谱,绝大部分时间进程都只是在“等”。程序明明什么都没干,却让使用它的人等得心焦。

这里要区分两个概念:I/O密集和CPU密集。像HTTP请求、数据库查询、文件读写这类操作,都属于I/O密集。它们的共同特点是:CPU本身不需要做太多计算,真正耗时的是等待外部设备返回数据。在等网络数据到达的这段时间里,CPU几乎是空闲的,只是在等内核通知它“数据来了”。我那个脚本就是典型的I/O密集型任务,320次请求里,每次至少95%的时间是纯等待。

1.2 为什么不是多线程:GIL与开销的权衡

对于这种I/O密集型的场景,很多人的第一反应是用多线程,我之前也这么写过。用concurrent.futures.ThreadPoolExecutor确实能快一些,因为线程在等待I/O的时候会被操作系统挂起,让出CPU给其他线程用。虽然Python有个著名的GIL(全局解释器锁),它限制一个进程内同时只能有一个线程执行Python字节码,但这个锁在线程遇到阻塞式I/O时会被释放,所以多线程在这种情况下并不是完全无效。

但我试了几次之后发现几个麻烦事:第一,线程本身开销不算小,每个线程要占用额外的内存和内核资源,320个线程同时跑,调度成本摆在那里;第二,如果开满320个线程同时发请求,不仅本机的资源被拉高,对方服务器也扛不住,很容易触发限流或者直接被封IP;第三,多线程如果涉及共享变量,还得考虑加锁、线程安全之类的破事,代码复杂度一下就上来了。

当然,我也不是说多线程方案不能用,实际上ThreadPoolExecutor配上最大并发数的限制,在这个场景下也能跑出不错的效果。但今天是想认真学点新东西,加上这个场景本身就是事件循环最典型的应用舞台,干脆就把asyncio好好啃一遍。

1.3 asyncio的适用边界:它解决什么问题

先说结论:asyncio特别适合“大量等待外部I/O、但CPU计算量很小”的任务,核心思想是在单线程内通过协作式调度,让多个任务在等待I/O时相互穿插执行。当任务A发出一个请求后,它会把控制权主动交还给事件循环,事件循环接着去执行任务B;等A的响应回来了,事件循环再恢复A继续往下跑。这样虽然只有一个线程在工作,但通过不断切换任务,让等待时间被其他任务的执行时间填满,整个程序的吞吐量就上来了。

但这不是银弹。遇到CPU密集型任务,比如视频编解码、大规模数值计算、加解密之类的场景,asyncio反而可能更慢。因为这类计算在执行时并不释放控制权,事件循环没有机会切换任务,再加上纯Python解释执行本身就不如C库快,硬要用asyncio去并行计算,效果会比普通同步代码还差。所以今天拿HTTP请求来练手,算是找对了场景。

2. 核心语法与设计思路:先搞懂事件循环再动手

2.1 事件循环:像个单线程餐厅服务员

学异步编程,最难的不是语法,而是建立正确的心智模型。网上很多教程一上来就讲事件循环、回调、Future,看得人头大。我琢磨了一下午,发现用餐厅服务员来类比最好懂。

想象一家餐厅只有一个服务员(这就是单线程)。如果服务员每给一桌客人上完菜,就站在旁边等着他们吃完,再服务下一桌,那么一个饭点只能服务一桌客人,其他客人全在门口排着——这就是同步版本的代码,一个HTTP请求没回来,后面的请求就全部堵着。而事件循环的思路是:服务员把菜端上桌,记下“这桌客人要加水”,然后立刻去接待下一桌客人。过一会儿转回来一看,菜吃完了,就撤盘子;水喝完了,就去加水。虽然全程只有一个人,但他通过“记住谁在等什么,谁先就绪就先处理谁”,硬是把一整个餐厅的活都干完了。

对应到代码层面,await的作用就是“服务员记下当前要做的事,然后把控制权交还给餐厅经理(事件循环)”。当await后面的I/O操作完成时,事件循环会把控制权交还给这个协程,让它从刚才挂起的地方继续执行。这个“挂起-恢复”的机制,就是整个asyncio的运转核心。

2.2 async/await与Task:什么时候该创建任务

有几个基本概念今天必须搞清楚,不然写代码就是靠猜:

  • 协程函数:用async def定义的函数。注意,调用一个协程函数并不会执行函数体,而是返回一个协程对象。
  • 协程对象:本身没有执行,必须被事件循环调度。调度方式有两种:直接用await等待它执行完成,或者把它包装成一个Task。
  • 任务(Task):把协程包装成任务并交给事件循环后,这个协程就会在后台参与调度,不等代码主动去await它,只要事件循环有机会就会让它跑。不过最终还是得有await来获取结果,否则程序退出时任务可能还没跑完。

新手最容易犯的错是:定义好async函数后,在另一个async函数里直接调用它但不加await。结果就是协程对象被创建后立刻丢弃,函数体压根不会执行,而且如果没有启用特定警告,程序还不会报错,只是什么都不发生。所以记住这句话:async函数不配合await,就是空转。

具体场景怎么选,我总结了一下:

  • 如果多个协程之间互不依赖,希望并发执行并且要拿到所有结果,用asyncio.gather(*tasks)最省事。
  • 如果有一个协程想让它先在后台跑起来,过一会儿再取结果,用asyncio.create_task。
  • 如果只是在单个协程内部按顺序做几件事,直接按顺序await就够了,不必强行创建任务。

2.3 并发控制:Semaphore与信号量

如果天真地给320个URL每个都创建一个Task,那就等于瞬间发出320个并发请求。对方服务器的网关大概率会直接拒绝一批连接,或者干脆返回429限流。所以必须要限制同时在飞(in-flight)的请求数量。

asyncio.Semaphore就是干这个用的。它是一个异步信号量,内部维护一个计数器,进入时做acquire操作让计数器减一,退出时做release让计数器加一。计数器降到0时,新的acquire会被阻塞,直到有其他协程释放信号量。写成代码就是:

sem = asyncio.Semaphore(10) async def fetch_with_limit(session, url): async with sem: # 只有拿到信号量,才能进入这里的请求逻辑 ...

这里async with sem会自动处理获取和释放,即使请求抛异常也能保证信号量最终归还,不会把自己或者别人堵死。为什么选10这个数字?我一开始设过50,发现对方接口偶尔会有429响应,降到10之后稳定了很多。这个具体数值取决于目标服务的限流规则,保险起见从低到高慢慢试,别一上来拉满。

2.4 几个最容易混淆的概念梳理

学习过程中我发现有几个概念特别容易混淆,专门整理了一个对照表方便自己记忆:

概念一句话理解常见使用场景
协程对象async def函数调用后得到的对象,还没开始执行需要被await或包装成Task
Task已交给事件循环调度的协程,会在后台排队执行用asyncio.create_task()创建
Future低层异步原语,表示一个未来才完成的结果Task就是Future的子类,日常用Task就够
事件循环整个异步程序的总调度器,负责安排协程运行通过asyncio.run()自动创建
gather同时等待多个协程/任务完成,按传入顺序返回结果批量并发获取多个结果
create_task创建任务但不立即等待,让它在后台跑需要后台常驻任务时使用

尤其是gather和create_task的区别,我一开始总搞混。简单说:create_task负责把协程“丢进”事件循环后台跑,它本身不会等结果;gather则是对一批协程/任务做汇总等待,一次性拿到所有结果。两者经常配合使用。

3. 实操过程:把脚本从8分钟改到40秒

3.1 原始代码与运行基线

动手之前一定要先有基线数据,不然不知道优化有没有效果。我先把旧脚本用time模块包了一层,完整跑了一遍320个URL,最终耗时8分12秒。这个数据就是对照组。

这里也建议你做任何性能优化前,先把“优化前的运行时间”用数字记录下来,而不是凭感觉说“挺慢的”。后面跑完对比一看,8分钟压到40秒,那种直观的成就感比任何教程都更能帮你理解异步的价值。

3.2 第一版异步改写:直接套async/await的结果

我一开始以为异步改造就是把函数改成async def,requests改成await requests.get,于是先写了一个最小测试:

import asyncio import requests async def fetch_something(url): resp = requests.get(url, timeout=5) return resp.json() async def main(): result = await fetch_something("https://example.com/api") print(result) asyncio.run(main())

这段代码能跑通,timeout也能正常工作,但它完全没有并发效果。原因很简单:requests.get是同步阻塞函数,它会直接卡住当前线程的事件循环。当第一个协程执行到requests.get时,整个事件循环被这个阻塞调用占住,其他协程根本没有机会运行。等这个请求回来了,函数返回,事件循环才有空去调度下一个协程。所以这个版本只是“用异步语法写的同步代码”,本质上还是串行。

这也是很多初学者卡壳的地方:异步不等于自动并发。要让并发真正发生,关键不是把函数声明成async,而是确保内部的I/O操作本身是非阻塞的,必须是支持异步的库。我最终换了aiohttp,这才是真正的异步HTTP客户端。

3.3 引入aiohttp与Semaphore的完整实现

最终的完整实现大概是这样:

import asyncio import aiohttp import pandas as pd async def fetch_one(session, url, sem): async with sem: try: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: if resp.status != 200: return {"url": url, "error": resp.status} data = await resp.json() return parse_item(data) except Exception as e: return {"url": url, "error": str(e)} async def main(): urls = load_urls_from_csv("urls.csv") sem = asyncio.Semaphore(10) async with aiohttp.ClientSession() as session: tasks = [asyncio.create_task(fetch_one(session, url, sem)) for url in urls] results = await asyncio.gather(*tasks) pd.DataFrame(results).to_excel("output.xlsx", index=False) asyncio.run(main())

几个细节值得说明。第一,aiohttp.ClientSession要全局复用一个实例,不要每个请求都新建session,否则TCP连接没法复用,每次都要重新握手,那又是新的开销。第二,超时参数在aiohttp里推荐用aiohttp.ClientTimeout(total=5)显式声明,而不是直接在get方法里传一个数字。第三,每个请求的异常都在fetch_one内部捕获并返回错误信息,而不是抛出去让gather终止。这样即使有几个URL挂在网络超时,也不会让整个任务全部失败。

3.4 实测结果对比与收益分析

改完后我做了一组对比测试,结果非常直观:

方案总耗时成功请求数备注
同步串行8分12秒320原始代码
异步+Semaphore(1)8分5秒320并发数1,验证异步语法
异步+Semaphore(10)41秒320稳定无限流
异步+Semaphore(50)35秒312出现少量429限流响应

从8分钟到40秒,大约是12倍的提升。把并发数从10调到50,时间只是从41秒降到35秒,但出现了8个请求失败,代价完全不成比例。实际做的时候要记住这个道理:并发数并不是越高越好,找到目标服务能承受的上限,留出安全余量,才是长期稳定的做法。

3.5 补充:超时、重试与错误隔离

第一版异步脚本跑通后,我又发现几个生产环境绕不开的问题。

一是某些接口不稳定,偶尔会超时或者返回5xx错误。解决办法是给请求加一个简单的重试机制,我用了指数退避,也就是第一次重试前等2秒,第二次等4秒,最多重试3次。第一次重试后如果还失败,再等4秒,第二次重试后再失败就放弃并记录错误:

async def fetch_with_retry(session, url, sem, retries=3): for attempt in range(retries): async with sem: try: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: if resp.status == 200: return await resp.json() elif resp.status >= 500: # 服务端错误,等一会再试 pass else: return {"url": url, "error": resp.status} except Exception: pass if attempt < retries - 1: await asyncio.sleep(2 ** attempt) return {"url": url, "error": "failed after retries"}

注意我的写法:async with sem是在每次请求前才获取信号量,重试等待的sleep期间并不占用并发名额。这样等待重试的协程不会堵住其他请求的路,整体吞吐会更好。

二是错误隔离。即使加了重试,也总会有彻底失败的请求,不能让单个失败拖垮整批任务。这就是为什么我在fetch_one内部用try/except把所有异常都兜住,要么返回解析后的数据,要么返回一个带error标记的结果。这样最后查看Excel时,哪些URL失败了,失败原因是什么,一目了然。

4. 学习中最容易踩的坑:今天踩过的三个

4.1 坑1:在协程里写同步阻塞代码

前面提过,我第一版异步改写里用了requests.get,结果实际运行时间几乎没变化。这个坑极其隐蔽,因为程序能正常跑,结果也正确,只是没有并发效果。怎么发现不对的?我在协程入口和出口各加了一行打印日志,观察执行顺序。

如果看到的是“A开始 -> A结束 -> B开始 -> B结束”,说明这就是串行执行,完全没有异步。正确的并发日志应该是“A开始 -> B开始 -> ... -> B结束 -> A结束”,任务之间是交叉执行的。这个诊断方法非常通用,以后遇到“异步不生效”的怪问题,建议先做这个最小实验。别急着看什么高级工具,一个print就能定位80%的问题。

4.2 坑2:jupyter里运行asyncio.run的冲突

在写最小测试的时候,我习惯性用了Jupyter Notebook来跑代码,结果一执行asyncio.run(main())就直接报错,错误信息是RuntimeError: This event loop is already running。查了一下原因,Jupyter的ipykernel本身已经启动了一个事件循环,而asyncio.run()的规定是“只能在没有任何运行中事件循环的情况下调用”,两边就冲突了。

解决办法有两个。在Notebook环境下,直接写成await main(),让IPython自带的事件循环来调度;在独立.py脚本里,再用asyncio.run(main())。这个坑不坑在代码本身,而是会让你以为是自己写的异步逻辑有问题,实际完全不是。所以如果你也在Notebook里做实验,记住这个区别。

4.3 坑3:误以为gather是按完成顺序返回结果

另一个让我困惑了一会儿的错误写法是:

results = [await task for task in tasks]

初看似乎没问题,也确实能拿到结果,但它的执行机制完全错误。列表推导式会先等待第一个任务完成,再去取第二个任务的结果,虽然所有任务其实已经在后台并发运行,但代码逻辑变成了“有序等待”,一旦第一个任务很慢,这行代码就会一直卡在它那里,后面早已完成的任务结果也不会提前被处理。如果任务之间有依赖关系,这种写法还会带来隐患。

正确写法是results = await asyncio.gather(*tasks),它一次性等待所有任务完成,而且返回结果的顺序与传入顺序一致,不会因为某个任务快就乱掉顺序。如果确实想按完成顺序逐个处理,可以用asyncio.as_completed,它返回一个迭代器,谁先完成就先被取出。今天这个区别值得写进笔记里。

4.4 调试技巧:all_tasks、回退日志、最小复现

还有几个调试技巧是今天摸索出来觉得很有用的。第一,asyncio.all_tasks()能返回当前事件循环中所有未完成任务的集合,如果怀疑有协程泄漏或者卡住不退出,用它看一眼就知道还有哪些任务在跑。第二,打印日志时可以用asyncio.current_task()获取当前任务的信息,这样多任务并发时每一行日志是谁打出来的就清清楚楚。第三,遇到并发结果诡异的情况,别顶着320个URL调试,先构造一个最小复现:只拿10个URL跑一遍,并发数设成2,逐行分析,问题很快就能定位。

5. 写给自己:今天的学习心得与后续计划

5.1 什么情况下才该用asyncio

今天最大的收获,是明白了异步不是银弹。判断一个任务适不适合asyncio,我给自己定了个很简单的标准:如果一个脚本90%的时间都在“等”,那就值得用;如果90%的时间在“算”,那就别用。等网络、等数据库、等文件,这些都属于前者。算哈希、跑数值、处理图像,这些都算后者。我那个数据处理脚本显然属于“等”字当头的类型,所以才会有12倍的提升。

5.2 剩余可优化的点

这次改完已经够用了,但还有几个方向我准备找时间继续研究。一是今天只用了aiohttp,以后想试试httpx的异步模式,它的接口和requests更接近,代码写起来会更顺手。二是有一个第三方库只有同步版本,没法直接用异步客户端,我想研究一下asyncio.to_thread,它可以把同步阻塞调用扔到线程池里去执行,尽量不阻塞事件循环。三是Semaphore的最佳并发数目前是拍脑袋定的,后续可以考虑做成可配置参数,根据对方服务的反馈动态调整。

5.3 一个值得记住的小技巧

最后记一个小技巧:asyncio.run(main())在main()运行结束后会顺手关闭事件循环。如果程序里还有后台任务没有完成,这个关闭动作可能会打断它们。所以如果你有“后台常驻任务”的需求,不能简单丢给asyncio.run就完事,得手动等待运行中的任务全部结束,或者改用loop.run_until_complete。今天虽然没有用到常驻任务,但这个隐患值得提前记住。

写到这里,我最大的感受是:异步编程其实不是一种高不可攀的技巧,而是一种思维方式的转变——学会在等待的时候放掉控制权,让别人先跑。这个道理放在代码里是事件循环,放在生活里就是排队的时候可以先做点别的事。今天这篇学习日记算是把asyncio的骨架搭起来了,剩下的血肉,等我用了to_thread、as_completed、StreamReader这些进阶用法之后,再来补一篇续集。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 17:26:47

FastAPI与LangGraph实战:构建生产级多轮对话Chatbot服务

1. 核心思路拆解&#xff1a;为什么要用FastAPI部署LangGraph1.1 LangGraph解决了Chatbot开发里的什么问题做Chatbot最难的地方&#xff0c;不是调大模型API&#xff0c;而是怎么管理多轮对话中的状态流转。早期用LangChain的时候&#xff0c;链式调用看起来很方便&#xff0c;…

作者头像 李华
网站建设 2026/10/20 17:26:10

技能提升的底层逻辑:从技能地图到场景复盘,打破假学习困境

最近几年我特别留意到一个现象&#xff1a;不管是刚入行的新人还是工作多年的老手&#xff0c;聊到“技能提升”都会不自觉地焦虑起来。书架上的书买了一摞、课程收藏了一堆&#xff0c;但真正能在工作中用得上的&#xff0c;往往还是那几个早就熟练的旧技能。我身边甚至有朋友…

作者头像 李华
网站建设 2026/10/20 17:25:35

LangGraph4j执行图原理解析:从蓝图到运行时

图执行阶段的内核拆解&#xff1a;从蓝图到运行时读源码读到图执行&#xff08;Execution Graph&#xff09;这一层&#xff0c;很多人会卡住。倒不是因为代码多难&#xff0c;而是脑子里缺一张“执行图到底在干什么”的完整地图。langgraph4j的图对象&#xff0c;建模的是状态…

作者头像 李华
网站建设 2026/10/20 17:25:27

REA模型:用资源、事件与参与者重构业务系统设计

看到“rea”这个代号的第一反应&#xff0c;我以为是哪个懒人把项目名打错了。直到把三个字母补全为 Resource、Event、Agent&#xff0c;我才意识到这其实是一套业务建模思想。它不是什么开源框架&#xff0c;也不是最新的网络热词&#xff0c;而是一套在会计与业务系统设计中…

作者头像 李华
网站建设 2026/10/20 17:25:08

压缩感知与密钥控制测量矩阵:图像压缩加密混合算法Matlab实现

压缩感知已经火了很多年&#xff0c;但真正把“压缩”和“加密”揉在一个框架里、还带一套完整Matlab实现的项目&#xff0c;依然是图像处理方向里少有的硬核选题。这个标题里的核心关键词有三个&#xff1a;压缩感知、密钥控制测量矩阵、图像压缩加密混合算法。很多初学者看到…

作者头像 李华
网站建设 2026/10/20 17:25:03

零基础学HTML+CSS:电商首页实战布局与避坑指南

简介&#xff1a;资源取自尚硅谷零基础前端入门课程&#xff0c;聚焦HTML与CSS技术&#xff0c;并附带尚品汇项目的完整源码&#xff0c;适合完全没有网页开发经验的学习者快速上手网页结构搭建与样式美化。该压缩包共收录三百五十九个文件&#xff0c;总体积为十五点一三兆字节…

作者头像 李华