在CPython中實現純Python函式的真正並行性

banq發表於2024-04-23


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) {
    auto c_args = /** Code to convert PyObject* args to C++ args */<font><i>;

    
// Release GIL<i>
    Py_BEGIN_ALLOW_THREADS

    
// Run pure C++ code<i>
    auto c_ret = c_function(c_args);

    
// Re-acquire GIL<i>
    Py_END_ALLOW_THREADS

    return
/** Code to convert c_ret to PyObject* */<i>;
}

上述模式通常由 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

這裡有幾點需要注意:-

  1. 單個程序中的多個 Python 直譯器作為一個概念,早在 Python 3.12 之前就已經存在。然而,在單個程序中建立的每個直譯器共享一個 GIL。因此,在 Python 3.12 之前的 CPython 程序中,在任何給定時刻都只有一個子直譯器執行。因此,多個直譯器的概念主要用於實現“隔離”(超出了本文的範圍),而不被視為提高效能的方法。
  2. 目前,從 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

# Run 3 threads of pure python functions in parallel using sub-interpreters.
result = parallel(['module1', 'func1', (arg11, arg12, arg13),)],
                  ['module2', 'func2', (arg21, arg22)],
                  ['module3', 'func3', tuple()])

透過這種設定,我幾乎充分利用了 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 任務 1 個執行緒) 11.48s
多執行緒處理 C++ 擴充套件,放棄 GIL(每個 C++ 任務 1 個執行緒) 0.74s

結論
子直譯器似乎是一種很有前途的並行 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中找到。
 

相關文章