Python 多線程編程

人生苦短,我用 Python!

我們知道,多線程與單線程相比,可以提高 CPU 利用率,加快程序的響應速度。

單線程是按順序執行的,比如用單線程執行如下操作:

6秒讀取文件1

9秒處理文件1

5秒讀取文件2

8秒處理文件2

總共用時 28 秒,如果開啟兩條線程來執行上面的操作(假設處理器為多核 CPU),如下所示:

6秒讀取文件1 + 5秒讀取文件2

9秒處理文件1 + 8秒處理文件2

只需 15 秒就可完成。

1 線程與進程

1.1 簡介

說到線程就不得不提與之相關的另一概念:進程,那麼什麼是進程?與線程有什麼關係呢?簡單來說一個運行著的應用程序就是一個進程,比如:我啟動了自己手機上的酷貓音樂播放器,這就是一個進程,然後我隨意點了一首歌曲進行播放,此時酷貓啟動了一條線程進行音樂播放,聽了一部分,我感覺歌曲還不錯,於是我按下了下載按鈕,此時酷貓又啟動了一條線程進行音樂下載,現在酷貓同時進行著音樂播放和音樂下載,此時就出現了多線程,音樂播放線程與音樂下載線程並行運行,說到並行,你一定想到了併發吧,那並行與併發有什麼區別呢?並行強調的是同一時刻,併發強調的是一段時間內。線程是進程的一個執行單元,一個進程中至少有一條線程,進程是資源分配的最小單位,線程是 CPU 調度的最小單位。

線程一般會經歷新建(New)、就緒(Runnable)、運行(Running)、阻塞(Blocked)、死亡(Dead)5 種狀態,當線程被創建並啟動後,並不會直接進入運行狀態,也不會一直處於運行狀態,CPU 可能會在多個線程之間切換,線程的狀態也會在就緒和運行之間轉換。

1.2 Python 中的線程與進程

Python 提供了 _thread(Python3 之前名為 thread ) 和 threading 兩個線程模塊。_thread 是低級、原始的模塊,threading 是高級模塊,對 _thread 進行了封裝,增強了其功能與易用性,絕大多數時候,我們只需使用 threading 模塊即可。下一節我們會對 threading 模塊進行詳細介紹。

Python 提供了 multiprocessing 模塊對多進程進行支持,它使用了與 threading 模塊相似的 API 產生進程,除此之外,還增加了新的 API,用於支持跨多個輸入值並行化函數的執行及跨進程分配輸入數據,詳細用法可以參考官方文檔 https://docs.python.org/zh-cn/3/library/multiprocessing.html。

2 GIL

要說 Python 的多線程,必然繞不開 GIL,可謂成也 GIL 敗也 GIL,到底 GIL 是啥?怎麼來的?為什麼說成也 GIL 敗也 GIL 呢?下面就帶著這幾個問題,給大家介紹一下 GIL。

2.1 GIL 相關概念

GIL 全稱 Global Interpreter Lock(全局解釋器鎖),是 Python 解釋器 CPython 採用的一種機制,通過該機制來控制同一時刻只有一條線程執行 Python 字節碼,本質是一把全局互斥鎖,將並行運行變成串行運行。

什麼是 CPython 呢?我們從 Python 官方網站下載安裝 Python 後,獲得的官方解釋器就是 CPython,因其是 C 語言開發的,故名為 CPython,是目前使用最廣泛的 Python 解釋器;因為我們大部分環境下使用的默認解釋器就是 CPython,有些人會認為 CPython 就是 Python,進而以為 GIL 是 Python 的特性,其實 CPython 只是一種 Python 解釋器,除了 CPython 解釋器還有:PyPy、Psyco、Jython (也稱 JPython)、IronPython 等解釋器,其中 Jython 與 IronPython 分別採用 Java 與 C# 語言實現,就沒有采用 GIL 機制;而 GIL 也不是 Python 特性,Python 可以完全獨立於 GIL 運行。

2.2 GIL 起源與發展

我們已經知道了 GIL 是 CPython 解釋器中引入的機制,那為什麼 CPython 解釋器中要引入 GIL 呢?GIL 一開始出現是因為 CPython 解釋器的內存管理不是線程安全的,也就是採用 GIL 這把鎖解決 CPython 的線程安全問題。

隨著時間的推移,計算機硬件逐漸向多核多線程方向發展,為了更加充分的利用多核 CPU 資源,各種編程語言開始對多線程進行支持,Python 也加入了其中,儘管多線程的編程方式可以提高程序的運行效率,但與此同時也帶來了線程間數據一致性和狀態同步的問題,解決這個問題最簡單的方式就是加鎖,於是 GIL 這把鎖再次登場,很容易便解決了這個問題。

慢慢的越來越多的代碼庫開發者開始接受了這種設定,進而開始大量依賴這種特性,因為默認加了 GIL 後,Python 的多線程便是線程安全的了,開發者在實際開發無需再考慮線程安全問題,省掉了不少麻煩。

對於 CPython 解釋器中的多線程程序,為了保證多線程操作安全,默認使用了 GIL 鎖,保證任意時刻只有一個線程在執行,其他線程處於等待狀態。

2.3 成也 GIL,敗也 GIL

以前為了解決多線程的線程操作安全問題,CPython 採用了 GIL 鎖的方式,這種方式雖然解決了線程操作安全問題,但由於同一時刻只能有一條線程執行,等於主動放棄了線程並行執行的機會,因此在目前 CPython 下的多線程並不是真正意義上的多線程。

現在這種情況,我們可能會想要實現真正意義上的多線程,可不可以去掉 GIL 呢?答案是可以的,但是有一個問題:依賴這個特性的代碼庫太多了,現在已經是尾大不掉了,使去除 GIL 的工作變得舉步維艱。

當初為了解決多線程帶來的線程操作安全問題使用了 GIL,現在又發現 GIL 方式下的多線程比較低效,想要去掉 GIL,但已經到了尾大不掉的地步了,真是成也 GIL,敗也 GIL。

對於 CPython 下多線程的低效問題,除了去掉 GIL,還有什麼其他解決方案嗎?我們來簡單瞭解下:

1)使用無 GIL 機制的解釋器;如:Jython 與 IronPython,但使用這兩個解釋器失去了利用 C 語言模塊一些優秀特性的機會,因此這種方式還是比較小眾。

2)使用 multiprocess 代替 threading;multiprocess 使用了與 threading 模塊相似的 API 產生進程,不同之處是它使用了多進程而不是多線程,每個進程有自己獨立的 GIL,因此不會出現進程之間的 GIL 爭搶,但這種方式只對計算密集型任務有效,通過後面的示例我們也能得出這個結論。

3 多線程實現

_thread 模塊是一個底層模塊,功能較少,當主線程運行完畢後,如果不做任何處理,會立刻把子線程給結束掉,現實中幾乎很少使用該模塊,因此不作過多介紹。對於多線程開發推薦使用 threading 模塊,這裡我們簡單瞭解下通過該模塊實現多線程,詳細介紹我們放在了下一節多線程的文章中。

threading 模塊通過 Thread 類提供對多線程的支持,首先,我們要導入 threading 中的類 Thread,示例如下:

from threading import Thread

依賴導入了,接下來要就要創建線程了,直接創建 Thread 實例即可,示例如下:

# method 為線程要執行的具體方法

p1 = Thread(target=method)

若要實現兩條線程,再創建一個 Thread 實例即可,示例如下:

p2 = Thread(target=method)

需要實現更多條的線程也是一個道理。線程創建好了,通過 start 方法啟動即可,示例如下:

p1.start()

p2.start()

如果是多線程任務,我們可能需要等待所有線程執行完成再進行下一步操作,使用 join 方法即可。示例如下:

# 等待線程 p1、p2 都執行完

p1.join()

p2.join()

4 多進程實現

Python 的多進程通過 multiprocessing 模塊的 Process 類實現,它的使用基本與 threading 模塊的 Thread 類一致,因此這裡就不一步步說了,直接看示例:

# 導入 Process

from multiprocessing import Process

# 創建兩個進程實例:p1、p2,method 是要執行的具體方法

p1 = Process(target=method)

p2 = Process(target=method)

# 啟動兩個進程

p1.start()

p2.start()

# 等待進程 p1、p2 都執行完

p1.join()

p2.join()

5 效率大比拼

現在我們已經瞭解了 Python 線程和進程的基本使用,那麼 Python 單線程、多線程、多進程的實際工作效率如何呢?下面我們就以計算密集型和 I/O 密集型兩種任務考驗一下它們。

5.1 計算密集型任務

計算密集型任務的特點是要進行大量的計算,消耗 CPU 資源,比如:計算圓周率、對視頻進行解碼 ... 全靠 CPU 的運算能力,下面看一下單線程、多線程、多進程的實際耗時情況。

1)單線程就是一條線程,我們直接以主線程為例,來看下單線程表現如何:

# 計算密集型任務-單線程

import os,time

def task():

ret = 0

for i in range(100000000):

ret *= i

if __name__ == '__main__':

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(5):

task()

stop = time.time()

print('單程耗時 %s' % (stop - start))

# 測試結果:

'''

本機為 4 核 CPU

單線程耗時 23.19068455696106

'''

2)來看多線程表現:

# 計算密集型任務-多線程

from threading import Thread

import os,time

def task():

ret = 0

for i in range(100000000):

ret *= i

if __name__ == '__main__':

arr = []

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(5):

p = Thread(target=task)

arr.append(p)

p.start()

for p in arr:

p.join()

stop = time.time()

print('多線程耗時 %s' % (stop - start))

# 測試結果:

'''

本機為 4 核 CPU

多線程耗時 25.024707317352295

'''

3)來看多進程表現:

# 計算密集型任務-多進程

from multiprocessing import Process

import os,time

def task():

ret = 0

for i in range(100000000):

ret *= i

if __name__ == '__main__':

arr = []

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(5):

p = Process(target=task)

arr.append(p)

p.start()

for p in arr:

p.join()

stop = time.time()

print('計算密集型任務,多進程耗時 %s' % (stop - start))

# 輸出結果

'''

本機為 4 核 CPU

計算密集型任務,多進程耗時 14.087027311325073

'''

通過測試結果我們發現,在 CPython 下執行計算密集型任務時,多進程效率最優,多線程還不如單線程。

5.2 I/O 密集型任務

涉及到網絡、磁盤 I/O 的任務都是 I/O 密集型任務,這類任務的特點是 CPU 消耗很少,任務的大部分時間都在等待 I/O 操作完成(因為 I/O 的速度遠遠低於 CPU 和內存的速度)。通過下面例子看一下耗時情況:

1)來看單線程表現:

# I/O 密集型任務-單線程

import os,time

def task():

f = open('tmp.txt','w')

if __name__ == '__main__':

arr = []

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(500):

task()

stop = time.time()

print('I/O 密集型任務,多進程耗時 %s' % (stop - start))

# 輸出結果

'''

本機為 4 核 CPU

I/O 密集型任務,單線程耗時 0.2964005470275879

'''

2)來看多線程表現:

# I/O 密集型任務-多線程

from threading import Thread

import os,time

def task():

f = open('tmp.txt','w')

if __name__ == '__main__':

arr = []

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(500):

p = Thread(target=task)

arr.append(p)

p.start()

for p in arr:

p.join()

stop = time.time()

print('I/O 密集型任務,多進程耗時 %s' % (stop - start))

# 輸出結果

'''

本機為 4 核 CPU

I/O 密集型任務,多線程耗時 0.24960064888000488

'''

3)來看多進程表現:

# I/O 密集型任務-多進程

from multiprocessing import Process

import os,time

def task():

f = open('tmp.txt','w')

if __name__ == '__main__':

arr = []

print('本機為',os.cpu_count(),'核 CPU')

start = time.time()

for i in range(500):

p = Process(target=task)

arr.append(p)

p.start()

for p in arr:

p.join()

stop = time.time()

print('I/O 密集型任務,多進程耗時 %s' % (stop - start))

# 輸出結果

'''

本機為 4 核 CPU

I/O 密集型任務,多進程耗時 21.05265736579895

'''

通過 I/O 密集型任務在 CPython 下的測試結果我們發現:多線程效率優於多進程,單線程與多線程效率接近。

對於一個運行的程序來說,隨著 CPU 的增加執行效率必然會有所提高,因此大多數時候,一個程序不會是純計算或純 I/O,所以我們只能相對的去看一個程序是計算密集型還是 I/O 密集型。

本節給大家介紹了 Python 多線程,讓大家對 Python 多線程現狀有了一定了解,能夠根據任務類型選擇更加高效的處理方式。


分享到:


相關文章: