Python 中的多進程與線程 每個數據科學家都需要知道

本文最初發佈於 FLOYDHUB 博客,經原作者 Sumit Ghosh 授權由 InfoQ 中文站翻譯並分享。

導讀:線程和進程都是現在計算機領域比較時髦的用語。進程 (Process) 是計算機中已運行程序的實體。進程本身不會運行,是線程的容器。程序本身只是指令的集合,進程纔是程序(那些指令) 的真正運行。若干進程有可能與同一個程序相關係,且每個進程皆可以同步(循序) 或不同步(平行) 的方式獨立運行。進程爲現今分時系統的基本運作單位。線程(thread),操作系統技術中的術語,是操作系統能夠進行運算調度的最小單位。它被包含在進程之中,一條線程指的是進程中一個單一順序的控制流,一個進程中可以併發多個線程,每條線程並行執行不同的任務。今天,我們翻譯並分享 Sumit Ghosh 撰寫的關於 Python 中的多進程與線程的方方面面,這些內容對於每個有志於成爲數據科學家的從業者都是應知必會的內容。

每個數據科學項目遲早都會面臨一個不可避免的挑戰:速度。使用更大的數據集會導致處理速度變慢,因此,最終不得不考慮優化算法的運行時間。正如大多數人所知道的,並行化就是這種優化的必要步驟。Python 爲並行化提供了兩個內置庫:multiprocessing(多進程)和 threading(線程)。在本文中,我們將探討數據科學家如何在這兩者之間進行選擇,以及在選擇時應記住哪些因素。

並行計算與數據科學

衆所周知,數據科學是一門處理大量數據,並從中提取有用見解的科學。通常情況下,我們在數據上執行的操作很容易實現並行化,這意味着不同的處理可以在數據上一次運行一個操作,然後在最後將結果組合起來以得到完整的結果。

爲了更好地理解並行性,讓我們考慮一個真實世界的類比。假設你需要打掃家裏的三個房間,你可以一個人包攬所有的事情:一個接一個地打掃房間,或者你也可以叫來兩個人幫助你,你們每個人只打掃一個房間。在後一種方法中,每個人都並行地處理整個任務的一部分,從而減少完成了任務所需的總時間,這就是並行性。

在 Python 中,可以通過兩種不同的方式實現並行處理:多進程和線程。

多進程和線程:理論

從根本上來說,多進程和線程是實現並行計算的兩種方法,分別使用進程和線程作爲處理代理。要了解這些方法的工作原理,我們就必須弄清楚什麼是進程,什麼是線程。

進程

進程是正在執行的計算機程序的實例。每個進程都有自己的內存空間,用於存儲正在運行的指令,以及需要存儲和訪問用來執行的任何數據。

線程

線程是進程的組件,可以並行運行。一個進程可以有多個線程,它們共享相同的內存空間,即父進程的內存空間。這意味着要執行的代碼以及程序中聲明的所有變量,將由所有線程共享。

進程和線程(圖:筆者與 Cburnett)

例如,讓我們考慮一下你的計算機上正在運行的程序。你可能正在瀏覽器中閱讀本文,瀏覽器可能打開了多個標籤頁。你還可能同時通過 Spotify 桌面應用收聽音樂。瀏覽器和 Spotify 應用程序是不同的進程,它們中的每一個都可以使用多個進程或線程來實現並行性。瀏覽器中的不同標籤頁可能在不同的線程中運行。Spotify 可以在一個線程中播放音樂,在另一個線程中從互聯網下載音樂,並使用第三個線程來顯示 GUI。這就叫做多線程(multithreading)。多進程(多即個進程)也可以做到這一點。事實上,大多數像 Chrome 和 Firefox 這樣的現代瀏覽器使用的是多進程而不是多線程來處理多個標籤。

技術細節

  • 一個進程的所有線程都位於同一個內存空間中,而進程有各自獨立的內存空間。
  • 與進程相比,線程更輕量級,並且開銷更低。生成進程比生成線程要慢一些。
  • 在線程之間共享對象更容易,因爲它們共享的是相同的內存空間。爲了在進程之間實現同樣的效果,我們必須使用一些類似 IPC(inter-process communication,進程間通信)模型,通常是由操作系統提供的。

並行計算的陷阱

在程序中引入並行性並不總是一個正和博弈;有一些需要注意的陷阱。最重要的陷阱如下:

  • 競態條件: 正如我們已經討論過的,線程具有共享的內存空間,因此它們可以訪問共享變量。當多個線程試圖通過同時更改同一個變量時,就會出現競態條件。線程調度程序可以在線程之間任意切換,因此,我們無法知曉線程將試圖更改數據的順序。這可能導致兩個線程中的任何一個出現不正確的行爲,特別是如果線程決定基於變量的值執行某些操作時。爲了防止這種情況的發生,可以在修改變量的代碼段周圍放置互斥鎖,這樣,一次只能有一個線程可以寫入變量。
  • 飢餓: 當線程在更長的時間內被拒絕訪問特定資源時,就會發生飢餓,因此,整個程序速度就會變慢。這可能是涉及不良的線程調度算法的意外副作用。
  • 死鎖: 過度使用互斥鎖也有一個缺點,它可能會在程序中引入死鎖。死鎖是一個線程等待另一個線程將鎖釋放,但另一個線程需要一個資源來完成第一個線程所持有的鎖。這樣,兩個線程都會停止,程序也隨之停止。死鎖可以被看作是飢餓的一種極端情況。要避免這種情況,我們必須小心不要引入太多相互依賴的鎖。
  • 活鎖: 活鎖是指線程在循環中繼續運行,但沒有任何進展。這也是由於涉及不良和互斥鎖的使用不當造成的。

Python 中的多進程和線程

全局解釋鎖

當涉及到 Python 時,有一些奇怪的地方需要記住。我們知道,線程共享相同的內存空間,因此必須採取特殊的預防措施,以便兩個線程不會寫入相同的內存位置。CPython 解釋器使用一種名爲 GIL 的機制或全局解釋鎖來處理這個問題。

摘自 Python 的官方 wiki :

在 CPython 中, 全局解釋鎖 (Global Interpreter Lock, GIL )是一個互斥鎖,用來保護對 Python 對象的訪問,防止多個線程同時執行 Python 字節碼。這種鎖是必要的,主要是因爲 CPython 的內存管理不是線程安全的。

請查看這個幻燈片來了解 Python GIL 的詳細信息: Understanding the Python GIL

GIL 完成了它的工作,但是也付出了代價。它有效地序列化了解釋器級別的指令。它的工作原理如下:任何線程要執行任何函數,都必須獲取全局鎖。一次只能有一個線程可以獲得全局鎖,這意味着 解釋器最終會串行地運行指令 。這種設計使內存管理做到線程安全,但結果是,它根本不能利用多個 CPU 內核。在單核 CPU 中(這正是設計師在開發 CPython 時所考慮的),這並不是什麼大問題。但是,如果使用多核 CPU 的話,那麼這個全局鎖最終將會成爲一個瓶頸了。

如果你的程序在其他地方存在更嚴重的瓶頸,例如在網絡、IO、或者用戶交互方面,那麼全局鎖這個瓶頸就變得無關緊要了。在這些情況下,線程化是一種完全有效的並行化方法。但對於計算密集型(CPU bound)的程序,線程化最終會使程序變慢。讓我們通過一些用例來探討這個問題。

線程用例

GUI 程序始終使用線程來使應用程序作出響應。例如,在文本編輯程序中,一個線程負責記錄用戶輸入,另一個線程負責顯示文本,第三個線程負責拼寫檢查,等等。在這裏,程序必須等待用戶交互,這是最大的瓶頸。使用多進程並不會使程序變得更快。

線程處理的另一個用例是 IO 密集型(IO bound)或網絡密集型的程序,比如 Web Scraper 。在這種情況下,多個線程可以負責並行抓取多個 Web 網頁。線程必須從互聯網上下載網頁,這將是最大的瓶頸,因此線程對於這種情況來說是一個完美的解決方案。網絡密集型的 Web 服務器的工作方式類似:對於它們這種情況,多進程並不比線程有任何優勢。另一個相關的例子是 TensorFlow ,它使用線程池(thread pool)來並行地轉換數據。

多進程的用例

在程序是計算密集型的,且不需要進行任何 IO 或用戶交互的情況下,那麼多進程就比線程處理更爲出色。例如,任何只處理數字的程序都將從多進程中獲得巨大的加速;事實上,線程化處理可能會降低它的運行速度。一個有趣的實際例子是 Pytorch Dataloader ,它使用多個子進程將數據加載到 GPU 中。

在 Python 中的並行化

Python 爲並行化方法提供了兩個同名的庫: multiprocessing 和 threading 。儘管它們之間存在根本的不同,但這兩個庫提供了非常相似的 API(從 Python 3.7 開始)。讓我們看看它們的實際應用。

 複製代碼

importthreading
importrandom
from functoolsimportreduce

deffunc(number):
random_list = random.sample(range(1000000), number)
returnreduce(lambda x, y: x*y, random_list)

number =50000
thread1 = threading.Thread(target=func,args=(number,))
thread2 = threading.Thread(target=func,args=(number,))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

你可以看到,我創建一個函數 func ,它創建了一個隨機數列表,然後按順序將其中的所有元素相乘。如果項目數量足夠大,比如 5 萬或 10 萬,這可能是一個相當繁重的過程。

然後,我創建了兩個線程,它們將執行相同的函數。線程對象有一個異步啓動線程的 start 方法。如果我們想等待它們終止並返回,就必須調用 join 方法,這就是我們這段代碼所做的事情。

正如你所見,在後臺將一個新線程轉化爲一個任務的 API 非常簡單。很棒的是,用於多進程的 API 也幾乎完全相同;讓我們來看一下。

 複製代碼

importmultiprocessing
importrandom
from functoolsimportreduce

deffunc(number):
random_list = random.sample(range(1000000), number)
returnreduce(lambda x, y: x*y, random_list)

number =50000
process1 = multiprocessing.Process(target=func,args=(number,))
process2 = multiprocessing.Process(target=func,args=(number,))

process1.start()
process2.start()

process1.join()
process2.join()

代碼就是這樣的,只需將 multiprocessing.Process 與 threading.Thread 進行交換。 你使用多進程實現了完全相同的程序。

很顯然,你可以用它做更多的事情,但這已不在本文的範疇之內,因此我們將不再贅述。如果有興趣瞭解更多相關信息,請查看 threading — Thread-based parallelism 

基準

現在我們已經瞭解了實現並行化的代碼是什麼樣子的,讓我們回到性能問題上來。正如我們之前所指出的,線程處理不適合計算密集型任務。在這種情況下,它最終會成爲瓶頸。我們可以使用一些簡單的基準來驗證這一點。

首先,讓我們看看上面所展示的代碼示例中線程和多進程的比較。請記住,此任務不涉及任何類型的 IO,因此它是純計算密集型的任務。

讓我們來看看 IO 密集型的任務的類似基準測試。例如,下面的函數:

 複製代碼

import requests

def func(number):
url ='http://example.com/'
foriinrange(number):
response = requests.get(url)
withopen('example.com.txt','w')asoutput:
output.write(response.text)

這個函數的作用就是隻獲取一個網頁,並將其保存到本地文件中,如此循環多次。雖然沒有什麼用,但是很直接,因此非常適合用來演示。讓我們看一下基準測試。

從這兩張圖表中可以注意到以下幾點:

  • 在這兩種情況下,單個進程比單個線程花費更多的執行時間。顯然,進程的開銷比線程更大。
  • 對於計算密集型的任務,多個進程的性能要比多個線程的性能要好得多。然而,當我們使用 8x 並行化時,這種差異就變得不那麼明顯了。由於我的筆記本的 CPU 是四核的,因此最多可以有四個進程有效地使用多個內核。因此,當我使用更多進程時,它就不能很好地進行擴展。但是,它的性能仍然比線程要好很多,因爲線程根本就不能利用多核。
  • 對於 IO 密集型的任務,那麼 CPU 就不是瓶頸了。因此,GIL 的常見限制在這裏並不適用,而多進程也沒有什麼優勢。不僅如此,線程的輕量級開銷實際上使它們比多進程更快,而且線程的性能始終優於多進程。

區別、優點和缺點

join

從所有這些討論中,我們可以得出以下結論:

  • 線程應該用於涉及 IO 或用戶交互的程序。
  • 多進程應該用於計算密集型程序。

站在數據科學家的角度來看

典型的數據處理管道可以分爲以下幾個步驟:

  1. 讀取㽜數據並存儲到主存儲器或 GPU 中。
  2. 使用 CPU 或 GPU 進行計算。
  3. 將挖掘出的信息存儲在數據庫或磁盤中。

讓我們探索一下如何在這些任務中引入並行性,以便加快它們的運行速度。

步驟 1 涉及從磁盤讀取數據,因此顯然磁盤 IO 將成爲這一步驟的瓶頸。正如我們已經討論過的,線程是並行化這種操作的最佳選擇。類似地,步驟 3 也是引入線程的理想候選步驟。

但是,步驟 2 包括了涉及 CPU 或 GPU 的計算。如果它是一個基於 CPU 的任務,那麼使用線程就沒有用;相反,我們必須進行多進程。只有這樣,我們才能充分利用 CPU 的多核並實現並行性。如果它是基於 GPU 的任務,由於 GPU 已經在硬件級別上實現了大規模並行化架構,使用正確的接口(庫和驅動程序)與 GPU 交互應該會解決其餘的問題。

現在你可能會想,“恐怕我的數據管道看起來有點不同啊;我有一些任務並不完全適合這個通用框架啊。”不過,你應該能夠觀察到此處用來決定線程和多進程之間的關係。你應該考慮的因素包括:

  • 你的任務是否具有任何形式的 IO?
  • IO 是否爲程序的瓶頸?
  • 你的任務是否依賴於 CPU 的大量計算?

考慮到這些因素,再加上上面提到的要點,你應該能夠作出自己的決定了。另外,請記住,你不必在整個程序中,使用單一形式的並行化。你應該爲程序的不同部分使用其中一種或另一種形式的並行化,以適合該特定部分的爲準。

現在,我們來看一下數據科學家可能面臨的兩個示例場景,以及如何使用並行計算來加速它們。

場景:下載電子郵件

假設你想分析你自己的創業公司收件箱裏的所有電子郵件,並瞭解趨勢:誰是發送頻率最高的發件人,在電子郵件中出現的最常見的關鍵詞是什麼,一週中的哪一天或者一天中的哪個時段收到的電子郵件最多,等等。當然,這個項目的第一步是將電子郵件下載到你的電腦上。

首先,讓我們按順序執行,不使用任何並行化。下面是要使用的代碼,它應該很容易理解。有一個函數 download_emails ,它將電子郵件的 ID 列表作爲輸入,並按順序下載它們。這會將此函數與一次 100 封電子郵件列表的 ID 一起調用。

 複製代碼

import imaplib
importtime

IMAP_SERVER ='imap.gmail.com'
USERNAME ='[email protected]'
PASSWORD ='password'

def download_emails(ids):
client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
foriinids:
print(f'Downloading mail id: {i.decode()}')
_, data = client.fetch(i,'(RFC822)')
withopen(f'emails/{i.decode()}.eml','wb')asf:
f.write(data0)
client.close()
print(f'Downloaded {len(ids)} mails!')

start=time.time()

client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
_, ids = client.search(None,'ALL')
ids = ids[0].split()
ids = ids[:100]
client.close()

download_emails(ids)
print('Time:',time.time() -start)

Time taken :: 35.65300488471985 seconds.

現在,讓我們在這個任務中引入一些並行化能力,以加快速度。在開始編寫代碼之前,我們必須在線程和多進程之間作出決定。正如你到目前爲止所瞭解的那樣,當涉及到 IO 爲瓶頸的任務時,線程就是最佳選擇。手邊的任務顯然屬於這一類,因爲它是通過互聯網訪問 IMAP 服務器。因此我們將使用 threading (線程)。

我們將要使用的大部分代碼與我們在順序情況下使用的代碼是相同的。唯一的區別是,我們將 100 封電子郵件的 ID 列表拆分爲 10 個較小的塊,每一塊包含 10 個 ID,然後創建 10 個線程並使用不同的塊調用函數 download_emails 。我使用 Python 標準庫中的 concurrent.futures.ThreadPoolExecutor 類進行線程化處理。

 複製代碼

importimaplib
importtime
fromconcurrent.futuresimportThreadPoolExecutor

IMAP_SERVER ='imap.gmail.com'
USERNAME ='[email protected]'
PASSWORD ='password'

defdownload_emails(ids):
client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
foriinids:
print(f'Downloading mail id:{i.decode()}')
_, data = client.fetch(i,'(RFC822)')
withopen(f'emails/{i.decode()}.eml','wb')asf:
f.write(data0)
client.close()
print(f'Downloaded{len(ids)}mails!')

start = time.time()

client = imaplib.IMAP4_SSL(IMAP_SERVER)
client.login(USERNAME, PASSWORD)
client.select()
_, ids = client.search(None,'ALL')
ids = ids[0].split()
ids = ids[:100]
client.close()

number_of_chunks =10
chunk_size =10
executor = ThreadPoolExecutor(max_workers=number_of_chunks)
futures = []
foriinrange(number_of_chunks):
chunk = ids[i*chunk_size:(i+1)*chunk_size]
futures.append(executor.submit(download_emails, chunk))

forfutureinconcurrent.futures.as_completed(futures):
pass
print('Time:', time.time() - start)

Time taken :: 9.841094255447388 seconds.

正如你所看到的,線程化大大加快了執行速度。

場景:使用 Scikit-Learn 進行分類

假設你有一個分類問題,想爲此使用隨機森林(random forest)分類器。因爲它是一種標準的、衆所周知的機器學習方法,所以我們不打算“ 重新發明輪子 ”,只使用 sklearn.ensemble.RandomForestClassifier 。

下面的代碼段用於演示目的。我使用輔助函數 sklearn.datasets.make_classification 創建了一個分類數據集,然後在此基礎上訓練了一個 RandomForestClassifier 。此外,我正在對代碼中做核心工作的部分進行計時,以對模型進行擬合。

 複製代碼

fromsklearn.ensembleimportRandomForestClassifier
fromsklearnimportdatasets
importtime

X, y = datasets.make_classification(n_samples=10000, n_features=50, n_informative=20, n_classes=10)

start =time.time()
model = RandomForestClassifier(n_estimators=500)
model.fit(X, y)
print('Time:',time.time()-start)

Time taken :: 34.17733192443848 seconds.

現在,我們來看看如何減少這個算法的運行時間。我們知道這個算法在一定程度上實行並行化,但什麼樣的並行化纔是合適的呢?它沒有任何 IO 瓶頸;相反,這是一項非常耗費 CPU 的任務。因此,多進程將是合理的選擇。

幸運的是, sklearn 已經在這個算法中實現了多進程,我們不必從頭開始編寫。正如你在下面的代碼中所看到的那樣,我們只需提供一個參數 n_jobs ,即它應該使用的進程數量,來啓用多進程。

 複製代碼

fromsklearn.ensembleimportRandomForestClassifier
fromsklearnimportdatasets
importtime

X, y = datasets.make_classification(n_samples=10000, n_features=50, n_informative=20, n_classes=10)

start =time.time()
model = RandomForestClassifier(n_estimators=500, n_jobs=4)
model.fit(X, y)
print('Time:',time.time()-start)

Time taken :: 14.576200723648071 seconds.

正如預期的那樣,多進程使其運行速度提高了很多。

結論

大多數(如果不是所有的話)數據科學項目將會看到並行計算速度大幅提高。事實上,許多流行的數據科學庫已經內置了並行性, 你只需啓用它即可 。因此,在嘗試自己實現它之前,請先查看正在使用的庫的文檔,並檢查它是否支持並行性(順便說一句,我強烈建議你查看 dask )。如果沒有的話,希望本文能夠幫助你自己來實現並行性。

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