[譯] Python 的多線程與多進程

初學者的並行編程指南

在參加 Kaggle 的 Understanding the Amazon from Space 比賽時,我試圖對本身代碼的各個部分進行加速。速度在 Kaggle 比賽中相當重要。高排名經常須要嘗試數百種模型結構與超參組合,能在一個持續一分鐘的 epoch 中省出 10 秒都是一個巨大的勝利。html

讓我吃驚的是,數據處理是最大的瓶頸。我用了 Numpy 的矩陣旋轉、矩陣翻轉、縮放及裁切等操做,在 CPU 上進行運算。Numpy 和 Pytorch 的 DataLoader 在某些狀況中使用了並行處理。我同時會運行 3 到 5 個實驗,每一個實驗都各自進行數據處理。但這種處理方式看起來效率不高,我但願知道我是否能用並行處理來加快全部實驗的運行速度。前端

什麼是並行處理?

簡單來講就是在同一時刻作兩件事情,也能夠是在不一樣的 CPU 上分別運行代碼,或者說當程序等待外部資源(文件加載、API 調用等)時把「浪費」的 CPU 週期充分利用起來提升效率。python

下面的例子是一個「正常」的程序。它會使用單線程,依次進行下載一個 URL 列表的內容。linux

下面是一個一樣的程序,不過使用了 2 個線程。它把 URL 列表分給不一樣的線程,處理速度幾乎翻倍。android

若是你對如何繪製以上圖表感到好奇,能夠參考源碼,下面也簡單介紹一下:ios

  1. 在你函數內部加上一個計時器,並返回函數執行的起始與結束時間
URLS = [url1, url2, url3, ...]
def download(url, base):
    start = time.time() - base
    resp = urlopen(url)
    stop = time.time() - base
    return start,stop
複製代碼
  1. 單線程程序的可視化以下:屢次執行你的函數,並將多個開始結束的時間存儲下來
results = [download(url, 1) for url in URLS]
複製代碼
  1. 將 [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 的併發庫同樣能夠返回結果數組。git

進程 vs 線程

一個進程就是一個程序的實例(好比 Jupyter notebook 或 Python 解釋器)。進程啓動線程(子進程)來處理一些子任務(好比按鍵、加載 HTML 頁面、保存文件等)。線程存活於進程內部,線程間共享相同的內存空間。github

舉例: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 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索