【Python爬蟲】—— 多線程基本原理

多線程的含義

進程可以理解爲是一個可以獨立運行的程序單位。

比如:

  • 打開一個瀏覽器,就開啓了一個瀏覽器進程。
  • 打開一個文本編輯器,就開啓了一個文本編輯器進程。

一個進程中可以同時處理很多事情。

比如:

  • 瀏覽器中可以在多個選項卡中打開多個頁面,有的頁面在播放音樂,有的頁面在播放視頻,有的網頁在播放動畫,可以同時運行,互不干擾。

爲什麼能同時做到同時運行這麼多的任務呢?

任務對應着線程的執行。


進程 是線程的集合,是由一個或多個線程構成的。
線程 是操作系統進行運算調度的最小單位,是進程中的一個最小運行單元。


併發和並行

併發(concurrency)

指同一時刻只能有一條指令執行,但多個線程的對應的指令被快速輪換地執行,宏觀上看起來多個線程在同時運行,但微觀上只是這個處理器在連續不斷地、在多個線程之間切換和執行。

在單處理器和多處理器系統中都可以存在,僅靠一個核,就可以實現併發。

並行(parallel)

指同一時刻有多條指令在多個處理器上同時執行,並行必須要依賴於多個處理器,不論宏觀上還是微觀上,多個線程都是在同一時刻一起執行的。

只能在多處理器系統中存在,如果計算機處理器只有一個核,就不可能實現並行。


多線程適用場景

在一個程序進程中,有些操作是比較耗時或者需要等待的。

比如:

  • 等待數據庫的查詢結果的返回
  • 等待網頁結果的響應

使用單線程:
處理器必須要等到這些操作完成之後才能繼續往下執行其他操作,而這個線程在等待的過程中,處理器明顯是可以來執行其他操作的。

使用多線程:
處理器就可以在某個線程等待時,去執行其他的線程,從而從整體上提高執行效率。


網絡爬蟲就是一個非常典型的例子
爬蟲在向服務器發起請求之後,有一段時間必須要等待服務器的響應返回,這種任務就屬於 IO 密集型任務。

但不是所有的任務都是 IO 密集型任務
有一種任務叫作計算密集型任務,也可以稱之爲 CPU 密集型任務,就是任務的運行一直需要處理器的參與。

這時如果開啓多線程,一個處理器從一個計算密集型任務切換到切換到另一個計算密集型任務上,處理器依然不會停下來,始終會忙於計算。

如果任務不全是計算密集型任務,可以使用多線程來提高程序整體的執行效率,尤其對於網絡爬蟲這種 IO 密集型任務來說,使用多線程會大大提高程序整體的爬取效率。


Python 實現多線程

在 Python 中,實現多線程的模塊叫作 threading,是 Python 自帶的模塊。

使用 threading 實現多線程的方法:

  • Thread 直接創建子線程
    首先可以使用 Thread 類來創建一個線程,創建時需要指定 target 參數爲運行的方法名稱,如果被調用的方法需要傳入額外的參數,則可以通過 Thread 的 args 參數來指定。
import threading
import time


def target(second):
    print(f'Threading {threading.current_thread().name} is running')
    print(f'Threading {threading.current_thread().name} sleep {second}s')
    time.sleep(second)
    print(f'Threading {threading.current_thread().name} is ended')


print(f'Threading {threading.current_thread().name} is running')

for i in [1, 5]:
    thread = threading.Thread(target=target, args=[i])
    thread.start()
    
print(f'Threading {threading.current_thread().name} is ended')

運行結果:

Threading MainThread is running
Threading Thread-1 is running
Threading Thread-1 sleep 1s
Threading Thread-2 is running
Threading Thread-2 sleep 5s
Threading MainThread is ended
Threading Thread-1 is ended
Threading Thread-2 is ended

如果想要主線程等待子線程運行完畢之後才退出,可以讓每個子線程對象都調用下 join 方法:

threads = []

for i in [1, 5]:
    thread = threading.Thread(target=target, args=[i])
    threads.append(thread)
    thread.start()
    
for thread in threads:
    thread.join()

運行結果:

Threading MainThread is running
Threading Thread-1 is running
Threading Thread-1 sleep 1s
Threading Thread-2 is running
Threading Thread-2 sleep 5s
Threading Thread-1 is ended
Threading Thread-2 is ended
Threading MainThread is ended
  • 繼承 Thread 類創建子線程
    另外也可以通過繼承 Thread 類的方式創建一個線程,該線程需要執行的方法寫在類的 run 方法裏面即可。上面的例子的等價改寫爲:
import threading
import time


class MyThread(threading.Thread):
    def __init__(self, second):
        threading.Thread.__init__(self)
        self.second = second
    
    def run(self):
        print(f'Threading {threading.current_thread().name} is running')
        print(f'Threading {threading.current_thread().name} sleep {self.second}s')
        time.sleep(self.second)
        print(f'Threading {threading.current_thread().name} is ended')


print(f'Threading {threading.current_thread().name} is running')

threads = []

for i in [1, 5]:
    thread = MyThread(i)
    threads.append(thread)
    thread.start()
    
for thread in threads:
    thread.join()
    
print(f'Threading {threading.current_thread().name} is ended')

運行結果:

Threading MainThread is running
Threading Thread-1 is running 
Threading Thread-1 sleep 1s 
Threading Thread-2 is running 
Threading Thread-2 sleep 5s 
Threading Thread-1 is ended 
Threading Thread-2 is ended 
Threading MainThread is ended 

守護線程

在線程中有一個叫作守護線程的概念,如果一個線程被設置爲守護線程,那麼意味着這個線程是“不重要”的,這意味着,如果主線程結束了而該守護線程還沒有運行完,那麼它將會被強制結束。

在 Python 中我們可以通過 setDaemon 方法來將某個線程設置爲守護線程:

import threading
import time


def target(second):
    print(f'Threading {threading.current_thread().name} is running')
    print(f'Threading {threading.current_thread().name} sleep {second}s')
    time.sleep(second)
    print(f'Threading {threading.current_thread().name} is ended')


print(f'Threading {threading.current_thread().name} is running')
t1 = threading.Thread(target=target, args=[2])
t1.start()
t2 = threading.Thread(target=target, args=[5])
t2.setDaemon(True)
t2.start()
print(f'Threading {threading.current_thread().name} is ended')

運行結果:

Threading MainThread is running 
Threading Thread-1 is running 
Threading Thread-1 sleep 2s 
Threading Thread-2 is running 
Threading Thread-2 sleep 5s 
Threading MainThread is ended 
Threading Thread-1 is ended 

這裏並沒有調用 join 方法,如果讓 t1 和 t2 都調用 join 方法,主線程就會仍然等待各個子線程執行完畢再退出,不論其是否是守護線程。


互斥鎖

在一個進程中的多個線程是共享資源的,比如在一個進程中,有一個全局變量 count 用來計數,現在聲明多個線程,每個線程運行時都給 count 加 1,代碼實現如下:

import threading
import time


count = 0

class MyThread(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)

    def run(self):
        global count
        temp = count + 1
        time.sleep(0.001)
        count = temp

threads = []

for _ in range(1000):
    thread = MyThread()
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()
    
print(f'Final count: {count}')

運行結果:

Final count: 69 

由於 count 這個值是共享的,每個線程都可以在執行 temp = count 這行代碼時拿到當前 count 的值,但是這些線程中的一些線程可能是併發或者並行執行的,這就導致不同的線程拿到的可能是同一個 count 值,最後導致有些線程的 count 的加 1 操作並沒有生效,導致最後的結果偏小。

所以,如果多個線程同時對某個數據進行讀取或修改,就會出現不可預料的結果。爲了避免這種情況,我們需要對多個線程進行同步,要實現同步,我們可以對需要操作的數據進行加鎖保護,這裏就需要用到 threading.Lock 了。


加鎖保護

某個線程在對數據進行操作前,需要先加鎖,這樣其他的線程發現被加鎖了之後,就無法繼續向下執行,會一直等待鎖被釋放,只有加鎖的線程把鎖釋放了,其他的線程才能繼續加鎖並對數據做修改,修改完了再釋放鎖。

這樣可以確保同一時間只有一個線程操作數據,多個線程不會再同時讀取和修改同一個數據。


Python多線程的問題

GIL 全稱爲 Global Interpreter Lock,譯爲全局解釋器鎖。

在 Python 多線程下,每個線程的執行方式如下:

  • 獲取 GIL
  • 執行對應線程的代碼
  • 釋放 GIL

可見,某個線程想要執行,必須先拿到 GIL,可以把 GIL 看作是通行證,並且在一個 Python 進程中,GIL 只有一個。拿不到通行證的線程,就不允許執行。這樣就會導致,即使是多核條件下,一個 Python 進程下的多個線程,同一時刻也只能執行一個線程。

對於爬蟲這種 IO 密集型任務來說,這個問題影響並不大;而對於計算密集型任務來說,由於 GIL 的存在,多線程總體的運行效率相比可能反而比單線程更低。


Reference:https://kaiwu.lagou.com/course/courseInfo.htm?courseId=46#/detail/pc?id=1666

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章