- 原文地址:Intro to Threads and Processes in Python
- 原文作者:Brendan Fortuner
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:lsvih
- 校對者:yqian1991
初學者的並行程式設計指南
在參加 Kaggle 的 Understanding the Amazon from Space 比賽時,我試圖對自己程式碼的各個部分進行加速。速度在 Kaggle 比賽中至關重要。高排名常常需要嘗試數百種模型結構與超參組合,能在一個持續一分鐘的 epoch 中省出 10 秒都是一個巨大的勝利。
讓我吃驚的是,資料處理是最大的瓶頸。我用了 Numpy 的矩陣旋轉、矩陣翻轉、縮放及裁切等操作,在 CPU 上進行運算。Numpy 和 Pytorch 的 DataLoader 在某些情況中使用了並行處理。我同時會執行 3 到 5 個實驗,每個實驗都各自進行資料處理。但這種處理方式看起來效率不高,我希望知道我是否能用並行處理來加快所有實驗的執行速度。
什麼是並行處理?
簡單來說就是在同一時刻做兩件事情,也可以是在不同的 CPU 上分別執行程式碼,或者說當程式等待外部資源(檔案載入、API 呼叫等)時把“浪費”的 CPU 週期充分利用起來提高效率。
下面的例子是一個“正常”的程式。它會使用單執行緒,依次進行下載一個 URL 列表的內容。
下面是一個同樣的程式,不過使用了 2 個執行緒。它把 URL 列表分給不同的執行緒,處理速度幾乎翻倍。
如果你對如何繪製以上圖表感到好奇,可以參考原始碼,下面也簡單介紹一下:
- 在你函式內部加上一個計時器,並返回函式執行的起始與結束時間
URLS = [url1, url2, url3, ...]
def download(url, base):
start = time.time() - base
resp = urlopen(url)
stop = time.time() - base
return start,stop
複製程式碼
- 單執行緒程式的視覺化如下:多次執行你的函式,並將多個開始結束的時間儲存下來
results = [download(url, 1) for url in URLS]
複製程式碼
- 將 [start, stop] 的結果陣列進行轉置,繪製柱狀圖
def visualize_runtimes(results):
start,stop = np.array(results).T
plt.barh(range(len(start)), stop-start, left=start)
plt.grid(axis=’x’)
plt.ylabel("Tasks")
plt.xlabel("Seconds")
複製程式碼
多執行緒的圖表生成方式與此類似。Python 的併發庫一樣可以返回結果陣列。
程式 vs 執行緒
一個程式就是一個程式的例項(比如 Jupyter notebook 或 Python 直譯器)。程式啟動執行緒(子程式)來處理一些子任務(比如按鍵、載入 HTML 頁面、儲存檔案等)。執行緒存活於程式內部,執行緒間共享相同的記憶體空間。
舉例:Microsoft Word
當你開啟 Word 時,你其實就是建立了一個程式。當你開始打字時,程式啟動了一些執行緒:一個執行緒專門用於獲取鍵盤輸入,一個執行緒用於顯示文字,一個執行緒用於自動儲存檔案,還有一個執行緒用於拼寫檢查。在啟動這些執行緒之後,Word 就能更好的利用空閒的 CPU 時間(等待鍵盤輸入或檔案載入的時間)讓你有更高的工作效率。
程式
- 由作業系統建立,以執行程式
- 一個程式可以包括多個執行緒
- 兩個程式可以在 Python 程式中同時執行程式碼
- 啟動與終止程式需要花費更多的時間,因此用程式比用執行緒的開銷更大
- 由於程式不共享記憶體空間,因此程式間交換資訊比執行緒間交換資訊要慢很多。在 Python 中,用序列化資料結構(如陣列)的方法進行資訊交換會花費 IO 處理級別的時間。
執行緒
- 執行緒是在程式內部的類似迷你程式的東西
- 不同的執行緒共享同樣的記憶體空間,可以高效地讀寫相同的變數
- 兩個執行緒不能在同一個 Python 程式中執行程式碼(有解決這個問題的方法
*
)
CPU vs 核
CPU,或者說處理器,管理著計算機最基本的運算工作。CPU 有一個或著多個核,可以讓 CPU 同時執行程式碼。
如果只有一個核,那麼對 CPU 密集型任務(比如迴圈、運算等)不會有速度的提升。作業系統需要在很小的時間片在不同的任務間來回切換排程。因此,做一些很瑣碎的操作(比如下載一些圖片)時,多工處理反而會降低處理效能。這個現象的原因是在啟動與維護多個任務時也有效能的開銷。
Python 的 GIL 鎖問題
CPython(python 的標準實現)有一個叫做 GIL(全域性解釋鎖)的東西,會阻止在程式中同時執行兩個執行緒。一些人非常不喜歡它,但也有一些人喜歡它。目前有一些解決它的方法,不過 Numpy 之類的庫大都是通過執行外部 C 語言程式碼來繞過這種限制。
何時使用執行緒,何時使用程式?
- 得益於多核與不存在 GIL,多程式可以加速 CPU 密集型的 Python 程式。
- 多執行緒可以很好的處理 IO 任務或涉及外部系統的任務,因為執行緒可以將不同的工作高效地結合起來。而程式需要對結果進行序列化才能匯聚多個結果,這需要消耗額外的時間。
- 由於 GIL 的存在,多執行緒對 CPU 密集的 Python 程式沒有什麼幫助。
*
對於點積等某些運算,Numpy 繞過了 Python 的 GIL 鎖,能夠並行執行程式碼。
並行處理示例
Python 的 concurrent.futures 庫用起來輕鬆愉快。你只需要簡單的將函式、待處理的物件列表和併發的數量傳給它即可。在下面幾節中,我會以幾種實驗來演示何時使用執行緒何時使用程式。
def multithreading(func, args,
workers):
with ThreadPoolExecutor(workers) as ex:
res = ex.map(func, args)
return list(res)
def multiprocessing(func, args,
workers):
with ProcessPoolExecutor(work) as ex:
res = ex.map(func, args)
return list(res)
複製程式碼
API 呼叫
對於 API 呼叫,多執行緒明顯比序列處理與多程式速度要快很多。
def download(url):
try:
resp = urlopen(url)
except Exception as e:
print ('ERROR: %s' % e)
複製程式碼
2 個執行緒
4 個執行緒
2 個程式
4 個程式
IO 密集型任務
我傳入了一個巨大的文字,以觀測執行緒與程式的寫入效能。執行緒效果較好,但多程式也讓速度有所提升。
def io_heavy(text):
f = open('output.txt', 'wt', encoding='utf-8')
f.write(text)
f.close()
複製程式碼
序列
%timeit -n 1 [io_heavy(TEXT,1) for i in range(N)]
>> 1 loop, best of 3: 1.37 s per loop
複製程式碼
4 個執行緒
4 個程式
CPU 密集型任務
由於沒有 GIL,可以在多核上同時執行程式碼,多程式理所當然的勝出。
def cpu_heavy(n):
count = 0
for i in range(n):
count += i
複製程式碼
序列: 4.2 秒
4 個執行緒: 6.5 秒
4 個程式: 1.9 秒
Numpy 中的點積
不出所料,無論是用多執行緒還是多程式都不會對此程式碼有什麼幫助。Numpy 在幕後執行外部的 C 語言程式碼,繞開了 GIL。
def dot_product(i, base):
start = time.time() - base
res = np.dot(a,b)
stop = time.time() - base
return start,stop
複製程式碼
序列: 2.8 秒
2 個執行緒: 3.4 秒
2 個程式: 3.3 秒
以上實驗的 Notebook 請參考此處,你可以自己來複現這些實驗。
相關資源
以下是我在探索這個主題時的一些參考文章。特別推薦 Nathan Grigg 的這篇部落格,給了我視覺化方法的靈感。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。