Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

支援自由執行緒的 CPython

CPython 3.14 宣告支援不依賴全域直譯器鎖(通常稱為 GIL)來提供執行緒安全的「自由執行緒」建置。自 0.23 版起,PyO3 支援為自由執行緒的 Python 建置 Rust 擴充,並可從 Rust 呼叫自由執行緒的 Python。

若想了解自由執行緒 Python 的更多背景,可參考 3.13 版更新說明中What’s New 的條目(首次以實驗模式加入「自由執行緒」建置)、CPython 文件中的自由執行緒 HOWTO 指南、社群維護的自由執行緒指南中的擴充移植指南,以及提供 CPython 自由執行緒實作技術背景的 PEP 703

在啟用 GIL 的建置中(在「自由執行緒」建置出現之前的唯一選擇),全域直譯器鎖會序列化對 Python 執行環境的存取。因此,依據阿姆達爾定律,GIL 是多執行緒 Python 工作流程平行擴展的根本限制,因為任何只在單一執行內容中進行的平行處理,都不可能藉由平行化而加速。

自由執行緒建置移除了多執行緒 Python 擴展上的限制,意味著使用 Python 的 threading 模組更容易達成平行化。若你曾為了某些 Python 程式碼的平行加速而使用 multiprocessing,自由執行緒很可能讓相同工作流程改以 Python 執行緒達成。

PyO3 對自由執行緒 Python 的支援,將能撰寫天生就具備執行緒安全的原生 Python 擴充,並提供比 C 擴充更強的安全保證。我們的目標是以 Rust 的 SendSync 特徵為基礎,在原生 Python 執行環境中實現無畏並行

本文提供將使用 PyO3 的 Rust 程式碼移植至自由執行緒 Python 的建議。

使用 PyO3 支援自由執行緒 Python

自 PyO3 0.28 起,PyO3 預設假設以它建立的 Python 模組是執行緒安全的。除非某些 Rust 程式碼使用 unsafe 錯誤地假設執行緒安全,否則應該成立。典型例子是曾基於歷史假設(Python 因 GIL 而是單執行緒)所撰寫的 unsafe 程式碼,並假定 PyO3 使用的 Python<'py> token 可保證執行緒安全。模組可在完成 unsafe 程式碼正確性稽核前,透過 #[pymodule(gil_used = true)] 宣告選擇不支援自由執行緒 Python(見下文)。

較複雜的 #[pyclass] 型別可能需要直接處理執行緒安全;指南中有專門章節討論此議題。

在較低層級,為模組加註會將擴充所定義模組的 Py_MOD_GIL slot 設為 Py_MOD_GIL_NOT_USED,讓直譯器在執行期得知擴充作者認為該擴充具備執行緒安全性。

若選擇不支援自由執行緒 Python,Python 直譯器會在匯入你的模組時於執行期重新啟用 GIL,並輸出含有觸發此行為之模組名稱的 RuntimeWarning。你可以設定環境變數 PYTHON_GIL=0 或在啟動 Python 時傳入 -Xgil=0 強制 GIL 維持關閉(0 代表關閉 GIL)。

如果你確定 PyModule 中暴露的所有資料結構都是執行緒安全的,請在宣告模組的 pymodule 程序巨集中傳入 gil_used = false,或在 PyModule 實例上呼叫 PyModule::gil_used。例如:

選擇支援範例

(注意:在 PyO3 0.23 至 0.27 版中,預設為 gil_used = true,因此必須反向設定;模組需要以 gil_used = false 選擇支援自由執行緒 Python。)

/// 此模組依賴 GIL 以確保執行緒安全
#[pyo3::pymodule(gil_used = true)]
mod my_extension {
    use pyo3::prelude::*;

    // 此型別不是執行緒安全
    #[pyclass]
    struct MyNotThreadSafeType {
        // 插入非執行緒安全的程式碼
    }
}

或是針對未使用 pymodule 巨集設定的模組:

use pyo3::prelude::*;

#[allow(dead_code)]
fn register_child_module(parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
    let child_module = PyModule::new(parent_module.py(), "child_module")?;
    child_module.gil_used(true)?;
    parent_module.add_submodule(&child_module)
}

關於如何使用原始 FFI 呼叫為採用單階段初始化的模組宣告自由執行緒支援,請參考 string-sum 範例;採用多階段初始化的模組則可參考 sequential 範例。

若你想透過條件編譯在自由執行緒建置下觸發不同的程式路徑,可在設定軟體箱以產生必要的建置設定資料後,使用 Py_GIL_DISABLED 屬性。關於支援多個 Python 版本(含自由執行緒建置)的詳細內容,請參考指南章節

自由執行緒建置的特別注意事項

自由執行緒直譯器沒有 GIL。許多既有擴充提供可變資料結構時仰賴 GIL 來鎖住 Python 物件,使內部可變性具備執行緒安全。

只有在作業系統執行緒明確附加到直譯器執行環境時,呼叫 CPython C API 才是合法的。在啟用 GIL 的建置中,這會在取得 GIL 時發生。自由執行緒建置中沒有 GIL,但在啟用 GIL 的建置裡負責釋放或取得 GIL 的同一組 C 巨集,會改為要求直譯器將該執行緒附加到 Python 執行環境,且可能同時附加多個執行緒。更多關於執行緒如何附加/分離直譯器執行環境(類似於在啟用 GIL 建置中釋放或取得 GIL)的背景,請參考 PEP 703

在啟用 GIL 的建置中,PyO3 使用 Python<'py> 型別與 'py 生命週期來表示已持有全域直譯器鎖。在自由執行緒建置中,持有 'py 生命週期僅代表該執行緒目前已附加到 Python 直譯器——其他執行緒仍可同時與直譯器互動。

附加到執行環境

你仍需要取得 'py 生命週期才能與 Python 物件互動或呼叫 CPython C API。若尚未附加到 Python 執行環境,可使用 Python::attach 註冊執行緒。由 Python 的 threading 模組建立的執行緒不需要這麼做,當 CPython 呼叫你的擴充時,pyo3 會負責設定 Python<'py> token。

分離以避免卡住與死結

自由執行緒建置在以下情況會觸發全域同步事件:

  • 垃圾回收時,為取得參考計數與物件間參考的一致全域視圖
  • 在 Python 3.13 中,啟動第一個背景執行緒時用以將部分物件標記為不朽
  • 呼叫 sys.settracesys.setprofile 以對執行中的程式碼物件與執行緒進行追蹤時
  • 呼叫 os.fork() 時,為確保整個行程的一致狀態

以上並非完整清單,未來的 Python 版本可能還會有其他觸發全域同步事件的情況。

這表示你應在與啟用 GIL 建置相同的情境下,使用 Python::detach 從直譯器執行環境分離:當進行不需要 CPython 執行環境的長時間工作,或進行任何需要重新附加執行環境的工作時(請見指南章節)。在前者情況,等待長時間工作完成的執行緒會卡住;在後者情況,當執行環境觸發全域同步事件後,執行緒嘗試附加時會發生死結,因為啟動該執行緒的執行緒阻止同步事件完成。

多執行緒存取可變 pyclass 實例時的例外與恐慌

附加在 pyclass 實例上的資料會透過類似 RefCell 的執行期借用檢查來防止並發存取。如同 RefCell,PyO3 會拋出例外(或在某些情況下 panic)以強制對可變借用的獨占存取。過去在 PyO3 中就可能產生這類 panic,例如在使用 Python::detach 釋放 GIL 的程式碼裡,或從 &mut self 呼叫接收 &self 的 Python 方法(見內部可變性文件),但在自由執行緒 Python 中,因為沒有 GIL 來鎖住來自 Python 的可變借用資料的並發存取,觸發這些 panic 的機會更多。

最直接觸發此問題的方式,是使用 Python 的 threading 模組,在多個執行緒中同時呼叫會可變借用 pyclass 的 Rust 函式。例如,考慮以下實作:

use pyo3::prelude::*;
#[pyclass]
#[derive(Default)]
struct ThreadIter {
    count: usize,
}

#[pymethods]
impl ThreadIter {
    #[new]
    pub fn new() -> Self {
        Default::default()
    }

    fn __next__(&mut self, py: Python<'_>) -> usize {
        self.count += 1;
        self.count
    }
}

接著如果在 Python 中這樣做:

import concurrent.futures
from my_module import ThreadIter

i = ThreadIter()

def increment():
    next(i)

with concurrent.futures.ThreadPoolExecutor(max_workers=16) as tpe:
    futures = [tpe.submit(increment) for _ in range(100)]
    [f.result() for f in futures]

會看到以下例外:

Traceback (most recent call last)
  File "example.py", line 5, in <module>
    next(i)
RuntimeError: Already borrowed

未來版本的 PyO3 可能會允許為可變 pyclass 定義提供可由使用者選擇的語意,必要時可選擇性啟用鎖定以模擬 GIL。目前你應明確加入鎖定,可能透過條件編譯或臨界區 API,以避免與 GIL 造成死結。

無法使用 limited API 建置擴充

自由執行緒建置使用全新的 ABI,目前尚無與自由執行緒 ABI 對應的 limited API。這表示若你的軟體箱依賴 PyO3 並使用 abi3 功能或 abi3-pyxx 功能,PyO3 在使用自由執行緒直譯器建置擴充時會輸出警告並忽略該設定。

這表示若你的軟體包利用 limited API 提供的 ABI 向前相容性,只在每次發行時上傳一個 wheel,你就需要更新發佈流程,另外上傳針對自由執行緒版本的特定 wheel。

更多關於支援多個 Python 版本(含自由執行緒建置)的細節,請參考指南章節

執行緒安全的單次初始化

若要只初始化一次資料,請使用 PyOnceLock 型別,它與 std::sync::OnceLock 類似,且在執行緒等待其他執行緒完成初始化時會從 Python 直譯器分離,以避免死結。若已使用 OnceLock,且不便改用 PyOnceLock,可使用 OnceLockExt 擴充特徵,其新增 OnceLockExt::get_or_init_py_attached 方法,在阻塞時以與 PyOnceLock 相同方式從直譯器分離。以下為使用 PyOnceLock 單次初始化持有 Py<PyDict> 之執行期快取的範例:

use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::PyDict;

let cache: PyOnceLock<Py<PyDict>> = PyOnceLock::new();

Python::attach(|py| {
    // 保證只會被呼叫一次
    cache.get_or_init(py, || PyDict::new(py).unbind())
});

若某函式必須恰好執行一次,可將 OnceExt 特徵引入範圍。OnceExt 特徵為 std::sync::Once 的 API 新增 OnceExt::call_once_py_attachedOnceExt::call_once_force_py_attached 函式,使 Once 能在執行緒已附加到 Python 直譯器的情境中使用。這些函式類似於 Once::call_onceOnce::call_once_force,差別在於它們除了 FnOnce 外還接受 Python<'py> token。這些函式會在阻塞前先從直譯器分離,並在執行函式前重新附加,以避免在未使用 PyO3 擴充特徵時可能發生的死結。以下是使用 Once 而非 PyOnceLock 的相同範例:

use pyo3::prelude::*;
use std::sync::Once;
use pyo3::sync::OnceExt;
use pyo3::types::PyDict;

struct RuntimeCache {
    once: Once,
    cache: Option<Py<PyDict>>
}

let mut cache = RuntimeCache {
    once: Once::new(),
    cache: None
};

Python::attach(|py| {
    // 保證只會被呼叫一次
    cache.once.call_once_py_attached(py, || {
        cache.cache = Some(PyDict::new(py).unbind());
    });
});

GILProtected 已被移除

GILProtected 是 PyO3 的一種型別,透過 GIL 鎖住其他執行緒的並發存取,允許對靜態資料進行可變存取。在自由執行緒 Python 中沒有 GIL,因此此型別必須改以其他鎖定方式取代。多數情況下,使用 std::sync::atomic 的型別或 std::sync::Mutex 就足夠了。

之前:

fn main() {
#[cfg(not(Py_GIL_DISABLED))] {
use pyo3::prelude::*;
use pyo3::sync::GILProtected;
use pyo3::types::{PyDict, PyNone};
use std::cell::RefCell;

static OBJECTS: GILProtected<RefCell<Vec<Py<PyDict>>>> =
    GILProtected::new(RefCell::new(Vec::new()));

Python::attach(|py| {
    // 代表執行任意 Python 程式碼的範例
    let d = PyDict::new(py);
    d.set_item(PyNone::get(py), PyNone::get(py)).unwrap();
    OBJECTS.get(py).borrow_mut().push(d.unbind());
});
}}

之後(使用 Mutex):

use pyo3::prelude::*;
fn main() {
use pyo3::types::{PyDict, PyNone};
use std::sync::Mutex;

static OBJECTS: Mutex<Vec<Py<PyDict>>> = Mutex::new(Vec::new());

Python::attach(|py| {
    // 代表執行任意 Python 程式碼的範例
    let d = PyDict::new(py);
    d.set_item(PyNone::get(py), PyNone::get(py)).unwrap();
    // 與任何 `Mutex` 用法相同,鎖住 mutex 的時間應盡可能短
    // 在此例中,只在把元素推入 `Vec` 時上鎖
    OBJECTS.lock().unwrap().push(d.unbind());
});
}

若在持有鎖的情況下執行任意 Python 程式碼,應引入 MutexExt 特徵並使用 lock_py_attached 方法取代 lock。這能確保由 Python 執行環境啟動的全域同步事件可以繼續,避免與直譯器產生死結。