CPython 是最常見的 Python 實現,被全球數百萬開發人員廣泛使用。然而,在 CPython 程序中實現真正的並行性一直是一個難題。在這裡,我們將嘗試在作業系統和 Python 的背景下更好地理解並行性、併發性。最後,憑藉所有這些知識和新的 Python 語言內部結構,我們將研究一種可能的機制,為在單個 CPython 3.12 程序中執行的純 Python 程式碼實現真正的並行性。
- 如果這些概念對您來說很陌生,請不要擔心,我們將在上下文部分中介紹所有內容。您可以在看完介紹後重新閱讀。
- 如果您只是想嘗試一下 Python 中真正的並行性,請使用此連結。
讓我們首先簡要介紹一些在我們的討論中會派上用場的主題。請根據您的知識水平隨意跳過某些部分。
併發與並行
- 並行性:當任務實際上同時執行時(例如,為影片遊戲建立要顯示的下一幀使用多個 GPU 和 CPU 核心同時處理數學問題)
- 併發:一個或多個任務在重疊的時間段內執行,但不一定在同一時刻執行。這對於等待 I/O 的任務來說是理想的選擇,其中一次可能只有一個不同的任務在處理器上執行。 (例如,在等待網路呼叫時,網路瀏覽器可以繼續讀取使用者輸入)
作業系統中的併發和並行
讓我們介紹一下現代作業系統中的基本並行/併發原語:-
- 程序:程序是程式的執行,包括所有程式程式碼、資料成員、資源等。不同的程序是獨立的,記憶體、資源等通常不共享。
- 執行緒:程序中可以安排執行的實體。程序的不同執行緒通常在它們之間共享記憶體,但是它們可以具有不同的執行堆疊。
預設情況下,在大多數現代作業系統中,程序的不同執行緒可以在不同的處理器上執行(即實際上同時執行)。由於多個執行緒可以同時執行,因此在多個執行緒之間共享物件時我們需要更加小心。我們需要使用“鎖”確保只有一個執行緒可以在某一時刻寫入共享物件,以防止記憶體損壞。在這裡,每當一個程序/執行緒嘗試獲取另一個執行緒/程序持有的鎖時,它必須等待另一個執行緒/程序放棄鎖。這稱為鎖爭用,它是大多數並行系統中的瓶頸。
值得注意的另一點是,並非所有執行緒/程序都可以在任何給定時間始終在處理器上執行。因此,對於執行緒和程序,作業系統通常會編排在給定時間在可用處理器上執行哪些任務。在這裡,作業系統必須確保每個執行緒都獲得一些 CPU 時間,以確保它們不會“飢餓”,即陷入等待狀態。因此,處理器必須不斷地從一個執行緒/程序切換到另一個執行緒/程序,從而導致“上下文切換”開銷。我們的目標應該是儘可能避免這種“上下文切換”開銷。此外,在本文中,任何等待使用者/網路 I/O 的任務都稱為“I/O 密集型任務”,而任何需要 CPU(例如數學運算)進一步處理的任務則稱為“CPU- 密集型任務”。繫結任務”。
Python 和多工處理
在這裡,就本部落格而言,Python=CPython,因為它是使用最廣泛的 Python 發行版。在Python中,我們可以透過使用執行緒和協程來實現併發(超出了本文的範圍)。然而,儘管Python內部使用簡單的作業系統執行緒進行多執行緒處理,但Python程式碼實際上無法並行執行。
為什麼Python程式碼不能並行執行?
Python 語言開發人員決定不允許在 CPython 中執行 Python 程式碼的並行性,以使其實現更簡單(記憶體分配、垃圾收集、引用計數等都大大簡化)。這也使得單執行緒效能更快,因為 CPython 不必擔心同步各個執行緒。
Python 如何防止並行性?
Python 透過引入 GIL(全域性直譯器鎖)的概念來防止並行性。在 Python 直譯器中執行任何程式碼之前需要獲取此鎖。由於每一行Python程式碼都在Python直譯器中執行(Python是一種解釋性語言),因此,在大多數情況下,這本質上禁止Python執行緒的並行執行,因為在給定時間只有一個執行緒可以獲取GIL。
如果一次只有一個執行緒可以有GIL,那麼,Python執行緒是如何併發的呢?
Python 在內部實現了類似於作業系統的“上下文切換”範例,一旦發生以下任一事件,它就會釋放全域性直譯器鎖(GIL)並允許其他執行緒執行其操作:-
- I/O 請求:當執行 Python 程式碼的執行緒發出 I/O 請求時,它會在內部釋放 GIL,並僅在 I/O 操作完成後嘗試重新獲取它。
- 受 CPU 限制的 Python 執行緒的 100 個“週期”:當執行 Python 程式碼的執行緒完成大約 100 個 Python 直譯器指令時,它會嘗試釋放 GIL 以確保其他執行緒不會“飢餓”。
因此,多個 I/O 請求可以在 Python 中並行執行。但是,對於在 Python 中執行的 CPU 密集型任務,使用多執行緒並沒有提供任何優勢。
Python 與並行性
在 Python 中實現並行性的最簡單方法是使用multiprocessing模組,它會生成多個單獨的 Python 程序,並與父程序進行某種程序間通訊。由於生成程序會產生一些開銷(並且不是很有趣),因此,出於本文的目的,我們將討論限制為使用單個 Python 程序可以實現的目標。
執行 Python 程式碼的程序仍然可以包含非 Python 程式碼片段,這些程式碼片段可以並行執行,而不受 GIL 的限制。然而,受 CPU 限制的 Python 程式碼需要 Python 直譯器,並且必須等待 GIL 執行。
因此,像 Numpy 這樣的庫在內部使用 C/C++ 進行計算,從而消除了直譯器開銷。這還可以透過在執行非 Python 程式碼時放棄 GIL 並允許真正的並行執行緒來實現並行性。請參閱:Superfastpython.com 的 Numpy 多執行緒並行性。
使用 C/C++ 擴充套件執行真正並行的 CPU 密集型程式碼
我們可以按照以下文件編寫 C/C++ 中的 Python 模組:擴充套件和嵌入 Python 直譯器。
在這裡,我們可以在內部放棄 GIL 作為我們手寫的 C/C++ 函式的一部分,從而允許 C++ 程式碼從多個執行緒並行執行,而沒有任何 GIL 限制。
例子:
PyObject* py_function(PyObject* self, PyObject* args) { |
上述模式通常由 Python 的 C/C++ 擴充套件使用,允許多個執行緒並行執行。此外,這些 C/C++ 函式還可以在內部生成多個執行緒,以實現更好的效能。請注意,由於 C++ 程式碼作為構建 C++ 擴充套件的一部分被編譯為機器程式碼,因此即使不將並行性納入等式中,C++ 擴充套件也比在 Python 直譯器中執行此程式碼提供更快的效能。
然而,這種方法只適合並行執行非Python程式碼(例如C/C++)。
我使用 C++ 擴充套件編寫了一個基本的 Python 函式,該函式在透過 C++ 函式放棄 GIL 後執行一項昂貴的數學任務,如下所示:
https://github.com/RishiRaj22/PythonParallelism/blob/main/pure_cpp_parallelism.cpp
使用子直譯器執行真正並行的 CPU 密集型程式碼
在 Python 3.12 中,Python 內部經歷了重大的正規化轉變。新增了對單個 CPython 程序中多個 GIL 的支援,而不是每個程序有一個 GIL。這允許在單個 CPython 程序中執行的多個 Python 程式碼執行緒同時執行。為此,我們可以建立多個 Python 子直譯器,每個子直譯器都有自己的 GIL。請參閱PEP 0684。
這裡有幾點需要注意:-
- 單個程序中的多個 Python 直譯器作為一個概念,早在 Python 3.12 之前就已經存在。然而,在單個程序中建立的每個直譯器共享一個 GIL。因此,在 Python 3.12 之前的 CPython 程序中,在任何給定時刻都只有一個子直譯器執行。因此,多個直譯器的概念主要用於實現“隔離”(超出了本文的範圍),而不被視為提高效能的方法。
- 目前,從 CPython 3.12 開始,無法透過 Python 程式碼直接與這些 API 互動。
為什麼需要子直譯器sub-interpreter?
- 比生成程序便宜,比執行緒昂貴。
- 在評估 Python 程式碼時無需獲取 GIL,因此不會出現與獲取 GIL 相關的鎖爭用。
- 預計將由 Python 3.13 進行標準化,並在多個直譯器之間建立適當的通訊通道。有關更多詳細資訊,請參閱PEP 0554 。
使用子直譯器實現並行性
為了玩轉子直譯器,我建立了一個模組 subinterpreter_parallelism,透過使用子直譯器實現任意 Python 程式碼的並行。由於在 Python 3.12 中,子直譯器並不是 stdlib 的一部分,所以這個模組是用 C++ 擴充套件模組實現的,如下所示:
L150>https://github.com/RishiRaj22/PythonParallelism/blob/main/subinterpreter_parallelism.cppL150<a>
from subinterpreter_parallelism import parallel |
透過這種設定,我幾乎充分利用了 CPU 的所有處理核心(即在 nproc=20 和 20 個 Python 函式並行執行的系統上,CPU 使用率為 1995%),體現了真正的並行性。此外,使用這種設定的效能比使用多處理模組更快。
子直譯器並不都是玫瑰和陽光
- 在當前狀態下,它可能無法與其他各種常用的 Python 庫(如 Numpy)很好地協同工作,或者根本無法協同工作。這是因為,預設情況下,所有 C/C++ 擴充套件模組在初始化時都不支援多直譯器。截至 2024 年 4 月,所有使用 Cythonize 建立的模組(如 Numpy)都是如此。這是因為 C 擴充套件庫經常與底層 API(如 PyGIL_*)互動,而眾所周知,底層 API 不支援多子直譯器。請參閱 Python 文件中的注意事項部分。希望隨著這種正規化被更多人採用,會有更多庫增加對它的支援。
- 由於 Python 是一種解釋型語言,與之相關的開銷較少,因此對於 CPU 約束較高的任務,純 C/C++ 程式碼的效能應該會好得多。
- 在我的 hacked together 模組中,直譯器之間的共享很少,因此像日誌配置、匯入等都需要在並行執行的函式中明確提供。
歸根結底,這只是一個用來測試的實驗專案。
效能資料
多程序(每個 Python 任務 1 個程序) 15.07s |
結論
子直譯器似乎是一種很有前途的並行 Python 程式碼機制,它具有顯著的優勢(在上述簡單的基準測試中,效能比多處理提高了 20%),等等。
雖然子直譯器對於純 Python 程式碼的並行化很有幫助,但它與 Numpy 等基於 C/C++ 擴充套件的庫並不相容。詳情請參考上文提到的第一點。我們還不清楚這種程式設計模式是否會在 Python 生態系統中得到廣泛支援。
如果效能是一個大問題,那麼依靠 C/C++ 擴充套件函式似乎更合適,因為編譯後的程式碼可以提高速度。
隨著 Python 3.13 的釋出,子直譯器將變得更有吸引力,這裡編寫的程式碼也將變得多餘,因為直譯器將成為 stdlib 本身的一部分。不過,看看我們如何在 Python 3.12 中實現類似的結果仍然令人著迷。
如果您對學習並行的子直譯器更感興趣,可以閱讀 Anthony Shaw 的部落格:Anthony Shaw’s blog.
討論的整個原始碼以及基準測試程式碼以及構建和使用程式碼的說明可以在github.com/RishiRaj22/PythonParallelism中找到。