Python 並行計算的那點事(第1部分)(The Python Concurrency Story - Part 1)
英文原文:https://powerfulpython.com/blog/python-concurrency-story-pt1/
本文:https://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 就是個很好的例子:主線程可以迅速地賣力干活,而輔助線程則負責監測用戶的輸入、停止計算或執行其他操作。 ↩︎
