第05讲:多路加速,了解多线程基本原理
我們知道,在一臺計算機中,我們可以同時打開許多軟件,比如同時瀏覽網頁、聽音樂、打字等等,看似非常正常。但仔細想想,為什么計算機可以做到這么多軟件同時運行呢?這就涉及到計算機中的兩個重要概念:多進程和多線程了。
同樣,在編寫爬蟲程序的時候,為了提高爬取效率,我們可能想同時運行多個爬蟲任務。這里同樣需要涉及多進程和多線程的知識。
本課時,我們就先來了解一下多線程的基本原理,以及在 Python 中如何實現多線程。
1.多線程的含義
說起多線程,就不得不先說什么是線程。然而想要弄明白什么是線程,又不得不先說什么是進程。
進程我們可以理解為是一個可以獨立運行的程序單位,比如打開一個瀏覽器,這就開啟了一個瀏覽器進程;打開一個文本編輯器,這就開啟了一個文本編輯器進程。但一個進程中是可以同時處理很多事情的,比如在瀏覽器中,我們可以在多個選項卡中打開多個頁面,有的頁面在播放音樂,有的頁面在播放視頻,有的網頁在播放動畫,它們可以同時運行,互不干擾。為什么能同時做到同時運行這么多的任務呢?這里就需要引出線程的概念了,其實這一個個任務,實際上就對應著一個個線程的執行。
而進程呢?它就是線程的集合,進程就是由一個或多個線程構成的,線程是操作系統進行運算調度的最小單位,是進程中的一個最小運行單元。比如上面所說的瀏覽器進程,其中的播放音樂就是一個線程,播放視頻也是一個線程,當然其中還有很多其他的線程在同時運行,這些線程的并發或并行執行最后使得整個瀏覽器可以同時運行這么多的任務。
了解了線程的概念,多線程就很容易理解了,多線程就是一個進程中同時執行多個線程,前面所說的瀏覽器的情景就是典型的多線程執行。
2.并發和并行
說到多進程和多線程,這里就需要再講解兩個概念,那就是并發和并行。我們知道,一個程序在計算機中運行,其底層是處理器通過運行一條條的指令來實現的。
并發,英文叫作 concurrency。它是指同一時刻只能有一條指令執行,但是多個線程的對應的指令被快速輪換地執行。比如一個處理器,它先執行線程 A 的指令一段時間,再執行線程 B 的指令一段時間,再切回到線程 A 執行一段時間。
由于處理器執行指令的速度和切換的速度非常非常快,人完全感知不到計算機在這個過程中有多個線程切換上下文執行的操作,這就使得宏觀上看起來多個線程在同時運行。但微觀上只是這個處理器在連續不斷地在多個線程之間切換和執行,每個線程的執行一定會占用這個處理器一個時間片段,同一時刻,其實只有一個線程在執行。
并行,英文叫作 parallel。它是指同一時刻,有多條指令在多個處理器上同時執行,并行必須要依賴于多個處理器。不論是從宏觀上還是微觀上,多個線程都是在同一時刻一起執行的。
并行只能在多處理器系統中存在,如果我們的計算機處理器只有一個核,那就不可能實現并行。而并發在單處理器和多處理器系統中都是可以存在的,因為僅靠一個核,就可以實現并發。
舉個例子,比如系統處理器需要同時運行多個線程。如果系統處理器只有一個核,那它只能通過并發的方式來運行這些線程。如果系統處理器有多個核,當一個核在執行一個線程時,另一個核可以執行另一個線程,這樣這兩個線程就實現了并行執行,當然其他的線程也可能和另外的線程處在同一個核上執行,它們之間就是并發執行。具體的執行方式,就取決于操作系統的調度了。
3.多線程適用場景
在一個程序進程中,有一些操作是比較耗時或者需要等待的,比如等待數據庫的查詢結果的返回,等待網頁結果的響應。如果使用單線程,處理器必須要等到這些操作完成之后才能繼續往下執行其他操作,而這個線程在等待的過程中,處理器明顯是可以來執行其他的操作的。如果使用多線程,處理器就可以在某個線程等待的時候,去執行其他的線程,從而從整體上提高執行效率。
像上述場景,線程在執行過程中很多情況下是需要等待的。比如網絡爬蟲就是一個非常典型的例子,爬蟲在向服務器發起請求之后,有一段時間必須要等待服務器的響應返回,這種任務就屬于 IO 密集型任務。對于這種任務,如果我們啟用多線程,處理器就可以在某個線程等待的過程中去處理其他的任務,從而提高整體的爬取效率。
但并不是所有的任務都是 IO 密集型任務,還有一種任務叫作計算密集型任務,也可以稱之為 CPU 密集型任務。顧名思義,就是任務的運行一直需要處理器的參與。此時如果我們開啟了多線程,一個處理器從一個計算密集型任務切換到切換到另一個計算密集型任務上去,處理器依然不會停下來,始終會忙于計算,這樣并不會節省總體的時間,因為需要處理的任務的計算總量是不變的。如果線程數目過多,反而還會在線程切換的過程中多耗費一些時間,整體效率會變低。
所以,如果任務不全是計算密集型任務,我們可以使用多線程來提高程序整體的執行效率。尤其對于網絡爬蟲這種 IO 密集型任務來說,使用多線程會大大提高程序整體的爬取效率。
4.Python 實現多線程
在 Python 中,實現多線程的模塊叫作 threading,是 Python 自帶的模塊。下面我們來了解下使用 threading 實現多線程的方法。
4.1Thread 直接創建子線程
首先,我們可以使用 Thread 類來創建一個線程,創建時需要指定 target 參數為運行的方法名稱,如果被調用的方法需要傳入額外的參數,則可以通過 Thread 的 args 參數來指定。示例如下:
import threading import timedef 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在這里我們首先聲明了一個方法,叫作 target,它接收一個參數為 second,通過方法的實現可以發現,這個方法其實就是執行了一個 time.sleep 休眠操作,second 參數就是休眠秒數,其前后都 print 了一些內容,其中線程的名字我們通過 threading.current_thread().name 來獲取出來,如果是主線程的話,其值就是 MainThread,如果是子線程的話,其值就是 Thread-*。
然后我們通過 Thead 類新建了兩個線程,target 參數就是剛才我們所定義的方法名,args 以列表的形式傳遞。兩次循環中,這里 i 分別就是 1 和 5,這樣兩個線程就分別休眠 1 秒和 5 秒,聲明完成之后,我們調用 start 方法即可開始線程的運行。
觀察結果我們可以發現,這里一共產生了三個線程,分別是主線程 MainThread 和兩個子線程 Thread-1、Thread-2。另外我們觀察到,主線程首先運行結束,緊接著 Thread-1、Thread-2 才接連運行結束,分別間隔了 1 秒和 4 秒。這說明主線程并沒有等待子線程運行完畢才結束運行,而是直接退出了,有點不符合常理。
如果我們想要主線程等待子線程運行完畢之后才退出,可以讓每個子線程對象都調用下 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這樣,主線程必須等待子線程都運行結束,主線程才繼續運行并結束。
4.2繼承 Thread 類創建子線程
另外,我們也可以通過繼承 Thread 類的方式創建一個線程,該線程需要執行的方法寫在類的 run 方法里面即可。上面的例子的等價改寫為:
import threading import timeclass MyThread(threading.Thread):def __init__(self, second):threading.Thread.__init__(self)self.second = seconddef 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可以看到,兩種實現方式,其運行效果是相同的。
4.3守護線程
在線程中有一個叫作守護線程的概念,如果一個線程被設置為守護線程,那么意味著這個線程是“不重要”的,這意味著,如果主線程結束了而該守護線程還沒有運行完,那么它將會被強制結束。在 Python 中我們可以通過 setDaemon 方法來將某個線程設置為守護線程。
示例如下:
import threading import timedef 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')在這里我們通過 setDaemon 方法將 t2 設置為了守護線程,這樣主線程在運行完畢時,t2 線程會隨著線程的結束而結束。
運行結果如下:
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可以看到,我們沒有看到 Thread-2 打印退出的消息,Thread-2 隨著主線程的退出而退出了。
不過細心的你可能會發現,這里并沒有調用 join 方法,如果我們讓 t1 和 t2 都調用 join 方法,主線程就會仍然等待各個子線程執行完畢再退出,不論其是否是守護線程。
4.4互斥鎖
在一個進程中的多個線程是共享資源的,比如在一個進程中,有一個全局變量 count 用來計數,現在我們聲明多個線程,每個線程運行時都給 count 加 1,讓我們來看看效果如何,代碼實現如下:
import threading import timecount = 0class MyThread(threading.Thread):def __init__(self):threading.Thread.__init__(self)def run(self):global counttemp = count + 1time.sleep(0.001)count = tempthreads = [] for _ in range(1000):thread = MyThread()thread.start()threads.append(thread)for thread in threads:thread.join() print(f'Final count: {count}')在這里,我們聲明了 1000 個線程,每個線程都是現取到當前的全局變量 count 值,然后休眠一小段時間,然后對 count 賦予新的值。
那這樣,按照常理來說,最終的 count 值應該為 1000。但其實不然,我們來運行一下看看。
運行結果如下:
Final count: 69最后的結果居然只有 69,而且多次運行或者換個環境運行結果是不同的。
這是為什么呢?因為 count 這個值是共享的,每個線程都可以在執行 temp = count 這行代碼時拿到當前 count 的值,但是這些線程中的一些線程可能是并發或者并行執行的,這就導致不同的線程拿到的可能是同一個 count 值,最后導致有些線程的 count 的加 1 操作并沒有生效,導致最后的結果偏小。
所以,如果多個線程同時對某個數據進行讀取或修改,就會出現不可預料的結果。為了避免這種情況,我們需要對多個線程進行同步,要實現同步,我們可以對需要操作的數據進行加鎖保護,這里就需要用到 threading.Lock 了。
加鎖保護是什么意思呢?就是說,某個線程在對數據進行操作前,需要先加鎖,這樣其他的線程發現被加鎖了之后,就無法繼續向下執行,會一直等待鎖被釋放,只有加鎖的線程把鎖釋放了,其他的線程才能繼續加鎖并對數據做修改,修改完了再釋放鎖。這樣可以確保同一時間只有一個線程操作數據,多個線程不會再同時讀取和修改同一個數據,這樣最后的運行結果就是對的了。
我們可以將代碼修改為如下內容:
import threading import timecount = 0class MyThread(threading.Thread):def __init__(self):threading.Thread.__init__(self)def run(self):global countlock.acquire()temp = count + 1time.sleep(0.001)count = templock.release()lock = threading.Lock() threads = [] for _ in range(1000):thread = MyThread()thread.start()threads.append(thread)for thread in threads:thread.join() print(f'Final count: {count}')在這里我們聲明了一個 lock 對象,其實就是 threading.Lock 的一個實例,然后在 run 方法里面,獲取 count 前先加鎖,修改完 count 之后再釋放鎖,這樣多個線程就不會同時獲取和修改 count 的值了。
運行結果如下:
Final count: 1000這樣運行結果就正常了。
關于 Python 多線程的內容,這里暫且先介紹這些,關于 theading 更多的使用方法,如信號量、隊列等,可以參考官方文檔:https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading。
5.Python 多線程的問題
由于 Python 中 GIL 的限制,導致不論是在單核還是多核條件下,在同一時刻只能運行一個線程,導致 Python 多線程無法發揮多核并行的優勢。
GIL 全稱為 Global Interpreter Lock,中文翻譯為全局解釋器鎖,其最初設計是出于數據安全而考慮的。
在 Python 多線程下,每個線程的執行方式如下:
- 獲取 GIL
- 執行對應線程的代碼
- 釋放 GIL
可見,某個線程想要執行,必須先拿到 GIL,我們可以把 GIL 看作是通行證,并且在一個 Python 進程中,GIL 只有一個。拿不到通行證的線程,就不允許執行。這樣就會導致,即使是多核條件下,一個 Python 進程下的多個線程,同一時刻也只能執行一個線程。
不過對于爬蟲這種 IO 密集型任務來說,這個問題影響并不大。而對于計算密集型任務來說,由于 GIL 的存在,多線程總體的運行效率相比可能反而比單線程更低。
總結
以上是生活随笔為你收集整理的第05讲:多路加速,了解多线程基本原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 第08讲:解析无所不能的正则表达式
- 下一篇: 第06讲:多路加速,了解多进程基本原理