Python 並行計算那點事(第1部分) — 譯文 [原創]
Python 並行計算的那點事(第1部分)(The Python Concurrency Story – Part 1)
英文原文://powerfulpython.com/blog/python-concurrency-story-pt1/
本文://www.cnblogs.com/popapa/p/python_concurrency1.html
採集日期:2021-05-02
以編寫軟體為業有一件事很不錯,就是能讓人保持謙卑。我一度以為自己很聰明,並對此有點洋洋自得。直到開始每天寫程式碼的日子,才發現並非如此。海森堡 Bug(Heisenbug)就像一個小瘟神一般,靜靜地等著我狂妄自大的那一刻。。。突然一個 Bug 就被我放了進來,為了找到它花了3個小時,修復卻只消1行程式碼。
當然,對於很多人來說,能讓我們保持謙卑的緣由就是並行計算(Concurrency)。從現在開始,無論對並行計算喜歡與否,作為專業的軟體工程師,我們都不得不對它做出妥善的考慮和理解。這就需要研發出一些思維模型,能夠對其進行清晰地演繹,並且掌握一些為了完成工作所必需的軟體工具。理想情況下,還能漸漸學會少發生一些那種找找3小時、修修1行程式碼的 Bug。
雖然基本原理是通用的,但軟體如何實現在很大程度上取決於所用的開發語言。為了實現並行計算,每種開發語言都有著各自的抽象、語法和支援庫。本文介紹 Python 的並行計算。。。從某種意義上說,這是一種世界觀,用來處理同時發生的多件事情。在21世紀,了解並行計算的來龍去脈能讓您獲益匪淺。
對並行計算表現能力最強大的可能就是 C 語言了[1]。用 C 語言實現的並行計算,能夠真正挑戰電腦的物理極限。因為可以利用一些非常底層的系統調用,類似於 Linux 中的clone()
,其實那就是用來實現執行緒的。有了這些利器,相當於掌控了整台虛擬設備!
高級語言通常不會給出那麼高的自由度,以便換取很多其他的便利。比如,普通的 PHP 根本不允許創建執行緒。並且在進程級別上幹活的工作量會很大。謝天謝地,現在不是用 PHP 編寫程式碼,而是用 Python,它自帶了一套很有意思的並行計算體系。只要完全理解了這套體系,您就會成為全球排名前1%的 Python 程式設計師。
為了實現上述目標,有必要真正弄明白由現代作業系統提供給 Python 使用的並發原語(Primitive)。理解了這一點,不僅會讓您成為更好的 Python 程式設計師,還將提升您這輩子所有語言的開發水平。
太酷了吧!興奮嗎?我就很興奮。那就開始吧!
(順便說一句,最讓人興奮的部分是深入探討何時執行緒不是真正的執行緒。您到時候自然就明白了。)
進程和執行緒(Processes and Threads)
就從基礎知識開始吧。現代作業系統為執行執行緒提供了兩種組織方式。進程就是一個正在運行的程式。執行緒是進程中的活動單位。這就為如何實現並行計算提供了兩種基本的選擇:N個進程或N個執行緒。
如果做些深入的了解,執行緒和進程確實有很多相似之處。其實在 Linux 中,之前提到的系統調用 clone()
既可以用於創建進程,也可以用來創建執行緒,只要調用函數時提供不同的參數即可。
在實操時,兩者的主要區別在於共享和不共享的東西不同。如果一個進程創建了兩個子進程,則每個子進程都擁有自己的記憶體,沒有什麼共享的東西。(默認如此,有參數可以進行修改。)而新的執行緒不僅會共享其父進程的記憶體,還會共享文件描述符、文件系統的上下文和訊號處理過程。
採用多執行緒而不是多進程,有一些實實在在的好處。執行緒在記憶體佔用方面要輕量得多。同樣是守護程式,相比生成10個執行緒而言,顯然生成10個不共享記憶體的子進程會佔用更多的記憶體空間。此外,執行緒之間的通訊和同步都比進程要簡單。根據定義,進程間的任何通訊都要用到 IPC 調用,因此還會帶來切入內核態的開銷。當然可以在進程之間共享記憶體,但工作量要比執行緒多些[2]。
執行緒的悲哀(The Tragedy of Threads)
簡而言之,在多執行緒和多進程這兩個並行模型中,理論上用執行緒可以寫出性能更高的應用程式。
哦哦,我說的只是「理論上」吧?沒錯。我們會被帶到溝里去:編寫無錯的多執行緒程式碼非常困難。您會遭遇各種微妙的、令人困惑的 Bug,想要很容易就重現這些 Bug 需要靠運氣,不走運的話就難了。競態條件(Race Condition)、死鎖(Deadlock)、活鎖(Live Lock)等等,還有很多。
解決這些問題的代價就是耗費開發時間。安全的執行緒編程涉及到規範地使用同步原語(Synchronization Primitive),諸如鎖和互斥鎖(Mutex)。作為一名優秀的軟體工程師,這是應付不時之需的工具包(如果您還沒有的話)。但不必這麼做總還是最好的。
(等等,這是前兆嗎?我覺得是吧。。。)
此外還需要考慮一點,也是 Python 所特有的:Python 的執行緒並不完全像它看上去的那樣。
何時執行緒不是真正的執行緒(When Threads Aren’t Really Threads)
上述執行緒實際上指的是作業系統執行緒(OS Thread)。在用 C 語言編寫程式時,調用pthread_create
(OS X,Linux)或CreateThread
(Windows)即可獲得這種執行緒。這是真正的執行緒,是由作業系統內核分配和管理的。但如果用 Python 這種高級語言創建執行緒,就不一定了,至少不完全是。
在較新版的 Python 中,只要線創建threading.Thread
的實例,然後調用其start()
方法即可啟動執行緒。已啟動的執行緒確實分到了一個獨立的作業系統執行緒,在大多數平台上確實如此[3]。兩者的區別在於:兩個作業系統執行緒可以同時運行,以充分利用各自獨立的核心或 CPU。但一般情況下兩個 Python 執行緒卻無法同時運行。
這是全局解釋器鎖導致的,也即 GIL。標準 Python 中存在一種機制,同時只允許1個執行緒運行 Python 位元組碼[4]。即便是在128核的野獸級機器上運行,標準的多執行緒 Python 程式在任一時刻也只能用到其中1個核。[5]
乍一看這似乎很糟糕,但事實證明,對於大部分工程領域而言,GIL 根本就不算什麼重大限制。就純粹的 CPU 性能而言[6],Python 的執行緒確實有些不如作業系統執行緒。不過 Python 進程完全不受 GIL 的影響,進程就是我們的出路,也是拯救處理器受限(CPU-Bound)任務的出路。第2部分將會討論進程。
-
其實彙編語言的表現能力更強。不過當前很少有人需要用到這麼底層的東西了,在可能遭遇的情形下,C 的並行計算能力幾乎一樣強大。 ↩︎
-
在較新的 Linux 中,我知道的最佳方案是
mmap()
,當然其他幾種機制也是可行的。 ↩︎ -
某些內核不支援執行緒的作業系統除外。我希望您不必為此擔心。 ↩︎
-
引入 GIL 是一個非常好的決定。這樣解釋器的實現難度就降低了數量級,同時在通常的單執行緒情況下又維持住了性能。別忘了,Python 是由志願者提供的。 ↩︎
-
其實這隻說對了大約98%。解決方案有很多,具體取決於您願意投入多大的精力。用 C 或 C++編寫的擴展模組可以臨時釋放 GIL。因此,像 numpy 這樣的軟體包並不受單核運行的限制。
而且,至少還有其他 Python 解釋器(Jython、IronPython、PyPy)具有實驗性的不帶 GIL 的分支(Branch)發布。不過對於您實際編寫的純 Python 程式碼,超過99%都只能完全用足一個 CPU 核。 ↩︎ -
當然,那些並沒有接近 CPU 極限的執行緒,可以採用一些非常有用的編程模式,因此 GIL 甚至都無需多慮。建立一個響應式(Responsive)UI 就是個很好的例子:主執行緒可以迅速地賣力幹活,而輔助執行緒則負責監測用戶的輸入、停止計算或執行其他操作。 ↩︎