?相關爬蟲知識點:[爬蟲知識] 深入理解多進程/多線程/協程的異步邏輯
相關爬蟲專欄: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 密集型任務,或者需要更強的隔離性和穩定性時:多進程則是其中的優選。