Python進階篇之多線程爬取網(wǎng)頁
一、前情提要
相信來看這篇深造爬蟲文章的同學(xué),大部分已經(jīng)對爬蟲有不錯的了解了,也在之前已經(jīng)寫過不少爬蟲了,但我猜爬取的數(shù)據(jù)量都較小,因此沒有過多的關(guān)注爬蟲的爬取效率。這里我想問問當(dāng)我們要爬取的數(shù)據(jù)量為幾十萬甚至上百萬時,我們會不會需要要等幾天才能將數(shù)據(jù)全都爬取完畢呢?
唯一的辦法就是讓爬蟲可以 7×24 小時不間斷工作。因此我們能做的就是多叫幾個爬蟲一起來爬數(shù)據(jù),這樣便可大大提升爬蟲的效率。
但在介紹Python 如何讓多個爬蟲一起爬取數(shù)據(jù)之前,我想先為大家介紹一個概念——并發(fā)。
二、并發(fā)的概念
為了讓大家簡單易懂,我就用例子代替復(fù)雜的文章來向大家介紹吧
第一個例子
我們用 requests 成功請求一個網(wǎng)頁,實際上 requests 做了三件事:
1、根據(jù)鏈接、參數(shù)等組合成一個請求;
2、把這個請求發(fā)往要爬取的網(wǎng)站,等待網(wǎng)站響應(yīng);
3、網(wǎng)站響應(yīng)后,把結(jié)果包裝成一個響應(yīng)對象方便我們使用。

其中步驟 2 花費的時間是最長的,取決于被爬網(wǎng)站的性能,這個時間可能達到幾十到幾百毫秒。
對這個程序來說:綠色部分代表代碼是在 運行 的,黃色部分(步驟 2)代表程序是 空閑 的,因為在等待網(wǎng)站響應(yīng)。 所以,爬蟲代碼真正運行的時間很短,大部分時間都浪費在等待網(wǎng)站響應(yīng)上了。
第二個例子
我們連續(xù)用 requests 請求三個網(wǎng)頁 A、B、C,執(zhí)行的過程如下圖所示:

同樣的,每次步驟 1、3 和 2 所花費時間的差異很大。我們假設(shè)步驟 1 和步驟 3 都要花費 1 毫秒,步驟 2 要花費 98 毫秒。那么一個網(wǎng)頁要花費 100 毫秒,爬取 A、B、C 三個網(wǎng)頁一共花費了 300 毫秒。
這時我們其實遇到一個問題:整個過程的 300 毫秒里,代碼運行的時間只有 6 毫秒,剩下有 294 毫秒我們的程序只是空閑在那里等待著網(wǎng)站響應(yīng)。
第三個例子
想一想,第一個例子里,順序必須是 1-2-3,因為步驟 2 依賴步驟 1 的結(jié)果,步驟 3 依賴步驟 2 的結(jié)果。但是第二個例子里,步驟為什么必須是 A1-A2-A3-B1-B2-B3-C1-C2-C3 呢?「爬取網(wǎng)頁 B」的步驟 1 其實和「爬取網(wǎng)頁 A」的步驟 3 并沒有依賴關(guān)系。

這張圖是什么意思呢?其實就是:在「爬取網(wǎng)頁 A」這個過程進行到步驟 2 的時候,程序空閑下來了,這時我們讓「爬取網(wǎng)頁 B」的步驟 1 開始執(zhí)行;同樣的,「爬取網(wǎng)頁 B」的步驟 1 執(zhí)行完,程序又空閑下來,于是我們安排「爬取網(wǎng)頁 C」開始執(zhí)行。
依然假設(shè)步驟 1 和 3 需要花費 1 毫秒,步驟 2 花費 98 毫秒。算一算,只需要102 毫秒!
我們要爬 10 個或者 20 個網(wǎng)頁,現(xiàn)在預(yù)計分別只需要 109 毫秒和 119 毫秒。而假如我們用第二個例子里的方式運行,則分別需要 1000 毫秒和 2000 毫秒!
可以看到,我們僅僅是利用了爬蟲等待網(wǎng)站響應(yīng)的空閑時間,爬蟲的效率就提升了數(shù)十倍。當(dāng)爬取數(shù)據(jù)量更大時,爬蟲效率提升會更加的顯著。
回到問題:什么叫并發(fā)?
上面第二個例子就不是并發(fā):我要做三件事,然后我一件一件完成它們。
上面的第三個例子就是并發(fā):我們明明要做三件事,但是在這段時間內(nèi),我們交錯著做這三件事,就好像在 同時做這些事 !
而上面第一個例子里,我們只需要做一件事情,這時不管我們寫并發(fā)的代碼或者普通的代碼,它總是步驟 1-2-3 這樣被執(zhí)行完,沒有什么區(qū)別。
上面第三種例子這種情況,在計算機中被稱為并發(fā)
讓我們用一段代碼,來讓大家直觀的看看并發(fā)是什么:
import time
import requests
class Adapter(requests.adapters.HTTPAdapter):
def send(self, *args, **kwargs):
global start
print(
"步驟 1 結(jié)束,耗時",
round((time.time() - start) * 1000),
"毫秒"
)
return super().send(*args, **kwargs)
s = requests.Session()
s.mount("https://", Adapter())
start = time.time()
r = s.get('https://www.baidu.com')
end = time.time()
print(
"步驟 2 結(jié)束,耗時",
round(r.elapsed.total_seconds() * 1000),
"毫秒"
)
print(
"步驟 3 結(jié)束,耗時",
int((end -start - r.elapsed.total_seconds()) * 1000),
"毫秒"
)
//輸出結(jié)果↓
//步驟 1 結(jié)束,耗時 2 毫秒
//步驟 2 結(jié)束,耗時 66 毫秒
//步驟 3 結(jié)束,耗時 1 毫秒
通過以上的講解,相信大家已經(jīng)對并發(fā)有一個初步的認(rèn)識了,接下來我們再來講講多線程
三、并發(fā)與多線程
操作系統(tǒng)為我們提供了兩個東西:進程和線程。利用這兩樣?xùn)|西,我們可以輕易地實現(xiàn)代碼的并發(fā),而不用考慮細(xì)枝末節(jié)。
例如,我們把下面三個任務(wù)丟到三個線程中,操作系統(tǒng)就能讓任務(wù)A等待時,啟動任務(wù)B,任務(wù)AB等待時,啟動任務(wù)C,而當(dāng)任務(wù)A等待結(jié)束了,接著回去完成任務(wù)A,以此類推,在最短的時間內(nèi)完成所有的任務(wù),而不用擠占時間。

我們來比較一下,有用多線程和沒有用多線程的爬蟲程序的耗時究竟相差多少!
import time
import requests
# 導(dǎo)入 concurrent.futures 這個包
from concurrent import futures
# 假設(shè)我們要爬取 30 個網(wǎng)頁
urls = ["https://wpblog.x0y1.com/?p=34"] * 30
session = requests.Session()
# 普通爬蟲
start1 = time.time()
results = []
for url in urls:
r = session.get(url)
results.append(r.text)
end1 = time.time()
print("普通爬蟲耗時", end1-start1, "秒")
# 多線程爬蟲
# 初始化一個線程池,最大的同時任務(wù)數(shù)是 5
executor = futures.ThreadPoolExecutor(max_workers=5)
start2 = time.time()
fs = []
for url in urls:
# 提交任務(wù)到線程池
f = executor.submit(session.get, url)
fs.append(f)
# 等待這些任務(wù)全部完成
futures.wait(fs)
# 獲取任務(wù)的結(jié)果
result = [f.result().text for f in fs]
end2 = time.time()
print("多線程爬蟲耗時", end2-start2, "秒")
#輸出結(jié)果↓ 耗時與線上環(huán)境和硬件條件有關(guān)
#普通爬蟲耗時 3.626128673553467 秒
#多線程爬蟲耗時 2.0856518745422363 秒
看到結(jié)果對比之后就會知道,通常情況下多線程爬蟲的效率會比單線程高很多。而且需要處理的任務(wù)量越多的時候,這個差異會越明顯。
好,我們再來仔細(xì)解讀一下這部分多線程爬蟲代碼,我們?nèi)〕鲫P(guān)鍵部分看看
# 導(dǎo)入 concurrent.futures 這個包 from concurrent import futures # 初始化一個線程池,最大的同時任務(wù)數(shù)是 5 executor = futures.ThreadPoolExecutor(max_workers=5)
concurrent是 Python 自帶的庫,這個庫具有線程池和進程池、管理并行編程任務(wù)、處理非確定性的執(zhí)行流程、進程/線程同步等功能。
executor 就是我們剛剛初始化的線程池,我們調(diào)用 executor 的 submit() 方法往里面提交任務(wù)。第一個參數(shù) session.get 是提交要運行的函數(shù),第二個參數(shù) url 是提交的函數(shù)運行時的參數(shù)。
fs = [] for url in urls: # 提交任務(wù)到線程池 f = executor.submit(session.get, url) fs.append(f)
executor 就是我們剛剛初始化的線程池,我們調(diào)用 executor 的 submit() 方法往里面提交任務(wù)。第一個參數(shù) session.get 是提交要運行的函數(shù),第二個參數(shù) url 是提交的函數(shù)運行時的參數(shù)。
executor.submit() 方法會給我們一個返回值,它是一個 future 對象,我們把它賦值給變量 f。
# 等待這些任務(wù)全部完成 futures.wait(fs)
fs 是保存了上面所有任務(wù)的 future 對象的列表,futures.wait() 方法可以等待直到 fs 里面所有的 future 對象都有結(jié)果為止。
# 獲取任務(wù)的結(jié)果 result = [f.result().text for f in fs]
fs 是保存了上面所有任務(wù)的 future 對象的列表,我們遍歷所有任務(wù)的 future 對象,調(diào)用 future 對象的 result() 方法,就能得到任務(wù)的結(jié)果。
那結(jié)果是什么類型的呢?取決于提交的任務(wù)。比如我們提交的是 session.get(url),它的返回值是一個 response 對象,那我們調(diào)用它的 text 屬性就能得到響應(yīng)的完整內(nèi)容了。
四、線程池
前面我們講過,線程是操作系統(tǒng)提供給我們的能力,可以把不同的任務(wù)放到不同的線程里,這樣它們可以同時運行。但是這個能力一定是有限的,并不能無止境的制造線程。如果運行的線程數(shù)太多,操作系統(tǒng)在安排這些線程的執(zhí)行順序等事情上要花費很大的代價。
我們先來回憶一下一開始的第三個例子,在這個例子里,之所以切換到第二個任務(wù)可以提高我們的效率,是因為第一個任務(wù)已經(jīng)處于空閑狀態(tài)。

但假如我們的線程數(shù)非常多,步驟 1 可以一直往圖的右下堆疊,直到占滿了空閑時間。這時再加線程對爬蟲而言是沒有意義的,任務(wù)同樣要排隊來運行。
所以線程池其實就是限制了最多同時運行的線程數(shù)。比如我們初始化一個最大任務(wù)數(shù)為 5 的線程池,這樣即使我們提交了 100 任務(wù)到這個池子里,同時在運行的也只有五個。而一個任務(wù)被完成后,也會被移出線程池騰出空間。所以,用線程池可以避免上面提到的兩個問題。
其實還有第三個問題,就是考慮到被爬網(wǎng)站的性能和其反爬機制,我們也不應(yīng)該讓機器過快地去運行爬蟲。線程池的數(shù)量建議可以在 10 左右,電腦性能好而且不擔(dān)心被爬取網(wǎng)站封禁的可以考慮加到幾十,性能差的可以考慮降到 5。
下一篇文章我會介紹一個并發(fā)爬取的項目實戰(zhàn),希望有需要的同學(xué)來看看!!
文章鏈接:Python進階多線程爬取網(wǎng)頁項目實戰(zhàn)
本次分享到此結(jié)束,非常感謝大家閱讀!!
有問題歡迎評論區(qū)留言!!
更多關(guān)于Python多線程爬取網(wǎng)頁的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Python?seaborn數(shù)據(jù)可視化繪圖(直方圖,密度圖,散點圖)
這篇文章主要介紹了Python?seaborn數(shù)據(jù)可視化繪圖(直方圖,密度圖,散點圖),文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價值,需要的朋友可以參考一下2022-07-07
Flask和Django框架中自定義模型類的表名、父類相關(guān)問題分析
這篇文章主要介紹了Flask和Django框架中自定義模型類的表名、父類相關(guān)問題,結(jié)合實例形式對比分析了Flask框架與Django框架表名定義方式的不同之處,并簡單描述了框架的父類繼承問題,需要的朋友可以參考下2018-07-07
Python3 itchat實現(xiàn)微信定時發(fā)送群消息的實例代碼
使用微信,定時往指定的微信群里發(fā)送指定信息。接下來通過本文給大家分享Python3 itchat實現(xiàn)微信定時發(fā)送群消息的實例代碼,需要的朋友可以參考下2019-07-07

