Skip to content

Commit e1630fb

Browse files
fix multithreaded access to freelist pyclasses (#4902)
* make FreeList explicitly a wrapper for *mut PyObject * fix multithreaded access to freelist pyclasses * add changelog entry * use a GILOnceCell to initialize the freelist * respond to code review * skip test on wasm --------- Co-authored-by: David Hewitt <mail@davidhewitt.dev>
1 parent 108abb4 commit e1630fb

File tree

5 files changed

+72
-31
lines changed

5 files changed

+72
-31
lines changed

newsfragments/4902.fixed.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Fixed thread-unsafe implementation of freelist pyclasses on the free-threaded build.

pyo3-macros-backend/src/pyclass.rs

+7-9
Original file line numberDiff line numberDiff line change
@@ -2395,15 +2395,13 @@ impl<'a> PyClassImplsBuilder<'a> {
23952395
quote! {
23962396
impl #pyo3_path::impl_::pyclass::PyClassWithFreeList for #cls {
23972397
#[inline]
2398-
fn get_free_list(py: #pyo3_path::Python<'_>) -> &mut #pyo3_path::impl_::freelist::FreeList<*mut #pyo3_path::ffi::PyObject> {
2399-
static mut FREELIST: *mut #pyo3_path::impl_::freelist::FreeList<*mut #pyo3_path::ffi::PyObject> = 0 as *mut _;
2400-
unsafe {
2401-
if FREELIST.is_null() {
2402-
FREELIST = ::std::boxed::Box::into_raw(::std::boxed::Box::new(
2403-
#pyo3_path::impl_::freelist::FreeList::with_capacity(#freelist)));
2404-
}
2405-
&mut *FREELIST
2406-
}
2398+
fn get_free_list(py: #pyo3_path::Python<'_>) -> &'static ::std::sync::Mutex<#pyo3_path::impl_::freelist::PyObjectFreeList> {
2399+
static FREELIST: #pyo3_path::sync::GILOnceCell<::std::sync::Mutex<#pyo3_path::impl_::freelist::PyObjectFreeList>> = #pyo3_path::sync::GILOnceCell::new();
2400+
// If there's a race to fill the cell, the object created
2401+
// by the losing thread will be deallocated via RAII
2402+
&FREELIST.get_or_init(py, || {
2403+
::std::sync::Mutex::new(#pyo3_path::impl_::freelist::PyObjectFreeList::with_capacity(#freelist))
2404+
})
24072405
}
24082406
}
24092407
}

src/impl_/freelist.rs

+24-18
Original file line numberDiff line numberDiff line change
@@ -8,58 +8,64 @@
88
//!
99
//! [1]: https://en.wikipedia.org/wiki/Free_list
1010
11+
use crate::ffi;
1112
use std::mem;
1213

13-
/// Represents a slot of a [`FreeList`].
14-
pub enum Slot<T> {
14+
/// Represents a slot of a [`PyObjectFreeList`].
15+
enum PyObjectSlot {
1516
/// A free slot.
1617
Empty,
1718
/// An allocated slot.
18-
Filled(T),
19+
Filled(*mut ffi::PyObject),
1920
}
2021

21-
/// A free allocation list.
22+
// safety: access is guarded by a per-pyclass mutex
23+
unsafe impl Send for PyObjectSlot {}
24+
25+
/// A free allocation list for PyObject ffi pointers.
2226
///
2327
/// See [the parent module](crate::impl_::freelist) for more details.
24-
pub struct FreeList<T> {
25-
entries: Vec<Slot<T>>,
28+
pub struct PyObjectFreeList {
29+
entries: Box<[PyObjectSlot]>,
2630
split: usize,
2731
capacity: usize,
2832
}
2933

30-
impl<T> FreeList<T> {
31-
/// Creates a new `FreeList` instance with specified capacity.
32-
pub fn with_capacity(capacity: usize) -> FreeList<T> {
33-
let entries = (0..capacity).map(|_| Slot::Empty).collect::<Vec<_>>();
34+
impl PyObjectFreeList {
35+
/// Creates a new `PyObjectFreeList` instance with specified capacity.
36+
pub fn with_capacity(capacity: usize) -> PyObjectFreeList {
37+
let entries = (0..capacity)
38+
.map(|_| PyObjectSlot::Empty)
39+
.collect::<Box<[_]>>();
3440

35-
FreeList {
41+
PyObjectFreeList {
3642
entries,
3743
split: 0,
3844
capacity,
3945
}
4046
}
4147

4248
/// Pops the first non empty item.
43-
pub fn pop(&mut self) -> Option<T> {
49+
pub fn pop(&mut self) -> Option<*mut ffi::PyObject> {
4450
let idx = self.split;
4551
if idx == 0 {
4652
None
4753
} else {
48-
match mem::replace(&mut self.entries[idx - 1], Slot::Empty) {
49-
Slot::Filled(v) => {
54+
match mem::replace(&mut self.entries[idx - 1], PyObjectSlot::Empty) {
55+
PyObjectSlot::Filled(v) => {
5056
self.split = idx - 1;
5157
Some(v)
5258
}
53-
_ => panic!("FreeList is corrupt"),
59+
_ => panic!("PyObjectFreeList is corrupt"),
5460
}
5561
}
5662
}
5763

58-
/// Inserts a value into the list. Returns `Some(val)` if the `FreeList` is full.
59-
pub fn insert(&mut self, val: T) -> Option<T> {
64+
/// Inserts a value into the list. Returns `Some(val)` if the `PyObjectFreeList` is full.
65+
pub fn insert(&mut self, val: *mut ffi::PyObject) -> Option<*mut ffi::PyObject> {
6066
let next = self.split + 1;
6167
if next < self.capacity {
62-
self.entries[self.split] = Slot::Filled(val);
68+
self.entries[self.split] = PyObjectSlot::Filled(val);
6369
self.split = next;
6470
None
6571
} else {

src/impl_/pyclass.rs

+11-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use crate::{
22
exceptions::{PyAttributeError, PyNotImplementedError, PyRuntimeError, PyValueError},
33
ffi,
44
impl_::{
5-
freelist::FreeList,
5+
freelist::PyObjectFreeList,
66
pycell::{GetBorrowChecker, PyClassMutability, PyClassObjectLayout},
77
pyclass_init::PyObjectInit,
88
pymethods::{PyGetterDef, PyMethodDefType},
@@ -20,6 +20,7 @@ use std::{
2020
marker::PhantomData,
2121
os::raw::{c_int, c_void},
2222
ptr::NonNull,
23+
sync::Mutex,
2324
thread,
2425
};
2526

@@ -910,7 +911,7 @@ use super::{pycell::PyClassObject, pymethods::BoundRef};
910911
/// Do not implement this trait manually. Instead, use `#[pyclass(freelist = N)]`
911912
/// on a Rust struct to implement it.
912913
pub trait PyClassWithFreeList: PyClass {
913-
fn get_free_list(py: Python<'_>) -> &mut FreeList<*mut ffi::PyObject>;
914+
fn get_free_list(py: Python<'_>) -> &'static Mutex<PyObjectFreeList>;
914915
}
915916

916917
/// Implementation of tp_alloc for `freelist` classes.
@@ -931,7 +932,9 @@ pub unsafe extern "C" fn alloc_with_freelist<T: PyClassWithFreeList>(
931932
// If this type is a variable type or the subtype is not equal to this type, we cannot use the
932933
// freelist
933934
if nitems == 0 && subtype == self_type {
934-
if let Some(obj) = T::get_free_list(py).pop() {
935+
let mut free_list = T::get_free_list(py).lock().unwrap();
936+
if let Some(obj) = free_list.pop() {
937+
drop(free_list);
935938
ffi::PyObject_Init(obj, subtype);
936939
return obj as _;
937940
}
@@ -951,7 +954,11 @@ pub unsafe extern "C" fn free_with_freelist<T: PyClassWithFreeList>(obj: *mut c_
951954
T::type_object_raw(Python::assume_gil_acquired()),
952955
ffi::Py_TYPE(obj)
953956
);
954-
if let Some(obj) = T::get_free_list(Python::assume_gil_acquired()).insert(obj) {
957+
let mut free_list = T::get_free_list(Python::assume_gil_acquired())
958+
.lock()
959+
.unwrap();
960+
if let Some(obj) = free_list.insert(obj) {
961+
drop(free_list);
955962
let ty = ffi::Py_TYPE(obj);
956963

957964
// Deduce appropriate inverse of PyType_GenericAlloc

tests/test_gc.rs

+29
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ fn class_with_freelist() {
3636
});
3737
}
3838

39+
#[pyclass(freelist = 2)]
40+
#[cfg(not(target_arch = "wasm32"))]
41+
struct ClassWithFreelistAndData {
42+
data: Option<usize>,
43+
}
44+
45+
#[cfg(not(target_arch = "wasm32"))]
46+
fn spin_freelist(py: Python<'_>, data: usize) {
47+
for _ in 0..500 {
48+
let inst1 = Py::new(py, ClassWithFreelistAndData { data: Some(data) }).unwrap();
49+
let inst2 = Py::new(py, ClassWithFreelistAndData { data: Some(data) }).unwrap();
50+
assert_eq!(inst1.borrow(py).data, Some(data));
51+
assert_eq!(inst2.borrow(py).data, Some(data));
52+
}
53+
}
54+
55+
#[test]
56+
#[cfg(not(target_arch = "wasm32"))]
57+
fn multithreaded_class_with_freelist() {
58+
std::thread::scope(|s| {
59+
s.spawn(|| {
60+
Python::with_gil(|py| spin_freelist(py, 12));
61+
});
62+
s.spawn(|| {
63+
Python::with_gil(|py| spin_freelist(py, 0x4d3d3d3));
64+
});
65+
});
66+
}
67+
3968
/// Helper function to create a pair of objects that can be used to test drops;
4069
/// the first object is a guard that records when it has been dropped, the second
4170
/// object is a check that can be used to assert that the guard has been dropped.

0 commit comments

Comments
 (0)