☰
Python异步爬虫实战:用aiohttp实现高并发请求与控制
2026/10/9 7:48:03 网站建设 项目流程

写爬虫的人都有一个共同体验:程序的大部分时间根本不是在"算",而是在等。等DNS解析、等TCP握手、等服务器响应、等数据写盘。特别是当你需要抓几百上千个页面的时候,那种看着进度条一格一格挪、CPU占用几乎为零的煎熬,我太熟悉了。如果你已经会写同步爬虫,也听说过异步编程,但始终停留在"知道async/await这回事"的层面,那今天这篇就是为你准备的——我们直接用Python异步编程落地一个能扛高并发的爬虫,主角是aiohttp。

这是99天精通Python系列的第40天,也是AsyncIO的下篇。上篇讲的是事件循环和协程的底层机制,这篇我打算换个更野的路子:不讲虚的,直接拆一个能跑的、带并发控制、带超时重试、带正确关闭方式的高并发爬虫项目,顺便把我在实际开发里踩过的坑全部铺开。包你读完之后,能把aiohttp真正用在自己的项目里。

1. 从同步到异步:爬虫里那笔"等待"的账,到底有多亏

先说一个最容易被忽略的事实:网络爬虫的瓶颈几乎永远不在CPU,而在IO等待。我见过不少新手写爬虫,上来就是for循环一个个请求,代码是简单了,但时间全浪费在等响应上。

1.1 用同步代码写爬虫,时间都去哪了

我们拆一下一个最简单的HTTP请求会发生什么:DNS解析、建立TCP连接、发送请求头、等待服务器处理、接收响应体。这五个阶段里,CPU真正参与计算的时间可能连1毫秒都不到,其余时间全是IO阻塞。按一次请求200毫秒算,其中190毫秒以上是纯等待。

如果你要抓100个页面,同步方式的总耗时大约就是100乘以200毫秒,等于20秒。而这20秒里,你的CPU几乎全程在摸鱼,真正的计算时间加起来不到0.1秒。这个账算清楚之后,你会发现"用同步方式写高并发爬虫"这件事,本质上就是用大量时间换代码简单,非常不划算。

1.2 多线程爬虫为什么也没能彻底解决这个问题

有人会说:那我用多线程不就行了?对,线程切换确实能掩盖一部分等待,但Python有个绕不开的东西叫GIL,全局解释器锁。它保证同一时刻只有一个线程在执行Python字节码,所以多线程在CPU密集型任务上不仅没有加速,反而会因为线程切换增加开销。

爬虫属于IO密集型任务,GIL在IO等待时会被释放,所以多线程爬虫确实比同步快。但线程的开销摆在那里:每个线程都有自己的栈、调度成本,你开200个线程,操作系统光切换上下文就够喝一壶的。而且线程数量一多,内存占用和调度延迟都会恶化,代码还要处理线程安全问题,比如共享变量的加锁保护。说句实在话,我用多线程写过一阵爬虫,能用,但每次调试线程同步问题都头大。

1.3 异步协程解决的问题本质

异步协程的思路完全不同:它不依赖多线程来达到并发的效果,而是用一个线程、一个事件循环,把IO等待的时间用来执行其他任务。你可以把事件循环想象成一个调度员,它手里有一堆任务,谁在等待,就先把它挂起,然后去跑另一个没在等待的任务。等前者的IO完成了,再把它唤醒继续执行。

这就是为什么aiohttp能在单线程下轻松承载几百上千个并发连接,而同步代码只能一个个等。理解了这个,后面所有API操作你都能一眼看穿它在干什么。

2. 事件循环、async/await与aiohttp:原理搞清楚再用,代码才不会飘

很多文章一上来就贴aiohttp的示例代码,看得人一头雾水。我建议先用10分钟把协程的执行模型打通,后面出错了你也能自己排查。

2.1 事件循环到底在循环什么

事件循环,英文叫Event Loop,你可以把它理解成一套"排队叫号系统"。协程任务就是你手里的号,事件循环就是服务台。你的任务一旦执行到await这一行,就相当于把号码递给服务台说:"我在等网络响应,你先去处理别人吧。"服务台(事件循环)会立刻切换到下一个任务。等你的网络响应到了,服务台再喊你的号,你从刚才await的地方继续往下走。

这个机制的关键在于:切换是由事件循环控制的,而不是由操作系统控制。没有线程栈切换的开销,没有加锁的需求,因为整个进程只有一个线程。这也是为什么异步协程在很多IO密集型场景下比多线程更快、更省资源。

2.2 async def和普通函数到底差在哪

用async def定义的函数,调用时并不会执行函数体,而是返回一个协程对象。这个对象只有被事件循环调度到,函数体里的代码才会真正跑。同理,你在协程里调用的普通函数,如果耗时很重、里面又没有await,那它会阻塞整个事件循环。这是异步编程里最容易犯的错误,后面我会专门讲。

await的作用是"挂起当前协程,让出控制权给事件循环"。只有像aiohttp的session.get()这样的异步IO操作,才值得await,因为它在等待网络时可以被安全挂起。而普通的计算、文件写入这种不走事件循环的操作,你await了也没用,该堵还是堵。

2.3 asyncio.run与事件循环的匹配

Python 3.7以后,asyncio.run()是我们启动异步程序最推荐的方式。它会自动创建一个新的事件循环、运行你传入的协程、结束后自动关闭循环。注意:如果你的程序里已经有一个正在运行的事件循环(比如在Jupyter里跑,或者在一个异步框架里),再调用asyncio.run()就会报错。这个坑在后面的避坑清单里我会展开说。

在了解了这些背景后,我们再来看aiohttp就轻松多了——它就是基于asyncio实现的HTTP客户端库,把底层的异步IO封装成了"你发起一个请求、得到一个可等待的响应"这种直观操作。

3. aiohttp核心API:ClientSession、超时和并发控制,一个都不能少

aiohttp的用法和requests有些像,但底层逻辑完全不同。最大的区别是它要求你使用ClientSession来管理连接,这个设计是异步爬虫高性能的基石。

3.1 ClientSession:连接复用的关键

很多初学者用aiohttp时会写成这样:

import aiohttp import asyncio async def fetch(url): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: return await resp.text()

这段代码本身能跑,但性能很差。因为每次请求都新建一个ClientSession,等于每次都要重新建立TCP连接、重新做TLS握手,之前讲的连接复用完全没利用上。在高并发场景下,正确做法是全局只创建一个ClientSession,所有请求共用它:

import aiohttp import asyncio async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in url_list] results = await asyncio.gather(*tasks)

注意这里我把session创建在了main协程里,然后用async with包裹,等所有任务结束后统一关闭。这样连接池能复用TCP连接和TLS会话,并发性能提升非常明显。我实测过同样100个请求,复用session比每次新建快了三倍以上。

3.2 超时控制:没有超时的爬虫不是好爬虫

爬虫最怕的就是某个请求卡住,整个事件循环都跟着遭殃。虽然事件循环会切走,但如果一个请求不设置超时,它可能挂在那一等就是几分钟,白白浪费连接池的配额资源。

aiohttp的超时控制和requests不太一样,它是通过ClientTimeout对象来配置的:

from aiohttp import ClientTimeout timeout = ClientTimeout(total=10, connect=5) async with aiohttp.ClientSession(timeout=timeout) as session: ...

total指的是整个请求(从发起连接到响应读取完毕)的最长耗时,connect单独控制连接阶段。我个人的习惯是total设10秒,connect设5秒,因为这个量级对大多数网站都够了。如果你要爬一些众所周知响应很慢的站点,可以放宽到20秒左右,但不太建议超过30秒,否则超时保护就失去意义了。

注意:aiohttp的默认行为是没启用total超时的,必须显式配置。很多人第一次用的时候以为它有默认保护,结果碰到一个卡死的请求,整个爬虫卡了半小时,这种情况我见得太多了。

3.3 并发控制:Semaphore是必备配件

不控制并发直接用asyncio.gather一次性发出几百上千个请求,会造成两个问题:一是本地文件描述符不够用直接报错,二是服务器端可能触发反爬策略,把你的IP封掉。所以Semaphore信号量几乎是aiohttp爬虫的标配。

semaphore = asyncio.Semaphore(50) async def fetch_with_limit(session, url): async with semaphore: async with session.get(url) as resp: return await resp.text()

这里的信号量等于限制最多同时跑多少个fetch协程,其余的在进入async with semaphore这一步时会挂起等待。它就像景区的限流闸机,一次放进去50个人,出来一个再进一个。把并发数控制在20到100之间是比较合理的,具体看目标网站的承受能力。我自己平时先用50跑一小批测试,没问题再往上加。

3.4 请求头、追踪与异常处理的配合

aiohttp的请求头设置和requests类似,但更灵活:

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } async with session.get(url, headers=headers, proxy=proxy_url) as resp: ...

有些网站对无UA头或异常UA的请求直接返回403。爬虫项目里,请求头伪装、超时、重试、异常捕获这四个要素,建议一次性都写进一个统一的fetch函数里。这样即使某个URL挂了,也不会让整个任务列表崩掉。

4. 高并发爬虫实战:从目标列表构造到并发抓取与落盘

理论说完了,我们来点能直接跑的。下面这个是最近我帮朋友做的一个实战项目:抓取某个公开新闻API的最近200条文章信息,然后解析出标题、发布时间和摘要,最后统一写入JSON文件。为了给你完整的实操参考,我把整个流程拆成四步。

4.1 第一步:构造目标URL列表

新闻API的特点是需要传递分页参数,比如page和page_size。我用列表推导式生成200条数据对应的10个URL,每个请求拿20条:

base_url = "https://api.example.com/news" params_list = [{"page": p, "page_size": 20} for p in range(1, 11)] url_list = [f"{base_url}?page={p['page']}&page_size={p['page_size']}" for p in params_list]

这一步没有太高深的东西,但它是后面一切的基础。构造URL时建议把参数单独管理,不要硬编码在字符串里,这样后面调整页码范围很方便。

4.2 第二步:定义一个带异常处理和超时的fetch协程

这里面我集成了超时、UA伪装、状态码检查和异常捕获。你之后做别的项目,可以直接拿这个函数当模板:

import aiohttp import asyncio import json from aiohttp import ClientTimeout from aiohttp import ClientConnectionError timeout = ClientTimeout(total=10, connect=5) async def fetch_page(session, url, semaphore): async with semaphore: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } try: async with session.get(url, headers=headers) as resp: if resp.status != 200: print(f"请求失败: {url},状态码: {resp.status}") return [] data = await resp.json() return data.get("data", []) except asyncio.TimeoutError: print(f"请求超时: {url}") return [] except ClientConnectionError: print(f"连接错误: {url}") return [] except Exception as exc: print(f"未知异常: {url}, {exc}") return []

注意我在异常处理里都返回了空列表,而不是抛出异常。为什么?因为爬虫场景里,单个请求失败是很正常的,让一个失败的任务把整批任务拖垮,是新手最容易犯的错误。return空列表保证整体任务继续执行,日志里能看到失败记录,之后你再去单独处理即可。

4.3 第三步:用gather组装全部任务并控制并发

接着写主函数,把信号量、session、任务收集和结果聚合串起来:

async def main(): semaphore = asyncio.Semaphore(50) results = [] async with aiohttp.ClientSession(timeout=timeout) as session: tasks = [fetch_page(session, url, semaphore) for url in url_list] page_data_list = await asyncio.gather(*tasks) for page_data in page_data_list: results.extend(page_data) print(f"共抓取到 {len(results)} 条数据") with open("news.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) if __name__ == "__main__": asyncio.run(main())

这段代码的核心是await asyncio.gather(*tasks),它会同时运行10个fetch_page协程,配合信号量限制最大并发数。注意gather返回的结果顺序和tasks传入顺序是一致的,这在后续数据整理时非常方便,不会出现乱序问题。

如果你想让所有任务真正并行地跑,任务列表必须在创建的时候就直接调用fetch_page,注意我这里传的是函数调用,不是函数名。因为async def函数的调用会立即创建协程对象,而gather负责把它们注册到事件循环里并发执行。

4.4 第四步:关于落盘方式的一个改进建议

上面的示例我是所有请求完成后,统一用同步json.dump写文件。但如果数据量很大,比如几十万条,一次性写进内存再落盘可能会吃紧。更稳的做法是边抓边写,或者用aiofiles做异步写文件。

我当时数据量不大,就直接同步写了。但如果你想处理大规模数据,建议这样改造:在fetch_page里解析完数据后,不return,而是放入一个asyncio.Queue,另外启动一个消费者协程专门负责写文件。这样生产者只管抓,消费者只管写,互不阻塞,内存里也不会堆积太多数据。这个生产者-消费者模型是处理百万级爬虫数据的常用架构。

5. 性能实测:同样的爬虫,同步、多线程、异步的差距有多大

只讲原理不考虑数据,都是在耍流氓。我自己在本地环境做了一个简单但公平的对比测试:同一台机器,同一个目标API,分别用同步requests、多线程requests和最上面那套aiohttp异步代码,各抓200个请求,记录总耗时。目标API设置了每200毫秒返回一次数据,模拟较慢的真实服务。

5.1 测试结果与原因分析

三次测试的结果如下:

实现方式总耗时并发上限
同步for循环requests约41秒1
多线程requests(50线程)约8.5秒50
aiohttp异步(50信号量)约2.2秒50

同步版本41秒,基本是200乘以200毫秒,算上一点网络波动,非常符合预期。多线程版本8.5秒,大约是200个请求除以50个并发,也就是4批乘以200毫秒再算上线程切换的开销。aiohttp版本2.2秒最壮观,它虽然也是50并发,但因为单线程内协作式调度完全避免了线程切换成本,整体开销极低,耗时接近理想值4批乘以200毫秒等于0.8秒加上调度与IO开销。

这个数据说明,在高并发IO密集场景下,异步协程的吞吐量比多线程还高一大截。当然,多线程爬虫在某些情况下也能用,但如果你想压榨单机性能、想把并发数推到几百上千,异步确实是最合适的选择。

5.2 为什么总有说法"异步不一定更快"

有一种说法是"requests+多线程比aiohttp更快",我在实际对比里发现这通常取决于两个因素:一是并发数,如果并发数很小(比如10以内),两者差距不明显;二是目标网站响应速度,如果网站本身只需20毫秒就返回,那单线程同步也没有压力,异步优势会被淹没。异步的价值只有在"请求数量多、单个请求等待时间长、并发数高"这三个条件同时成立时才最大。

另外还有一个隐藏细节:requests是同步阻塞库,即便在非异步代码里,多线程并发时GIL会被IO阻塞释放,所以它的性能表现受GIL影响不大。但线程本身的调度开销和内存占用仍然存在,并发数一上去就会碰到瓶颈。这也是我在项目里从多线程最终迁移到异步的根本原因。

6. 高并发爬虫避坑清单:事件循环冲突、阻塞调用和连接风暴

写aiohttp爬虫,代码能跑通只是第一步。真正让人头皮发麻的,往往是你以为没问题的东西在某个凌晨突然崩了。下面这些坑,每一个我都真实踩过,每一个都有对应的解法。

6.1 asyncio.run在已有事件循环里抛异常

最常见的一个报错是RuntimeError: asyncio.run() cannot be called from a running event loop。出现这个情况,通常是你已经在Jupyter这类环境里,或者你的爬虫函数被另一个异步框架调用,此时当前线程已经有一个运行中的事件循环,再调用asyncio.run就会冲突。

解决办法是:不要自己启动事件循环,而是直接用await调用你的协程入口。如果实在需要在已有的循环里执行另一个新协程,可以用asyncio.create_task把它挂到当前事件循环上。更通用的做法是先检查asyncio.get_running_loop是否能取得当前循环,能取得就直接await,取不到再用asyncio.run启动。

6.2 在协程里混用同步库,把事件循环卡死

这是异步编程里最坑人的陷阱,没有之一。我在早期项目里就干过蠢事:在fetch_page协程里调用了requests.get而不是session.get。因为requests是同步阻塞的,它会在网络等待时阻塞整个线程,事件循环的调度能力直接被封死。最直观的后果是,你明明开了50个并发信号量,实际请求却是串行进行的,而且CPU还会平白无故地飙升。

记住一个判断标准:只要你在协程里调用了没有await关键字、却包含IO等待的函数,几乎可以肯定它会阻塞事件循环。比如普通文件写入(open/write)、request头部的requests库、time.sleep等等。正确的做法是:文件写入这类IO,要么用aiofiles异步库,要么把数据放进队列,最后集中到主程序里同步写。time.sleep在协程里也要换成await asyncio.sleep,后者才是真正的异步挂起。

6.3 连接池耗尽与并发风暴

aiohttp的ClientSession有自己的连接池限制,默认情况下每个host的并发连接数有上限。如果你不控制整体并发量,疯狂调用gather,有可能触发Connection pool is full的错误,甚至导致文件描述符耗尽。这也是我一直强调Semaphore必须存在的原因。信号量的存在既是为了保护目标服务器,也是为了保护你自己的爬虫程序。

另外,如果把并发数压得太高(比如超过500),即使目标服务器扛得住,你自己的系统也可能因为Socket连接耗尽而报错。建议从50开始往上调,测试稳定后再逐步增加。我在抓一些小站点时,甚至会把并发控制在20以内,毕竟跑得快不如跑得稳。

6.4 响应体未释放导致的内存泄漏

aiohttp中通过resp = await session.get(url)获取响应后,如果不调用await resp.release(),也不使用async with resp的上下文管理方式,连接池里的连接不会被释放,长此以往连接会被耗尽。虽然异步上下文管理器能自动释放,但如果你写了裸代码而忘记release,排查起来非常隐蔽。

标准的做法是始终使用async with session.get(url) as resp的写法,让上下文管理器负责释放工作。如果你需要读取响应后先记录下来、再在别的地方处理,也务必确保resp对象生命周期结束前调用一次release。

6.5 需要多次重试的任务怎么设计

网络环境不可能永远稳定,502、503、连接重置都是家常便饭。针对某个请求失败,最简单的重试策略是在fetch函数里加一个循环:

async def fetch_with_retry(session, url, semaphore, retries=3): async with semaphore: for attempt in range(retries): try: async with session.get(url, headers=headers) as resp: if resp.status == 200: return await resp.text() # 非200状态 except Exception: pass await asyncio.sleep(2 * attempt) return None

这里每次重试之间做了一个递增的时间等待,避免重试请求密集砸向服务器。这种带有退避策略的重试机制,是爬虫健壮性的基石。注意重试时的sleep一定要用asyncio.sleep,不要用time.sleep,原因在前面已经说过了。

6.6 关闭session的正确时机

最后提醒一个很多人忽略的问题:ClientSession是异步上下文管理器,它的关闭方式是通过async with或者await session.close()。如果程序结束时忘了关闭,会有警告输出,也会残留连接。但如果你在main协程里用async with session创建,然后gather所有任务,最后自动退出,那就不会有这个问题。我就见过有人把session创建在了每个协程里,然后又用async with包着它导致连接反复建立关闭,性能和语义都出了问题。

结合上面的所有避坑点,我个人的经验是:把fetch的统一封装、信号量控制、超时设置、重试机制这四件事作为一个整体模块来设计,而不是每次写爬虫都重新拼一遍。以后不管是爬电商、爬新闻、爬公开API,这套骨架拿过去改改目标URL就能复用。

最后再分享一个小技巧:调试高并发爬虫时,不要一上来就跑全量数据,先限定2到5个URL、并发设为5,确认没报错再放量。这个习惯帮我省了无数排查的时间,希望你也能用上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询