diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcc4dd8bf0b..eba8676f01d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -553,8 +553,6 @@ jobs: test-free-threaded: needs: [fmt] runs-on: ubuntu-latest - env: - UNSAFE_PYO3_BUILD_FREE_THREADED: 1 steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 diff --git a/guide/src/free-threading.md b/guide/src/free-threading.md index 77b2ff327a2..fe364077cfb 100644 --- a/guide/src/free-threading.md +++ b/guide/src/free-threading.md @@ -35,11 +35,83 @@ concurrency"](https://doc.rust-lang.org/book/ch16-00-concurrency.html) in the native Python runtime by building on the Rust `Send` and `Sync` traits. This document provides advice for porting Rust code using PyO3 to run under -free-threaded Python. While many simple PyO3 uses, like defining an immutable -Python class, will likely work "out of the box", there are currently some -limitations. +free-threaded Python. + +## Supporting free-threaded Python with PyO3 + +Many simple uses of PyO3, like exposing bindings for a "pure" Rust function +with no side-effects or defining an immutable Python class, will likely work +"out of the box" on the free-threaded build. All that will be necessary is to +annotate Python modules declared by rust code in your project to declare that +they support free-threaded Python, for example by declaring the module with +`#[pymodule(gil_used = false)]`. + +At a low-level, annotating a module sets the `Py_MOD_GIL` slot on modules +defined by an extension to `Py_MOD_GIL_NOT_USED`, which allows the interpreter +to see at runtime that the author of the extension thinks the extension is +thread-safe. You should only do this if you know that your extension is +thread-safe. Because of Rust's guarantees, this is already true for many +extensions, however see below for more discussion about how to evaluate the +thread safety of existing Rust extensions and how to think about the PyO3 API +using a Python runtime with no GIL. + +If you do not explicitly mark that modules are thread-safe, the Python +interpreter will re-enable the GIL at runtime and print a `RuntimeWarning` +explaining which module caused it to re-enable the GIL. You can also force the +GIL to remain disabled by setting the `PYTHON_GIL=0` as an environment variable +or passing `-Xgil=0` when starting Python (`0` means the GIL is turned off). + +If you are sure that all data structures exposed in a `PyModule` are +thread-safe, then pass `gil_used = false` as a parameter to the +`pymodule` procedural macro declaring the module or call +`PyModule::gil_used` on a `PyModule` instance. For example: -## Many symbols exposed by PyO3 have `GIL` in the name +```rust +use pyo3::prelude::*; + +/// This module supports free-threaded Python +#[pymodule(gil_used = false)] +fn my_extension(m: &Bound<'_, PyModule>) -> PyResult<()> { + // add members to the module that you know are thread-safe + Ok(()) +} +``` + +Or for a module that is set up without using the `pymodule` macro: + +```rust +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(false)?; + parent_module.add_submodule(&child_module) +} + +``` + +See the +[`string-sum`](https://github.com/PyO3/pyo3/tree/main/examples/string-sum) +example for how to declare free-threaded support using raw FFI calls for modules +using single-phase initialization and the +[`sequential`](https://github.com/PyO3/pyo3/tree/main/examples/sequential) +example for modules using multi-phase initialization. + +## Special considerations for the free-threaded build + +The free-threaded interpreter does not have a GIL, and this can make interacting +with the PyO3 API confusing, since the API was originally designed around strong +assumptions about the GIL providing locking. Additionally, since the GIL +provided locking for operations on Python objects, many existing extensions that +provide mutable data structures relied on the GIL to make interior mutability +thread-safe. + +Working with PyO3 under the free-threaded interpreter therefore requires some +additional care and mental overhead compared with a GIL-enabled interpreter. We +discuss how to handle this below. + +### Many symbols exposed by PyO3 have `GIL` in the name We are aware that there are some naming issues in the PyO3 API that make it awkward to think about a runtime environment where there is no GIL. We plan to @@ -83,7 +155,7 @@ garbage collector can only run if all threads are detached from the runtime (in a stop-the-world state), so detaching from the runtime allows freeing unused memory. -## Exceptions and panics for multithreaded access of mutable `pyclass` instances +### Exceptions and panics for multithreaded access of mutable `pyclass` instances Data attached to `pyclass` instances is protected from concurrent access by a `RefCell`-like pattern of runtime borrow checking. Like a `RefCell`, PyO3 will @@ -99,7 +171,7 @@ The most straightforward way to trigger this problem to use the Python `threading` module to simultaneously call a rust function that mutably borrows a `pyclass`. For example, consider the following `PyClass` implementation: -``` +```rust # use pyo3::prelude::*; # fn main() { #[pyclass] @@ -152,7 +224,7 @@ We plan to allow user-selectable semantics for mutable pyclass definitions in PyO3 0.24, allowing some form of opt-in locking to emulate the GIL if that is needed. -## `GILProtected` is not exposed +### `GILProtected` is not exposed `GILProtected` is a PyO3 type that allows mutable access to static data by leveraging the GIL to lock concurrent access from other threads. In diff --git a/newsfragments/4588.added.md b/newsfragments/4588.added.md new file mode 100644 index 00000000000..42b5b8e219a --- /dev/null +++ b/newsfragments/4588.added.md @@ -0,0 +1,3 @@ +* It is now possible to declare that a module supports the free-threaded build + by either calling `PyModule::gil_used` or passing + `gil_used = false` as a parameter to the `pymodule` proc macro. diff --git a/noxfile.py b/noxfile.py index 32176240f59..25cb8d1eb92 100644 --- a/noxfile.py +++ b/noxfile.py @@ -676,14 +676,6 @@ def test_version_limits(session: nox.Session): config_file.set("PyPy", "3.11") _run_cargo(session, "check", env=env, expect_error=True) - # Python build with GIL disabled should fail building - config_file.set("CPython", "3.13", build_flags=["Py_GIL_DISABLED"]) - _run_cargo(session, "check", env=env, expect_error=True) - - # Python build with GIL disabled should pass with env flag on - env["UNSAFE_PYO3_BUILD_FREE_THREADED"] = "1" - _run_cargo(session, "check", env=env) - @nox.session(name="check-feature-powerset", venv_backend="none") def check_feature_powerset(session: nox.Session): diff --git a/pyo3-ffi/build.rs b/pyo3-ffi/build.rs index 622c2707110..931838b5e5d 100644 --- a/pyo3-ffi/build.rs +++ b/pyo3-ffi/build.rs @@ -6,7 +6,6 @@ use pyo3_build_config::{ }, warn, BuildFlag, PythonImplementation, }; -use std::ops::Not; /// Minimum Python version PyO3 supports. struct SupportedVersions { @@ -107,7 +106,17 @@ fn ensure_python_version(interpreter_config: &InterpreterConfig) -> Result<()> { if interpreter_config.abi3 { match interpreter_config.implementation { - PythonImplementation::CPython => {} + PythonImplementation::CPython => { + if interpreter_config + .build_flags + .0 + .contains(&BuildFlag::Py_GIL_DISABLED) + { + warn!( + "The free-threaded build of CPython does not yet support abi3 so the build artifacts will be version-specific." + ) + } + } PythonImplementation::PyPy => warn!( "PyPy does not yet support abi3 so the build artifacts will be version-specific. \ See https://github.com/pypy/pypy/issues/3397 for more information." @@ -121,29 +130,6 @@ fn ensure_python_version(interpreter_config: &InterpreterConfig) -> Result<()> { Ok(()) } -fn ensure_gil_enabled(interpreter_config: &InterpreterConfig) -> Result<()> { - let gil_enabled = interpreter_config - .build_flags - .0 - .contains(&BuildFlag::Py_GIL_DISABLED) - .not(); - ensure!( - gil_enabled || std::env::var("UNSAFE_PYO3_BUILD_FREE_THREADED").map_or(false, |os_str| os_str == "1"), - "the Python interpreter was built with the GIL disabled, which is not yet supported by PyO3\n\ - = help: see https://github.com/PyO3/pyo3/issues/4265 for more information\n\ - = help: please check if an updated version of PyO3 is available. Current version: {}\n\ - = help: set UNSAFE_PYO3_BUILD_FREE_THREADED=1 to suppress this check and build anyway for free-threaded Python", - std::env::var("CARGO_PKG_VERSION").unwrap() - ); - if !gil_enabled && interpreter_config.abi3 { - warn!( - "The free-threaded build of CPython does not yet support abi3 so the build artifacts will be version-specific." - ) - } - - Ok(()) -} - fn ensure_target_pointer_width(interpreter_config: &InterpreterConfig) -> Result<()> { if let Some(pointer_width) = interpreter_config.pointer_width { // Try to check whether the target architecture matches the python library @@ -209,7 +195,6 @@ fn configure_pyo3() -> Result<()> { ensure_python_version(&interpreter_config)?; ensure_target_pointer_width(&interpreter_config)?; - ensure_gil_enabled(&interpreter_config)?; // Serialize the whole interpreter config into DEP_PYTHON_PYO3_CONFIG env var. interpreter_config.to_cargo_dep_env()?; diff --git a/pyo3-ffi/examples/sequential/Cargo.toml b/pyo3-ffi/examples/sequential/Cargo.toml index 3348595b4e9..6e32d1d2952 100644 --- a/pyo3-ffi/examples/sequential/Cargo.toml +++ b/pyo3-ffi/examples/sequential/Cargo.toml @@ -10,4 +10,7 @@ crate-type = ["cdylib", "lib"] [dependencies] pyo3-ffi = { path = "../../", features = ["extension-module"] } +[build-dependencies] +pyo3-build-config = { path = "../../pyo3-build-config" } + [workspace] diff --git a/pyo3-ffi/examples/sequential/build.rs b/pyo3-ffi/examples/sequential/build.rs new file mode 100644 index 00000000000..0475124bb4e --- /dev/null +++ b/pyo3-ffi/examples/sequential/build.rs @@ -0,0 +1,3 @@ +fn main() { + pyo3_build_config::use_pyo3_cfgs(); +} diff --git a/pyo3-ffi/examples/sequential/src/module.rs b/pyo3-ffi/examples/sequential/src/module.rs index 5e71f07a865..baa7c66f206 100644 --- a/pyo3-ffi/examples/sequential/src/module.rs +++ b/pyo3-ffi/examples/sequential/src/module.rs @@ -23,6 +23,11 @@ static mut SEQUENTIAL_SLOTS: &[PyModuleDef_Slot] = &[ slot: Py_mod_multiple_interpreters, value: Py_MOD_PER_INTERPRETER_GIL_SUPPORTED, }, + #[cfg(Py_GIL_DISABLED)] + PyModuleDef_Slot { + slot: Py_mod_gil, + value: Py_MOD_GIL_NOT_USED, + }, PyModuleDef_Slot { slot: 0, value: ptr::null_mut(), diff --git a/pyo3-ffi/examples/string-sum/Cargo.toml b/pyo3-ffi/examples/string-sum/Cargo.toml index 6fb72141cdc..b9a17239334 100644 --- a/pyo3-ffi/examples/string-sum/Cargo.toml +++ b/pyo3-ffi/examples/string-sum/Cargo.toml @@ -10,4 +10,7 @@ crate-type = ["cdylib"] [dependencies] pyo3-ffi = { path = "../../", features = ["extension-module"] } +[build-dependencies] +pyo3-build-config = { path = "../../pyo3-build-config" } + [workspace] diff --git a/pyo3-ffi/examples/string-sum/build.rs b/pyo3-ffi/examples/string-sum/build.rs new file mode 100644 index 00000000000..0475124bb4e --- /dev/null +++ b/pyo3-ffi/examples/string-sum/build.rs @@ -0,0 +1,3 @@ +fn main() { + pyo3_build_config::use_pyo3_cfgs(); +} diff --git a/pyo3-ffi/examples/string-sum/src/lib.rs b/pyo3-ffi/examples/string-sum/src/lib.rs index 9f0d6c6435f..c5979835de5 100644 --- a/pyo3-ffi/examples/string-sum/src/lib.rs +++ b/pyo3-ffi/examples/string-sum/src/lib.rs @@ -32,7 +32,17 @@ static mut METHODS: &[PyMethodDef] = &[ #[allow(non_snake_case)] #[no_mangle] pub unsafe extern "C" fn PyInit_string_sum() -> *mut PyObject { - PyModule_Create(ptr::addr_of_mut!(MODULE_DEF)) + let module = PyModule_Create(ptr::addr_of_mut!(MODULE_DEF)); + if module.is_null() { + return module; + } + #[cfg(Py_GIL_DISABLED)] + { + if PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED) < 0 { + return std::ptr::null_mut(); + } + } + module } /// A helper to parse function arguments diff --git a/pyo3-ffi/src/moduleobject.rs b/pyo3-ffi/src/moduleobject.rs index ff6458f4b15..2417664a421 100644 --- a/pyo3-ffi/src/moduleobject.rs +++ b/pyo3-ffi/src/moduleobject.rs @@ -88,6 +88,10 @@ pub const Py_mod_create: c_int = 1; pub const Py_mod_exec: c_int = 2; #[cfg(Py_3_12)] pub const Py_mod_multiple_interpreters: c_int = 3; +#[cfg(Py_3_13)] +pub const Py_mod_gil: c_int = 4; + +// skipped private _Py_mod_LAST_SLOT #[cfg(Py_3_12)] pub const Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED: *mut c_void = 0 as *mut c_void; @@ -96,7 +100,15 @@ pub const Py_MOD_MULTIPLE_INTERPRETERS_SUPPORTED: *mut c_void = 1 as *mut c_void #[cfg(Py_3_12)] pub const Py_MOD_PER_INTERPRETER_GIL_SUPPORTED: *mut c_void = 2 as *mut c_void; -// skipped non-limited _Py_mod_LAST_SLOT +#[cfg(Py_3_13)] +pub const Py_MOD_GIL_USED: *mut c_void = 0 as *mut c_void; +#[cfg(Py_3_13)] +pub const Py_MOD_GIL_NOT_USED: *mut c_void = 1 as *mut c_void; + +#[cfg(all(not(Py_LIMITED_API), Py_GIL_DISABLED))] +extern "C" { + pub fn PyUnstable_Module_SetGIL(module: *mut PyObject, gil: *mut c_void) -> c_int; +} #[repr(C)] pub struct PyModuleDef { diff --git a/pyo3-macros-backend/src/attributes.rs b/pyo3-macros-backend/src/attributes.rs index 94526e7dafc..6fe75e44302 100644 --- a/pyo3-macros-backend/src/attributes.rs +++ b/pyo3-macros-backend/src/attributes.rs @@ -7,7 +7,7 @@ use syn::{ punctuated::Punctuated, spanned::Spanned, token::Comma, - Attribute, Expr, ExprPath, Ident, Index, LitStr, Member, Path, Result, Token, + Attribute, Expr, ExprPath, Ident, Index, LitBool, LitStr, Member, Path, Result, Token, }; pub mod kw { @@ -44,6 +44,7 @@ pub mod kw { syn::custom_keyword!(transparent); syn::custom_keyword!(unsendable); syn::custom_keyword!(weakref); + syn::custom_keyword!(gil_used); } fn take_int(read: &mut &str, tracker: &mut usize) -> String { @@ -308,6 +309,7 @@ pub type RenameAllAttribute = KeywordAttribute; pub type TextSignatureAttribute = KeywordAttribute; pub type SubmoduleAttribute = kw::submodule; +pub type GILUsedAttribute = KeywordAttribute; impl Parse for KeywordAttribute { fn parse(input: ParseStream<'_>) -> Result { diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 7d2c72dbdfb..62ff30613a0 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -2,8 +2,8 @@ use crate::{ attributes::{ - self, kw, take_attributes, take_pyo3_options, CrateAttribute, ModuleAttribute, - NameAttribute, SubmoduleAttribute, + self, kw, take_attributes, take_pyo3_options, CrateAttribute, GILUsedAttribute, + ModuleAttribute, NameAttribute, SubmoduleAttribute, }, get_doc, pyclass::PyClassPyO3Option, @@ -29,6 +29,7 @@ pub struct PyModuleOptions { name: Option, module: Option, submodule: Option, + gil_used: Option, } impl Parse for PyModuleOptions { @@ -72,6 +73,9 @@ impl PyModuleOptions { submodule, " (it is implicitly always specified for nested modules)" ), + PyModulePyO3Option::GILUsed(gil_used) => { + set_option!(gil_used) + } } } Ok(()) @@ -344,7 +348,13 @@ pub fn pymodule_module_impl( ) } }}; - let initialization = module_initialization(&name, ctx, module_def, options.submodule.is_some()); + let initialization = module_initialization( + &name, + ctx, + module_def, + options.submodule.is_some(), + options.gil_used.map(|op| op.value.value).unwrap_or(true), + ); Ok(quote!( #(#attrs)* @@ -383,7 +393,13 @@ pub fn pymodule_function_impl( let vis = &function.vis; let doc = get_doc(&function.attrs, None, ctx); - let initialization = module_initialization(&name, ctx, quote! { MakeDef::make_def() }, false); + let initialization = module_initialization( + &name, + ctx, + quote! { MakeDef::make_def() }, + false, + options.gil_used.map(|op| op.value.value).unwrap_or(true), + ); // Module function called with optional Python<'_> marker as first arg, followed by the module. let mut module_args = Vec::new(); @@ -428,6 +444,7 @@ fn module_initialization( ctx: &Ctx, module_def: TokenStream, is_submodule: bool, + gil_used: bool, ) -> TokenStream { let Ctx { pyo3_path, .. } = ctx; let pyinit_symbol = format!("PyInit_{}", name); @@ -449,7 +466,7 @@ fn module_initialization( #[doc(hidden)] #[export_name = #pyinit_symbol] pub unsafe extern "C" fn __pyo3_init() -> *mut #pyo3_path::ffi::PyObject { - #pyo3_path::impl_::trampoline::module_init(|py| _PYO3_DEF.make_module(py)) + #pyo3_path::impl_::trampoline::module_init(|py| _PYO3_DEF.make_module(py, #gil_used)) } }); } @@ -596,6 +613,7 @@ enum PyModulePyO3Option { Crate(CrateAttribute), Name(NameAttribute), Module(ModuleAttribute), + GILUsed(GILUsedAttribute), } impl Parse for PyModulePyO3Option { @@ -609,6 +627,8 @@ impl Parse for PyModulePyO3Option { input.parse().map(PyModulePyO3Option::Module) } else if lookahead.peek(attributes::kw::submodule) { input.parse().map(PyModulePyO3Option::Submodule) + } else if lookahead.peek(attributes::kw::gil_used) { + input.parse().map(PyModulePyO3Option::GILUsed) } else { Err(lookahead.error()) } diff --git a/pytests/conftest.py b/pytests/conftest.py new file mode 100644 index 00000000000..ce729689355 --- /dev/null +++ b/pytests/conftest.py @@ -0,0 +1,22 @@ +import sysconfig +import sys +import pytest + +FREE_THREADED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + +gil_enabled_at_start = True +if FREE_THREADED_BUILD: + gil_enabled_at_start = sys._is_gil_enabled() + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + if FREE_THREADED_BUILD and not gil_enabled_at_start and sys._is_gil_enabled(): + tr = terminalreporter + tr.ensure_newline() + tr.section("GIL re-enabled", sep="=", red=True, bold=True) + tr.line("The GIL was re-enabled at runtime during the tests.") + tr.line("") + tr.line("Please ensure all new modules declare support for running") + tr.line("without the GIL. Any new tests that intentionally imports ") + tr.line("code that re-enables the GIL should do so in a subprocess.") + pytest.exit("GIL re-enabled during tests", returncode=1) diff --git a/pytests/src/awaitable.rs b/pytests/src/awaitable.rs index 5e3b98e14ea..fb04c33ed05 100644 --- a/pytests/src/awaitable.rs +++ b/pytests/src/awaitable.rs @@ -78,7 +78,7 @@ impl FutureAwaitable { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn awaitable(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; diff --git a/pytests/src/buf_and_str.rs b/pytests/src/buf_and_str.rs index bbaad40f312..15230a5e153 100644 --- a/pytests/src/buf_and_str.rs +++ b/pytests/src/buf_and_str.rs @@ -47,7 +47,7 @@ fn return_memoryview(py: Python<'_>) -> PyResult> { PyMemoryView::from(&bytes) } -#[pymodule] +#[pymodule(gil_used = false)] pub fn buf_and_str(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(wrap_pyfunction!(return_memoryview, m)?)?; diff --git a/pytests/src/comparisons.rs b/pytests/src/comparisons.rs index 5c7f659c9b3..4ed79e42790 100644 --- a/pytests/src/comparisons.rs +++ b/pytests/src/comparisons.rs @@ -112,7 +112,7 @@ impl OrderedDefaultNe { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn comparisons(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; diff --git a/pytests/src/datetime.rs b/pytests/src/datetime.rs index 3bdf103b62e..5162b3508a5 100644 --- a/pytests/src/datetime.rs +++ b/pytests/src/datetime.rs @@ -203,7 +203,7 @@ impl TzClass { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn datetime(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(make_date, m)?)?; m.add_function(wrap_pyfunction!(get_date_tuple, m)?)?; diff --git a/pytests/src/enums.rs b/pytests/src/enums.rs index fb96c0a9366..8652321700a 100644 --- a/pytests/src/enums.rs +++ b/pytests/src/enums.rs @@ -4,7 +4,7 @@ use pyo3::{ wrap_pyfunction, Bound, PyResult, }; -#[pymodule] +#[pymodule(gil_used = false)] pub fn enums(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; diff --git a/pytests/src/free_threaded_mod.rs b/pytests/src/free_threaded_mod.rs new file mode 100644 index 00000000000..e0b44616749 --- /dev/null +++ b/pytests/src/free_threaded_mod.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +#[pyfunction] +fn add_two(x: usize) -> usize { + x + 2 +} + +#[pymodule(gil_used = false)] +pub fn free_threaded_mod(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(add_two, m)?)?; + Ok(()) +} diff --git a/pytests/src/lib.rs b/pytests/src/lib.rs index 72f5feaa0f4..8a467fe3554 100644 --- a/pytests/src/lib.rs +++ b/pytests/src/lib.rs @@ -8,6 +8,7 @@ pub mod comparisons; pub mod datetime; pub mod dict_iter; pub mod enums; +pub mod free_threaded_mod; pub mod misc; pub mod objstore; pub mod othermod; @@ -17,7 +18,7 @@ pub mod pyfunctions; pub mod sequence; pub mod subclassing; -#[pymodule] +#[pymodule(gil_used = false)] fn pyo3_pytests(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_wrapped(wrap_pymodule!(awaitable::awaitable))?; #[cfg(not(Py_LIMITED_API))] @@ -35,6 +36,7 @@ fn pyo3_pytests(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_wrapped(wrap_pymodule!(pyfunctions::pyfunctions))?; m.add_wrapped(wrap_pymodule!(sequence::sequence))?; m.add_wrapped(wrap_pymodule!(subclassing::subclassing))?; + m.add_wrapped(wrap_pymodule!(free_threaded_mod::free_threaded_mod))?; // Inserting to sys.modules allows importing submodules nicely from Python // e.g. import pyo3_pytests.buf_and_str as bas diff --git a/pytests/src/misc.rs b/pytests/src/misc.rs index ed9c9333ec2..e44d1aa0ecf 100644 --- a/pytests/src/misc.rs +++ b/pytests/src/misc.rs @@ -32,7 +32,7 @@ fn get_item_and_run_callback(dict: Bound<'_, PyDict>, callback: Bound<'_, PyAny> Ok(()) } -#[pymodule] +#[pymodule(gil_used = false)] pub fn misc(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(issue_219, m)?)?; m.add_function(wrap_pyfunction!(get_type_fully_qualified_name, m)?)?; diff --git a/pytests/src/objstore.rs b/pytests/src/objstore.rs index 844cee946ad..8e729052992 100644 --- a/pytests/src/objstore.rs +++ b/pytests/src/objstore.rs @@ -18,7 +18,7 @@ impl ObjStore { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn objstore(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::() } diff --git a/pytests/src/othermod.rs b/pytests/src/othermod.rs index 36ad4b5e23e..0de912d7d04 100644 --- a/pytests/src/othermod.rs +++ b/pytests/src/othermod.rs @@ -28,7 +28,7 @@ fn double(x: i32) -> i32 { x * 2 } -#[pymodule] +#[pymodule(gil_used = false)] pub fn othermod(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(double, m)?)?; diff --git a/pytests/src/path.rs b/pytests/src/path.rs index 0675e56d13a..b52c038ed34 100644 --- a/pytests/src/path.rs +++ b/pytests/src/path.rs @@ -11,7 +11,7 @@ fn take_pathbuf(path: PathBuf) -> PathBuf { path } -#[pymodule] +#[pymodule(gil_used = false)] pub fn path(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(make_path, m)?)?; m.add_function(wrap_pyfunction!(take_pathbuf, m)?)?; diff --git a/pytests/src/pyclasses.rs b/pytests/src/pyclasses.rs index 4e52dbc8712..3af08c053cc 100644 --- a/pytests/src/pyclasses.rs +++ b/pytests/src/pyclasses.rs @@ -104,7 +104,7 @@ impl ClassWithDict { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn pyclasses(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; diff --git a/pytests/src/pyfunctions.rs b/pytests/src/pyfunctions.rs index 77496198bb9..024641d3d2e 100644 --- a/pytests/src/pyfunctions.rs +++ b/pytests/src/pyfunctions.rs @@ -67,7 +67,7 @@ fn args_kwargs<'py>( (args, kwargs) } -#[pymodule] +#[pymodule(gil_used = false)] pub fn pyfunctions(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(none, m)?)?; m.add_function(wrap_pyfunction!(simple, m)?)?; diff --git a/pytests/src/sequence.rs b/pytests/src/sequence.rs index f552b4048b8..175f5fba8aa 100644 --- a/pytests/src/sequence.rs +++ b/pytests/src/sequence.rs @@ -16,7 +16,7 @@ fn vec_to_vec_pystring(vec: Vec>) -> Vec vec } -#[pymodule] +#[pymodule(gil_used = false)] pub fn sequence(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(vec_to_vec_i32, m)?)?; m.add_function(wrap_pyfunction!(array_to_array_i32, m)?)?; diff --git a/pytests/src/subclassing.rs b/pytests/src/subclassing.rs index 8e451cd9183..0f00e74c19d 100644 --- a/pytests/src/subclassing.rs +++ b/pytests/src/subclassing.rs @@ -17,7 +17,7 @@ impl Subclassable { } } -#[pymodule] +#[pymodule(gil_used = false)] pub fn subclassing(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; Ok(()) diff --git a/pytests/tests/test_free_threaded.py b/pytests/tests/test_free_threaded.py new file mode 100644 index 00000000000..f87c10d392c --- /dev/null +++ b/pytests/tests/test_free_threaded.py @@ -0,0 +1,14 @@ +import pytest +import sys +import sysconfig + +from pyo3_pytests import free_threaded_mod # NOQA + +GIL_DISABLED_BUILD = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + + +@pytest.mark.skipif( + not GIL_DISABLED_BUILD, reason="test is not meaningful on GIL-enabled build" +) +def test_gil_disabled(): + assert not sys._is_gil_enabled() diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index da17fe4bbdc..08b1ead7584 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -8,17 +8,20 @@ use std::{cell::UnsafeCell, ffi::CStr, marker::PhantomData}; not(all(windows, Py_LIMITED_API, not(Py_3_10))), not(target_has_atomic = "64"), ))] -use portable_atomic::{AtomicI64, Ordering}; +use portable_atomic::AtomicI64; #[cfg(all( not(any(PyPy, GraalPy)), Py_3_9, not(all(windows, Py_LIMITED_API, not(Py_3_10))), target_has_atomic = "64", ))] -use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(any(PyPy, GraalPy)))] use crate::exceptions::PyImportError; +#[cfg(all(not(Py_LIMITED_API), Py_GIL_DISABLED))] +use crate::PyErr; use crate::{ ffi, impl_::pymethods::PyMethodDef, @@ -41,6 +44,8 @@ pub struct ModuleDef { interpreter: AtomicI64, /// Initialized module object, cached to avoid reinitialization. module: GILOnceCell>, + /// Whether or not the module supports running without the GIL + gil_used: AtomicBool, } /// Wrapper to enable initializer to be used in const fns. @@ -85,10 +90,12 @@ impl ModuleDef { ))] interpreter: AtomicI64::new(-1), module: GILOnceCell::new(), + gil_used: AtomicBool::new(true), } } /// Builds a module using user given initializer. Used for [`#[pymodule]`][crate::pymodule]. - pub fn make_module(&'static self, py: Python<'_>) -> PyResult> { + #[cfg_attr(any(Py_LIMITED_API, not(Py_GIL_DISABLED)), allow(unused_variables))] + pub fn make_module(&'static self, py: Python<'_>, gil_used: bool) -> PyResult> { // Check the interpreter ID has not changed, since we currently have no way to guarantee // that static data is not reused across interpreters. // @@ -134,6 +141,19 @@ impl ModuleDef { ffi::PyModule_Create(self.ffi_def.get()), )? }; + #[cfg(all(not(Py_LIMITED_API), Py_GIL_DISABLED))] + { + let gil_used_ptr = { + if gil_used { + ffi::Py_MOD_GIL_USED + } else { + ffi::Py_MOD_GIL_NOT_USED + } + }; + if unsafe { ffi::PyUnstable_Module_SetGIL(module.as_ptr(), gil_used_ptr) } < 0 { + return Err(PyErr::fetch(py)); + } + } self.initializer.0(module.bind(py))?; Ok(module) }) @@ -190,7 +210,10 @@ impl PyAddToModule for PyMethodDef { /// For adding a module to a module. impl PyAddToModule for ModuleDef { fn add_to_module(&'static self, module: &Bound<'_, PyModule>) -> PyResult<()> { - module.add_submodule(self.make_module(module.py())?.bind(module.py())) + module.add_submodule( + self.make_module(module.py(), self.gil_used.load(Ordering::Relaxed))? + .bind(module.py()), + ) } } @@ -223,7 +246,7 @@ mod tests { ) }; Python::with_gil(|py| { - let module = MODULE_DEF.make_module(py).unwrap().into_bound(py); + let module = MODULE_DEF.make_module(py, false).unwrap().into_bound(py); assert_eq!( module .getattr("__name__") diff --git a/src/macros.rs b/src/macros.rs index d2fa6f31ada..4d7d4616425 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -176,7 +176,7 @@ macro_rules! wrap_pymodule { &|py| { use $module as wrapped_pymodule; wrapped_pymodule::_PYO3_DEF - .make_module(py) + .make_module(py, false) .expect("failed to wrap pymodule") } }; diff --git a/src/types/module.rs b/src/types/module.rs index f3490385721..d3e59c85198 100644 --- a/src/types/module.rs +++ b/src/types/module.rs @@ -9,6 +9,8 @@ use crate::types::{ }; use crate::{exceptions, ffi, Borrowed, Bound, BoundObject, Py, PyObject, Python}; use std::ffi::{CStr, CString}; +#[cfg(all(not(Py_LIMITED_API), Py_GIL_DISABLED))] +use std::os::raw::c_int; use std::str; /// Represents a Python [`module`][1] object. @@ -385,6 +387,40 @@ pub trait PyModuleMethods<'py>: crate::sealed::Sealed { /// [1]: crate::prelude::pyfunction /// [2]: crate::wrap_pyfunction fn add_function(&self, fun: Bound<'_, PyCFunction>) -> PyResult<()>; + + /// Declare whether or not this module supports running with the GIL disabled + /// + /// If the module does not rely on the GIL for thread safety, you can pass + /// `false` to this function to indicate the module does not rely on the GIL + /// for thread-safety. + /// + /// This function sets the [`Py_MOD_GIL` + /// slot](https://docs.python.org/3/c-api/module.html#c.Py_mod_gil) on the + /// module object. The default is `Py_MOD_GIL_USED`, so passing `true` to + /// this function is a no-op unless you have already set `Py_MOD_GIL` to + /// `Py_MOD_GIL_NOT_USED` elsewhere. + /// + /// # Examples + /// + /// ```rust + /// use pyo3::prelude::*; + /// + /// #[pymodule(gil_used = false)] + /// fn my_module(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { + /// let submodule = PyModule::new(py, "submodule")?; + /// submodule.gil_used(false)?; + /// module.add_submodule(&submodule)?; + /// Ok(()) + /// } + /// ``` + /// + /// The resulting module will not print a `RuntimeWarning` and re-enable the + /// GIL when Python imports it on the free-threaded build, since all module + /// objects defined in the extension have `Py_MOD_GIL` set to + /// `Py_MOD_GIL_NOT_USED`. + /// + /// This is a no-op on the GIL-enabled build. + fn gil_used(&self, gil_used: bool) -> PyResult<()>; } impl<'py> PyModuleMethods<'py> for Bound<'py, PyModule> { @@ -511,6 +547,23 @@ impl<'py> PyModuleMethods<'py> for Bound<'py, PyModule> { let name = fun.getattr(__name__(self.py()))?; self.add(name.downcast_into::()?, fun) } + + #[cfg_attr(any(Py_LIMITED_API, not(Py_GIL_DISABLED)), allow(unused_variables))] + fn gil_used(&self, gil_used: bool) -> PyResult<()> { + #[cfg(all(not(Py_LIMITED_API), Py_GIL_DISABLED))] + { + let gil_used = match gil_used { + true => ffi::Py_MOD_GIL_USED, + false => ffi::Py_MOD_GIL_NOT_USED, + }; + match unsafe { ffi::PyUnstable_Module_SetGIL(self.as_ptr(), gil_used) } { + c_int::MIN..=-1 => Err(PyErr::fetch(self.py())), + 0..=c_int::MAX => Ok(()), + } + } + #[cfg(any(Py_LIMITED_API, not(Py_GIL_DISABLED)))] + Ok(()) + } } fn __all__(py: Python<'_>) -> &Bound<'_, PyString> { diff --git a/tests/test_module.rs b/tests/test_module.rs index 7b97fb3a889..36d21abe9b0 100644 --- a/tests/test_module.rs +++ b/tests/test_module.rs @@ -37,7 +37,7 @@ fn double(x: usize) -> usize { } /// This module is implemented in Rust. -#[pymodule] +#[pymodule(gil_used = false)] fn module_with_functions(m: &Bound<'_, PyModule>) -> PyResult<()> { #[pyfn(m)] #[pyo3(name = "no_parameters")] @@ -182,6 +182,8 @@ fn test_module_from_code_bound() { .extract() .expect("The value should be able to be converted to an i32"); + adder_mod.gil_used(false).expect("Disabling the GIL failed"); + assert_eq!(ret_value, 3); }); } diff --git a/tests/ui/invalid_pymodule_args.stderr b/tests/ui/invalid_pymodule_args.stderr index 261d8115e15..23a5109b4cb 100644 --- a/tests/ui/invalid_pymodule_args.stderr +++ b/tests/ui/invalid_pymodule_args.stderr @@ -1,4 +1,4 @@ -error: expected one of: `name`, `crate`, `module`, `submodule` +error: expected one of: `name`, `crate`, `module`, `submodule`, `gil_used` --> tests/ui/invalid_pymodule_args.rs:3:12 | 3 | #[pymodule(some_arg)]