本篇博客是 python 爬蟲 120 例中,基礎知識補充篇,內容將圍繞 python 協程進行,
在開始協程相關知識前,先補充一下預備概念,
在 python 爬蟲的學習程序中,經常要區分兩個概念,一個叫做 I/O 密集型任務,另一個叫做 計算密集型任務,
以上兩種任務,都有 2 個前提,一是存在可執行的子任務,二是需要計算機支持多核 CPU,
I/O 密集型任務
密集型任務指的硬碟 I/O 或者網路 I/O 占主要任務,程式計算量很小,大部分時間都用在請求網頁和讀寫檔案上,這種情況下,CPU 經常等待 I/O 操作完成,所以可以利用這些時間去完成其它事務,
基于上述內容,I/O 密集型任務,采用 多執行緒 就可以提高程式執行效率,當然采用 多行程 也是可以的,但多進程會出現共享資源和通訊問題,因此,I/O 密集型任務,采用多執行緒即可,
計算密集型任務
也叫作 CPU 密集型任務,在這種情況下,CPU 注意滿負荷狀態,例如大資料查找,大字串處理,
計算密集型任務在 python 中一般采用多進程處理,因為 python 中的多執行緒有同步鎖安全機制,并且采用的是全域鎖,所以即便使用多核 CPU,同一時間,也只有一個執行緒在執行,
除了以上兩種任務型別外,還有一些基礎概念知識需要補充學習,
阻塞和非阻塞
阻塞狀態指程式 未得到 所需資源時,被掛起的狀態,在這個狀態下,程式必須等待某個操作完成,自身無法繼續運行,
引起阻塞的常見原因
- 網路 I/O 阻塞;
- 硬碟 I/O 阻塞;
- 用戶輸入阻塞,
非阻塞是因阻塞而存在,我們的目標就是實作程式在等待某個操作的程序中,自身不被阻塞,可以繼續運行,非阻塞是為了提高程式整體執行效率,
同步和異步
初學階段,同步可以理解為,不同程式之間為了保證資料的一致性,必須依賴某種通信機制,實作程式單元之間的資料同步性,同步是有序的,例如大家同時去秒殺 10 件商品,不管你在手機端,網頁端,平板端,商品只有 10 件,
異步表示在不同程式之間,不需要保證資料的一致性,可以分別執行,異步是無序的,例如咱們之前寫的爬蟲程式,一堆圖片,分別下載即可,誰先下載誰后下載沒有區別,異步是高效組織非阻塞任務的方式,
上述描述都非精準描述,大家在本階段理解概念即可,
并發與并行
并發:描述的是程式的組織結構,程式要被設計成多個可獨立執行的子任務,從而可以利用有限的計算機資源使多個任務可以被實時或者近實時的執行,核心是為了讓獨立的子任務盡快運行,但整體進度不一定有變化,
并行:描述的是程式的執行狀態,指多個任務同時被執行,從而可以利用富余的計算機資源(多核 CPU)加速完成多個任務,核心是利用多核,
協程基本知識
正式開始前,一定要先確定一點,協程不是 python 專屬概念,它僅僅是一個普通的計算機概念,任何語言都有自己的實作,協程是單執行緒下的并發,
協程(coroutine):也叫作微執行緒,纖程,
協程的作用:在執行函式 A 時,隨時中斷去執行函式 B,然后再中斷函式 B,回傳來執行函式 A,該操作類似多執行緒,但協程中只有一個執行緒在執行,
協程的優勢:
- 非常適用于 I/O 密集型任務;
- 執行效率高(切換函式,而不切換執行緒,沒有多余開銷);
- 不需要鎖機制,
協程的劣勢:
- 無法利用多核資源;
- 進行阻塞操作會阻塞掉整個程式,
使用 yield 實作協程
yield 關鍵字翻譯成中文,有生產,退讓的意思,如果在一個 python 函式中使用 yield 關鍵字,那這個函式就是一個生成器函式,呼叫生成器函式會獲得一個生成器,根據之前的知識,咱們已經知道函式中出現 yield 關鍵字 ,會讓出程式的控制權,讓呼叫方繼續作業,直到下次呼叫時,呼叫方則需等待生成器提供給其對應的資料,因此生成器就是一個協程,
測驗以下 yield 生成器代碼,
import time
def task1():
while True:
print("任務1--準備執行")
yield
print("任務1--執行完畢")
time.sleep(0.5)
def task2():
while True:
print("任務2--準備執行")
yield
print("任務2--執行完畢")
time.sleep(0.5)
if __name__ == '__main__':
t1 = task1()
t2 = task2()
while True:
next(t1)
print("主函式回圈")
next(t2)
上述代碼的運行程序需要重點關注,相關步驟在注釋區進行說明
任務1--準備執行 # 主函式中執行 while 回圈,呼叫 next(t1),進入到 t1 函式內部,輸出對應提示,然后碰到了 yield,將函式掛起,退出
主函式回圈 # t1 函式掛起,主函式運行
任務2--準備執行 # 呼叫 next(t2),進入到 t2 函式內部,輸出對應提示,然后碰到 yield,將函式掛起,退出,注意此時第一次回圈結束
任務1--執行完畢 # 第二次回圈開始,再次呼叫 next(t1),此時從之前的 yield 掛起處再次運行,輸出對應提示,sleep 0.5 秒
任務1--準備執行 # 輸出對應提示之后,又碰到 yield 關鍵字,掛起
主函式回圈 # 執行主函式
任務2--執行完畢 # next(t2),從 yield 關鍵字位置繼續執行
任務2--準備執行 # sleep 0.5 秒輸出對應提示,第二次回圈結束
任務1--執行完畢 # 第三次回圈開始
任務1--準備執行
yield 陳述句將一個函式變為了生成器函式,函式碰到 yield 陳述句,就會交出程式的控制權,當下一次執行,即第二次被 next 函式呼叫時,程式會恢復到之前“掛起”時的狀態,本案例中 yield 后無任何代碼,如果為一個 I/O 操作,那程式的效率就會大幅度的提高,
實作簡單協程
生成器只是協程的子集,因為生成器函式交出程式控制權之后,并不能決定由哪個協程接替子集運行,我們在此基礎上,增加一個協程之間調度的派遣器,核心實作的功能就是當一個協程交出控制權之后,派遣器可以協調另一個協程來接替子集運行,
def my_coroutine():
for i in range(5):
in_x = yield i + 1
print(f"呼叫者傳入引數為{in_x}")
my_cor = my_coroutine()
a = next(my_cor) # 生成器最初的值
for i in range(7):
try:
out_y = my_cor.send(i)
print(f"生成器回傳的值為{out_y}")
except StopIteration as se:
print("生成器所有值都已經獲取完畢")
print(f"生成器最初的值為{a}")
上述代碼的運行邏輯已經在下圖進行標識,其中用到了 send 函式,send 不但能觸發生成器,還能發送資料到 yield 位置,
send 不能作為第一輪回圈的開始,會報錯,如果要在第一次運行時使用 send 函式,那么傳的值就必須是 None,
greenlet 模塊
greenlet 模塊是 C 語言實作的協程模塊,與 python 的 yield 相比,可以在任意函式之間切換,并且不需要將函式宣告為生成器,
該模塊需要進行手動安裝,pip install greenlet,目前最新的版本為 1.1.1(2021 年 10 月版本),
使用 greenlet 實作協程
from greenlet import greenlet
import time
def task1():
while True:
print("任務1")
g2.switch() # 切換到 g2 中運行
time.sleep(0.5)
def task2():
while True:
print("任務2")
g1.switch() # 切換到 g1 中運行
time.sleep(0.5)
if __name__ == "__main__":
g1 = greenlet(task1) # greenlet 物件
g2 = greenlet(task2)
g1.switch() # 切換到 g1 中運行
greenlet 撰寫的協程代碼,需要手動切換各個任務,并且如果程式中并沒有 I/O 操作,單純的切換反而會降低程式運行速度,
gevent 模塊
由于 greenlet 需要手動切換各個任務,所以又出現了一款可以自動切換任務的模塊 gevent,該模塊的原理是當一個 greenlet 遇到 I/O 操作時,自動切換到其它 greenlet,等 I/O 操作結束,再切換回繼續執行,
該模塊使用前注意提前安裝:pip install gevent,
import gevent
def task1(num):
for i in range(num):
print(gevent.getcurrent(), i)
# 模擬 I/O 操作,測驗時可以分別測驗注釋下述代碼和不注釋下述代碼
gevent.sleep(1)
if __name__ == "__main__":
# 創建協程
g1 = gevent.spawn(task1, 5)
g2 = gevent.spawn(task1, 5)
g3 = gevent.spawn(task1, 5)
# 等待協程運行完畢
g1.join()
g2.join()
g3.join()
上述代碼中注釋 gevent.sleep(1) (模擬 I/O 操作)之后,代碼依次執行,非注釋時,交替運行,
還可以將上述代碼進行完善,例如下述代碼:
import gevent
def task1(tag):
print(f'task1 IO 阻塞前,傳入引數{tag}')
gevent.sleep(3)
print(f'task1 IO 阻塞后,傳入引數{tag}')
def task2(tag):
print(f'task2 IO 阻塞前,傳入引數{tag}')
gevent.sleep(1)
print(f'task2 IO 阻塞后,傳入引數{tag}')
g1 = gevent.spawn(task1, '橡皮擦')
g2 = gevent.spawn(task2, tag='Python')
g1.join()
g2.join()
# 也可以將上述兩個步驟合并為一行 gevent.joinall([g1,g2])
print('主程式')
gevent.spawn() 函式的引數說明如下,第一個引數為函式名,例如 task1,第二個引數開始是 task1 函式的引數,可以為位置實參或者關鍵字實參,然后 spawn 實作異步提交任務,
g1.join() 等待任務結束,可以將兩個等待任務結束代碼合并為一行 gevent.joinall([g1,g2]),
上述代碼中 gevent.sleep(2) 為 I/O 阻塞模擬操作,但是卻無法識別 yield 中的 time.sleep() 或者其它阻塞,解決辦法也非常簡單,增加 monkey 補丁,
from gevent import monkey
# 寫在最上面,后面的所有阻塞都能識別
monkey.patch_all()
import gevent
import time
def task1(tag):
print(f'task1 IO 阻塞前,傳入引數{tag}')
print(threading.current_thread().getName())
time.sleep(3)
print(f'task1 IO 阻塞后,傳入引數{tag}')
def task2(tag):
print(f'task2 IO 阻塞前,傳入引數{tag}')
print(threading.current_thread().getName())
time.sleep(1)
print(f'task2 IO 阻塞后,傳入引數{tag}')
g1 = gevent.spawn(task1, '橡皮擦')
g2 = gevent.spawn(task2, tag='Python')
gevent.joinall([g1, g2])
print('主程式')
可以通過 threading.current_thread().getName() 查看虛擬執行緒的名字,
使用 gevent 實作一個簡易爬蟲
到這里本文的篇幅已經有些超出長度了,可以寫一個協程爬蟲進行收尾了,
本次案例要抓取的為:https://www.qqtn.com/tx/nvshengtx_1.html,女生頭像網站,

該案例涉及的 I/O 操作主要為網路請求與圖片保存,該網站還存在詳情頁,邏輯基本一致,不再涉及,
串列頁分頁規則如下:
https://www.qqtn.com/tx/nvshengtx_1.html
https://www.qqtn.com/tx/nvshengtx_2.html
https://www.qqtn.com/tx/nvshengtx_243.html
下面撰寫爬蟲代碼,本案例開啟 5 個協程去采集資料,
from gevent import monkey
monkey.patch_all()
import threading
from bs4 import BeautifulSoup
import gevent
import requests
import lxml
def get_page(this_urls):
while True:
if this_urls is None:
break
url = this_urls.pop()
print('正在抓取:{},當前的虛擬執行緒為:{}'.format(url, threading.current_thread().getName()))
res = requests.get(url=url)
res.encoding = "gb2312"
if res.status_code == 200:
soup = BeautifulSoup(res.text, 'lxml')
content = soup.find(attrs={'class': 'g-gxlist-imgbox'})
img_tags = content.find_all('img')
for img_tag in img_tags:
img_src = img_tag['src']
# 注意去除檔案路徑中的特殊符號,防止出錯
try:
name = img_tag['alt'].replace('/', '').replace('+', '').replace('?', '').replace('*', '')
except OSError as e:
continue
save_img(img_src, name)
# 保存圖片
def save_img(img_src, name):
res = requests.get(img_src)
with open(f'imgs/{name}.jpg', mode='wb') as f:
f.write(res.content)
if __name__ == '__main__':
urls = [f"https://www.qqtn.com/tx/nvshengtx_{page}.html" for page in range(1, 244)]
# 開啟 5 個協程
gevent.joinall([gevent.spawn(get_page, urls) for i in range(5)])
print("爬取完畢")
寫在后面
協程掌握了,python 爬蟲之路就開啟了,
采集程序中獲取的頭像圖片,下載地址:4000+女生頭像.zip
今天是持續寫作的第 241 / 365 天,
期待 關注,點贊、評論、收藏,
更多精彩
《爬蟲 100 例,專欄銷售中,買完就能學會系列專欄》

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/317936.html
標籤:python
下一篇:Python 最近兩條好訊息:①TIOBE排名超過C和Java②新版本發布3.10.0,還有今天剛發布的《What’s New in Python(2021.10.15)》
