相关爬虫知识点:[爬虫知识] 深入理解多进程/多线程/协程的异步逻辑
相关爬虫专栏:JS逆向爬虫实战 爬虫知识点合集 爬虫实战案例 逆向知识点合集
前言:
在之前文章中,我们深入探讨了多进程、多线程和协程这三大异步技术的工作原理及其在爬虫中的应用场景。现在,我们将通过一个具体的爬虫实战案例——爬取豆瓣电影 Top 250,来直观对比同步与异步爬取(包括多进程、多线程和协程)的实际效率差异。通过详细的代码示例和运行结果,你将亲身体验到异步化对爬虫性能带来的巨大提升。
一、同步爬取:一步一个脚印
同步爬取是最直观的爬取方式,程序会严格按照代码顺序执行,一个请求完成后才能进行下一个。这意味着在等待网络响应(I/O 操作)时,程序会一直处于阻塞状态,CPU 大部分时间都在空闲等待。对于需要访问多个页面的爬虫来说,这种方式效率极低。
代码实战与讲解
import os.path
import time
from itertools import zip_longestfrom lxml import etree
import requests
from DataRecorder import Recorder# 初始化excel文件
def get_excel():filename = 'top250电影_同步.xlsx'if os.path.exists(filename):os.remove(filename)print('\n旧文件已清除\n')recorder = Recorder(filename)recorder.show_msg = Falsereturn recorderdef get_msg():session = requests.session()recorder = get_excel()total_index = 1# 循环爬取250个数据for j in range(10):# 初始化爬取数据url = f'https://movie.douban.com/top250?start={j*25}&filter='headers = {'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36','referer':'https://movie.douban.com/top250?start=225&filter='}res = session.get(url,headers=headers).texttree = etree.HTML(res)# 获取其中关键数据titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')# zip_longest是防止如有某个数据不存在,无法将该数据组输出的情况for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')map_={'序号':total_index,'电影名':title,'评分':score,'短评':comment}recorder.add_data(map_)recorder.record()total_index+=1if __name__ == '__main__':# 计时start_time = time.time()get_msg()end_time = time.time()use_time = end_time - start_timeprint(f'共用时:{use_time:.2f}秒!') # 取后小数点后两位# 共用时:7.24秒!
分析: 同步爬虫会依次请求每一页,每页请求完成并处理后,才会开始下一页。总耗时累加了所有页面的网络请求时间和数据处理时间,效率最低。
二、多进程爬取:分而治之,并行加速
多进程利用操作系统级别的并行,每个进程拥有独立的内存空间和 Python 解释器。这意味着它们可以真正地同时在多个 CPU 核心上运行,从而规避了 Python GIL 的限制。对于爬虫,我们可以将爬取每一页的任务分配给不同的进程,让它们并行工作,最后再由主进程统一汇总数据。
代码实战与讲解
这里代码逻辑的编写明显不同于之前的同步爬取逻辑。
之前在同步爬取中,我们直接用自己写的for循环十次。但在后面的并发与异步编程中,我们逻辑都需要转换:将这十次for循环分开,并让每次for循环逻辑丢给并发,让并发跑。
因为如果我们直接将原先的大任务拆分成十个小任务的话,它并不能很好的执行,甚至在某些地方会出现混乱(比如原同步爬虫中的写入逻辑),必须重新规划原先的同步逻辑,将其细分
import os.path
import time
from itertools import zip_longestfrom lxml import etree
import requests
from DataRecorder import Recorder
import multiprocessing # 多进程# 初始化excel文件
def get_excel():filename = 'top250电影_多进程.xlsx'if os.path.exists(filename):os.remove(filename)print('\n旧文件已清除\n')recorder = Recorder(filename)recorder.show_msg = Falsereturn recorderdef get_msg(page_index):session = requests.session()# 初始化爬取数据url = f'https://movie.douban.com/top250?start={page_index*25}&filter='headers = {'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36','referer':'https://movie.douban.com/top250?start=225&filter='}res = session.get(url,headers=headers).texttree = etree.HTML(res)# 获取其中关键数据titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')data = []for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):# print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')data.append({'电影名':title,'评分':score,'短评':comment})return dataif __name__ == '__main__':start_time = time.time()recorder = get_excel()# 使用多进程爬取每一页pool = multiprocessing.Pool(processes=5)results = pool.map(get_msg,range(10)) # results为嵌套列表pool.close()pool.join()# 统一处理所有数据并录入total_index = 1for movies in results:for movie in movies:movie['序号'] = total_indexprint(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")recorder.add_data(movie)recorder.record()total_index+=1end_time = time.time()use_time = end_time - start_timeprint(f'\n共用时:{use_time:.2f}秒!')# 共用时:6.95秒!
分析: 多进程版本通过将每页的爬取任务分发到不同的进程并行执行,显著减少了总耗时。进程之间的数据独立性保证了爬取和写入的正确性。
三、多线程爬取:并发处理,I/O 高效
多线程在同一个进程内创建多个执行流,它们共享进程的内存。虽然 Python 的 GIL 限制了多线程无法真正并行执行 CPU 密集型任务,但在处理 I/O 密集型任务(如网络请求)时,一个线程在等待网络响应时会释放 GIL,允许其他线程运行。这使得多线程非常适合爬虫场景,能够在等待时并发地发起新的请求。
代码实战与讲解
逻辑思路与之前的多进程大致相同,仅需在原多进程的地方,将其方法改写成多线程即可。
import os.path
import time
from itertools import zip_longestfrom lxml import etree
import requests
from DataRecorder import Recorder
from concurrent.futures import ThreadPoolExecutor # 多线程# 初始化excel文件
def get_excel():filename = 'top250电影_多线程.xlsx'if os.path.exists(filename):os.remove(filename)print('\n旧文件已清除\n')recorder = Recorder(filename)recorder.show_msg = Falsereturn recorderdef get_msg(page_index):session = requests.session()# 初始化爬取数据url = f'https://movie.douban.com/top250?start={page_index*25}&filter='headers = {'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36','referer':'https://movie.douban.com/top250?start=225&filter='}res = session.get(url,headers=headers).texttree = etree.HTML(res)# 获取其中关键数据titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')data = []for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):# print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')data.append({'电影名':title,'评分':score,'短评':comment})return dataif __name__ == '__main__':start_time = time.time()recorder = get_excel()# 创建一个 最多同时运行 5 个线程 的线程池 executor用于并发执行任务。 with ... as ...:用上下文管理器,自动管理线程池的创建和销毁with ThreadPoolExecutor(max_workers=5) as executor:# executor.map(func, iterable) 会为 iterable 中的每个值并发执行一次 funcresults = list(executor.map(get_msg,range(10))) # 嵌套列表# 统一处理所有数据并录入total_index = 1for movies in results:for movie in movies:movie['序号'] = total_indexprint(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")recorder.add_data(movie)recorder.record()total_index+=1end_time = time.time()use_time = end_time - start_timeprint(f'\n共用时:{use_time:.2f}秒!')# 共用时:5.79秒!
分析: 多线程版本利用 GIL 在 I/O 阻塞时释放的特性,实现了并发的网络请求,从而缩短了总耗时。相对于多进程,它的资源开销更小,但仍需注意线程安全问题(此处因为每个线程有独立的 requests.session()
且数据返回后统一处理,所以未涉及复杂锁)。
四、协程爬取:极致并发,优雅高效
协程是一种用户态的轻量级并发机制,它不受 GIL 限制。协程的切换由程序主动控制,当遇到 I/O 操作时,协程会主动让出 CPU 控制权,允许其他协程运行。这种协作式多任务的特性,使得协程在处理大量并发 I/O 密集型任务时具有无与伦比的效率和极低的开销。
代码实战与讲解
代码运行逻辑与多进程/多线程也基本相同,但很多细微处需要注意下:
- requests库需要替换成aiohttp库,requests本身并不支持异步。
-
async和
await
的使用这是异步 Python 的核心语法。
-
async def
: 任何包含await
关键字的函数,或者你希望它能被await
的函数,都必须用async def
定义,使其成为一个协程函数。 -
await
:await
关键字只能在async def
定义的函数内部使用。它用于等待一个“可等待对象”(如另一个协程、asyncio.sleep()
、aiohttp
的 I/O 操作等)完成。当await
遇到 I/O 阻塞时,它会将控制权交还给事件循环,让事件循环去调度其他可执行的协程。 -
async with
: 对于需要上下文管理(如文件的打开、网络会话的建立和关闭)的异步资源,要使用async with
语句。例如,aiohttp.ClientSession
和response
对象都应该这样使用:async with aiohttp.ClientSession() as session:async with await session.get(url) as response:# ...
-
-
事件循环(Event Loop)的理解与管理
-
入口点: 异步程序的入口通常是
asyncio.run(main_async_function())
。这个函数会负责创建、运行和关闭事件循环。 -
不要手动创建/管理循环(通常情况): 对于简单的异步脚本,避免直接使用
asyncio.get_event_loop()
和loop.run_until_complete()
等低级 API,asyncio.run()
已经为你处理了这些。
-
-
并发任务的组织
为了真正实现异步的并发优势,通常需要将多个独立的异步任务组织起来并行执行。
-
asyncio.gather()
: 这是最常用的方法,用于同时运行多个协程,并等待它们全部完成。tasks = [] for url in urls:asyncio.ensure_future(fetch_data(url, session)) # 创建任务tasks.append(task) results = await asyncio.gather(*tasks) # 并发执行所有任务
-
asyncio.ensure_future()
: 把协程变成一个任务,并交给事件循环去执行。现在一般更推荐用asyncio.create_task()
来实现这个功能。
-
以下是协程代码实例:
import os.path
import time
from itertools import zip_longestfrom lxml import etree
from DataRecorder import Recorder
import asyncio
import aiohttp # 协程异步# 初始化excel文件
def get_excel():filename = 'top250电影_协程.xlsx'if os.path.exists(filename):os.remove(filename)print('\n旧文件已清除\n')recorder = Recorder(filename)recorder.show_msg = Falsereturn recorder# 协程获取页面数据
async def get_msg(page_index):# session = requests.session()# 初始化爬取数据url = f'https://movie.douban.com/top250?start={page_index*25}&filter='headers = {'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36','referer':'https://movie.douban.com/top250?start=225&filter='}async with aiohttp.ClientSession() as sess:async with await sess.get(url,headers=headers)as resp:res = await resp.text()tree = etree.HTML(res)# 获取其中关键数据titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')data = []for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):# print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')data.append({'电影名':title,'评分':score,'短评':comment})return data# 主协程函数
async def main():start_time = time.time()recorder = get_excel()# 建立异步请求sessiontasks = []for i in range(10):task = asyncio.ensure_future(get_msg(i))tasks.append(task)results = await asyncio.gather(*tasks)# 统一处理所有数据并录入total_index = 1for movies in results:for movie in movies:movie['序号'] = total_indexprint(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")recorder.add_data(movie)recorder.record()total_index+=1end_time = time.time()use_time = end_time - start_timeprint(f'\n共用时:{use_time:.2f}秒!')if __name__ == '__main__':asyncio.run(main())# 共用时:5.23秒!
分析: 协程版本通过 aiohttp
和 asyncio
实现了高效的并发。在 I/O 操作时,协程会主动切换,充分利用等待时间,使得总耗时最短。这是在 Python 中实现高并发 I/O 密集型爬虫的最佳实践。
五、总结与性能对比
通过以上四种爬取方式的实战对比,我们可以清晰地看到异步化带来的性能提升:
爬取方式 | 平均耗时(秒) | 核心原理 | 优点 | 缺点/注意点 |
同步 | ~7.24 | 串行执行 | 编码简单 | 效率最低,I/O 阻塞严重 |
多进程 | ~6.95 | 真正并行(多 CPU) | 规避 GIL,利用多核 CPU,隔离性强 | 资源开销大,进程间通信复杂 |
多线程 | ~5.79 | I/O 并发(GIL 释放) | 资源开销小,I/O 效率提升显著 | 受 GIL 限制,线程安全问题 |
协程 | ~5.23 | I/O 协作式多任务 | 极高并发,开销小,效率最优 | 异步传染性,需异步库支持,调试稍复杂 |
观察结果: 在这个 I/O 密集型的爬虫任务中,协程的性能表现最佳,多线程次之,多进程虽然也能并行但因为进程创建开销略高,效果不如协程和多线程(当然,在极端 CPU 密集型任务中,多进程的优势会更明显)。同步爬取无疑是效率最低的。
实际选择建议:
-
对于大多数需要高效率的爬虫项目:优先考虑使用 协程(
asyncio
+aiohttp
)。 -
如果项目规模较小,或不愿引入异步编程的复杂性:多线程 是一个简单有效的提速方案。
-
当爬虫涉及大量 CPU 密集型任务,或者需要更强的隔离性和稳定性时:多进程则是其中的优选。