Python 中的多線程和多進程(上)
77 人贊了文章
說到Python中最複雜、最核心的內容,非多線程(進程)莫屬了。初學者很可能會將多線程和多進程混起來,而其實Python中兩者適合處理的任務不同。另外由於多進程的實現具有很強的系統相關性,很多使用了多進程的代碼可能換個系統就不能正常跑了。今天我們就要搞清楚究竟兩者有什麼區別,到底是什麼原因造成了多進程代碼的在不同系統上的差異性,以及在各種情況下應該採取的解決方案。上篇主要會解釋理論部分,也就是前面的兩點,最後的解決方案會在下篇中介紹。
簡介:多線程與多進程
看到多線程和多進程,不熟悉的人可能會認為兩者是同一個東西,其實兩者大有不同。其實看他們原本的英語表達就知道了,多線程是multi threading,而多進程是 multi processing。線程與進程的區別我們在操作系統中已經學習過了,簡而言之,進程可以包含多個線程,而線程是進程的一個實體。開線程的代價比開進程的代價小,而且便於通信,但是多進程更穩定一些,畢竟一個線程crash了,整個進程都會掛,而多進程之間通常是獨立的。有人看到這可能有疑問,如果我處理好線程安全性的問題,那直接用多線程不就好了。
事情遠沒有這麼簡單,由於Python的內存操作並不是線程安全的[1],Python(此處指大家廣泛使用的Python發行版CPython)的設計者對於多線程的操作加了一把鎖。這把鎖被稱為GIL(Global Interpreter Lock)。當然對於不涉及到線程安全的一些操作來說不受影響,主要是那些IO密集型的操作,以及Numpy中的一些運算。也就是說,如果你需要在Python中用並行來加速計算密集型任務的執行的話,那麼至少在GIL沒有被取消掉的當下,用多線程是走不通的。另外根據官方文檔的介紹[1],會發生一件很有趣的事情,由於系統調度的原因,在多核處理器下執行多線程的計算,會比不使用多線程還要慢。
由於GIL的存在,如果我們需要使用多核來對計算密集型任務進行加速的話,那麼我們不能使用多線程,而不得不去使用多進程。但是Python的設計在這裡很有迷惑性,他把線程和進程的介面設計的幾乎一模一樣,除了進程還可以強制終結和一些特有的屬性之外,沒有任何差別。這給人造成一種錯覺,好像是多進程和多線程之間可以無縫替換,當然這可能是設計者的願景,而其實兩者之間的區別是巨大的。前面已經介紹過了,舉個具體的例子,比如說數據的流動,在線程中只要指定好就可以了,但是進程間的數據流動一般來說沒有那邊簡單,一般都需要採用一些外部的方式來解決,如Socket,Pipe,共享內存等。另外,由於Unix/Windows可使用的多進程實現方式不同,也導致了多進程的代碼具有很強的系統依賴性。
多進程在不同系統下的實現方式
首先先來介紹下Python多進程的實現方式[2]。Unix系統下默認的實現方式是fork,而fork可以將進程複製一份,子進程可以執行與主程序不同的函數,此外,這種方式生成的進程繼承了父進程的數據,所以數據可以方便的從父進程流動到子進程。而在Windows上不支持fork,而是要使用spawn。spawn其實也是將進程複製一份,但是進程會重新執行一遍主函數裡面的代碼,就像父進程一樣,然後再去執行相應的函數。所以這就會導致一個問題就是如果我們不加任何判斷的話,這個進程會不斷的複製自身,形成新的進程。Python的設計者當然考慮到了這一點,所以如果你在spawn進程的初始階段還嘗試創建新進程的話,會報錯退出。怎麼區別主進程和父進程呢?一般會採用__name__屬性來區分。
我們用一段代碼來驗證一下之前講的這些結論:
import multiprocessing as mpimport os# Run phasedef v(): print(Run, os.getpid()) print(__name__, os.getpid())# Initialization phaseprint(Initialize, os.getpid())print(__name__, os.getpid())if __name__ == __main__: # start a new Process to execute function `v` and wait for it p = mp.Process(target=v) p.start() p.join()
代碼在Windows下的輸出結果為 (Python 2.7)
(Initialize, 6896)(__main__, 6896)(Initialize, 14284)(__parents_main__, 14284)(Run, 14284)(__main__, 14284)
主進程執行的時候,會有將當前模塊的名稱設置為__main__,而spawn出來的子進程中在初始化階段該屬性則為__parents_main__,而在執行階段名稱又會變回__main__。
Python 3下執行又會是如何呢?
Initialize 15088__main__ 15088Initialize 13992__mp_main__ 13992Run 13992__mp_main__ 13992
可以發現除了子進程的模塊名本身發生了變化以外,在子進程執行階段的模塊名也不再換回__main__。我猜測可能是不想讓你儘可能的不要在子進程裡面再開子進程吧,當然其實這也節省了開銷,畢竟不用做Context的拷貝了。[?]
在Unix環境下輸出如下 (Python 2.7)
(Initialize, 21)(__main__, 21)(Run, 22)(__main__, 22)
這個就沒有這麼複雜了,子進程沒有初始化階段,直接跳到執行部分。模塊名保持__main__不變。
當然對於Python 3.4以上的版本來說,Unix下也是可以使用spawn的,他的結果如下:
Initialize 64__main__ 64Initialize 66__mp_main__ 66Run 66__mp_main__ 66
可以看到,結果與Windows下完全一致。
看到這或許有人要吐槽Windows了,但是其實fork也有他的不足。首先就是不安全,畢竟他就直接把所有句柄全部複製給子進程了。如果兩者間的同步做的不足的話,那麼就容易出問題。對於一些典型應用,如資料庫,CUDA等等,都不是fork 安全的。另外,fork出來的子進程也不能將它對數據的改動直接傳至父進程。也就是說雖然數據拿出來簡單,可是要放回去就還是要想辦法。
多進程的數據流動方式
前面提到了多線程和多進程採用了統一的介面,他的設計目的當然是想將兩者儘可能的無縫切換,所以它實現了一些進程間數據的流動方式,包括Pipe,網路傳輸和共享內存。Pipe(管道)方式是最常用的一種數據傳輸方式,比如說我們跑些批處理或者控制台應用程序的時候,使用的輸入輸出都是使用管道方式傳輸的。管道方式適合小量數據的傳輸,因為他是基於緩存的一種傳輸方式,另外緩存設置的比較小,一個典型的緩存大小的數值是64K [3]。所以如果你嘗試用Pipe去傳輸1G左右的數據就會非常的慢。網路傳輸我們都比較熟悉了,他的傳輸速度還是比較快的,但是其實它需要對數據進行序列化和反序列化,所以當數據量上去之後用於數據轉換的時間會比較長。最終的解決方案就是共享內存了,他非常適合做大數據量情況下的高速數據傳輸,缺點就是實現較為複雜。
Python中的多進程內置實現了上述所說的所有方式,我們依次來簡單介紹一下,具體怎麼使用我們下次再介紹。首先是Pipe,它是multiprocessing中Pool以及Queue的默認傳輸方式。網路傳輸方式主要是通過mp庫中的Manager來實現,可以通過這個來實現簡單的分散式。最後,共享內存方式mp庫主要實現了Value和Array,還是比較受限的,更加靈活的結構就需要自己寫C++代碼來編譯了。當然,我們也可以自己來實現文件傳輸,比如用一系列持久化工具如CPickle,HDF5,joblib等先保存成文件,然後將文件的路徑作為參數進行傳輸即可。
這次的內容基本就講完了,下次我們會介紹一系列應用場景,然後就著那些應用場景來介紹我們究竟應該怎麼去使用多線程和多進程庫。
以上,就是文章的全部內容啦,如果感覺還意猶未盡的話,可以給我的 Github 主頁或者另外一個項目加個watch或者star之類的(滑稽),以後說不定還會再分享一些相關的經驗。
引用
[1] GlobalInterpreterLock - Python Wiki
[2] 17.2. multiprocessing - Process-based parallelism - Python 3.7.0 documentation
[3] Pipe buffer size is 4k or 64k?
推薦閱讀:
※[python] re爬取HTML網頁標籤信息總結
※PyQt5系列教程(47):極簡圖書管理(QTableWidget的使用)2
※TMDB電影數據分析報告
※用Python淺嘗數據挖掘
※Ubuntu+Python環境配置(III)—用Python