Skip to content

Commit 9419609

Browse files
committed
POC: Add compat mutex for free-threaded Python builds
In free-threaded Python (Py_GIL_DISABLED), the GIL no longer provides mutual exclusion. Existing code that assumes gil_scoped_acquire provides mutual exclusion would have data races. This adds a global compatibility mutex that restores the safety guarantee: - Acquired when gil_scoped_acquire is constructed (if not already held) - Released when gil_scoped_release is constructed (if held) - Ownership is tracked per-thread to handle the main thread case KNOWN LIMITATION: The global mutex conflicts with per-interpreter GILs (Py_MOD_PER_INTERPRETER_GIL_SUPPORTED). The "Per-Subinterpreter GIL" test deadlocks with this change. Future work could use per-interpreter mutexes. This is a proof-of-concept for discussion.
1 parent 78381e5 commit 9419609

File tree

3 files changed

+123
-3
lines changed

3 files changed

+123
-3
lines changed

include/pybind11/gil.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ class gil_scoped_acquire {
9797
}
9898

9999
inc_ref();
100+
# ifdef Py_GIL_DISABLED
101+
if (!detail::compat_mutex_held_by_this_thread()) {
102+
detail::get_compat_mutex().lock();
103+
detail::compat_mutex_held_by_this_thread() = true;
104+
acquired_compat_mutex_ = true;
105+
}
106+
# endif
100107
}
101108

102109
gil_scoped_acquire(const gil_scoped_acquire &) = delete;
@@ -141,6 +148,12 @@ class gil_scoped_acquire {
141148
PYBIND11_NOINLINE void disarm() { active = false; }
142149

143150
PYBIND11_NOINLINE ~gil_scoped_acquire() {
151+
# ifdef Py_GIL_DISABLED
152+
if (acquired_compat_mutex_) {
153+
detail::compat_mutex_held_by_this_thread() = false;
154+
detail::get_compat_mutex().unlock();
155+
}
156+
# endif
144157
dec_ref();
145158
if (release) {
146159
PyEval_SaveThread();
@@ -151,6 +164,9 @@ class gil_scoped_acquire {
151164
PyThreadState *tstate = nullptr;
152165
bool release = true;
153166
bool active = true;
167+
# ifdef Py_GIL_DISABLED
168+
bool acquired_compat_mutex_ = false;
169+
# endif
154170
};
155171

156172
class gil_scoped_release {
@@ -162,6 +178,13 @@ class gil_scoped_release {
162178
// `internals.tstate` for subsequent `gil_scoped_acquire` calls. Otherwise, an
163179
// initialization race could occur as multiple threads try `gil_scoped_acquire`.
164180
auto &internals = detail::get_internals();
181+
# ifdef Py_GIL_DISABLED
182+
if (detail::compat_mutex_held_by_this_thread()) {
183+
detail::compat_mutex_held_by_this_thread() = false;
184+
detail::get_compat_mutex().unlock();
185+
released_compat_mutex_ = true;
186+
}
187+
# endif
165188
// NOLINTNEXTLINE(cppcoreguidelines-prefer-member-initializer)
166189
tstate = PyEval_SaveThread();
167190
if (disassoc) {
@@ -186,6 +209,12 @@ class gil_scoped_release {
186209
// `PyEval_RestoreThread()` should not be called if runtime is finalizing
187210
if (active) {
188211
PyEval_RestoreThread(tstate);
212+
# ifdef Py_GIL_DISABLED
213+
if (released_compat_mutex_) {
214+
detail::get_compat_mutex().lock();
215+
detail::compat_mutex_held_by_this_thread() = true;
216+
}
217+
# endif
189218
}
190219
if (disassoc) {
191220
detail::get_internals().tstate = tstate;
@@ -196,6 +225,9 @@ class gil_scoped_release {
196225
PyThreadState *tstate;
197226
bool disassoc;
198227
bool active = true;
228+
# ifdef Py_GIL_DISABLED
229+
bool released_compat_mutex_ = false;
230+
# endif
199231
};
200232

201233
PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)

include/pybind11/gil_simple.h

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,113 @@
77
#include "detail/common.h"
88

99
#include <cassert>
10+
#ifdef Py_GIL_DISABLED
11+
# include <mutex>
12+
#endif
1013

1114
PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
1215

16+
#ifdef Py_GIL_DISABLED
17+
namespace detail {
18+
19+
// Compatibility mutex for free-threaded Python builds.
20+
// In traditional Python, the GIL provides mutual exclusion for code that acquires it.
21+
// In free-threaded Python, there is no GIL, so existing code that assumes mutual exclusion
22+
// after gil_scoped_acquire would have data races. This mutex restores that safety guarantee.
23+
//
24+
// This is intentionally a global mutex (not per-interpreter) for simplicity. The performance
25+
// cost is acceptable as a safe default; code that needs maximum parallelism can be migrated
26+
// to use explicit locking or the lighter-weight scoped_ensure_thread_state helper.
27+
inline std::mutex &get_compat_mutex() {
28+
static std::mutex mtx;
29+
return mtx;
30+
}
31+
32+
// Thread-local flag to track whether this thread holds the compat mutex.
33+
// This is needed because the main thread starts with Python initialized (holding the "GIL")
34+
// but we don't lock the compat mutex at that point. We only want to lock/unlock when
35+
// transitioning via gil_scoped_acquire/release.
36+
inline bool &compat_mutex_held_by_this_thread() {
37+
static thread_local bool held = false;
38+
return held;
39+
}
40+
41+
inline void acquire_compat_mutex() {
42+
if (!compat_mutex_held_by_this_thread()) {
43+
get_compat_mutex().lock();
44+
compat_mutex_held_by_this_thread() = true;
45+
}
46+
}
47+
48+
inline void release_compat_mutex() {
49+
if (compat_mutex_held_by_this_thread()) {
50+
compat_mutex_held_by_this_thread() = false;
51+
get_compat_mutex().unlock();
52+
}
53+
}
54+
55+
} // namespace detail
56+
#endif
57+
1358
class gil_scoped_acquire_simple {
1459
PyGILState_STATE state;
60+
#ifdef Py_GIL_DISABLED
61+
bool acquired_compat_mutex_ = false;
62+
#endif
1563

1664
public:
17-
gil_scoped_acquire_simple() : state{PyGILState_Ensure()} {}
65+
gil_scoped_acquire_simple() : state{PyGILState_Ensure()} {
66+
#ifdef Py_GIL_DISABLED
67+
if (!detail::compat_mutex_held_by_this_thread()) {
68+
detail::get_compat_mutex().lock();
69+
detail::compat_mutex_held_by_this_thread() = true;
70+
acquired_compat_mutex_ = true;
71+
}
72+
#endif
73+
}
1874
gil_scoped_acquire_simple(const gil_scoped_acquire_simple &) = delete;
1975
gil_scoped_acquire_simple &operator=(const gil_scoped_acquire_simple &) = delete;
20-
~gil_scoped_acquire_simple() { PyGILState_Release(state); }
76+
~gil_scoped_acquire_simple() {
77+
#ifdef Py_GIL_DISABLED
78+
if (acquired_compat_mutex_) {
79+
detail::compat_mutex_held_by_this_thread() = false;
80+
detail::get_compat_mutex().unlock();
81+
}
82+
#endif
83+
PyGILState_Release(state);
84+
}
2185
};
2286

2387
class gil_scoped_release_simple {
2488
PyThreadState *state;
89+
#ifdef Py_GIL_DISABLED
90+
bool released_compat_mutex_ = false;
91+
#endif
2592

2693
public:
2794
// PRECONDITION: The GIL must be held when this constructor is called.
2895
gil_scoped_release_simple() {
2996
assert(PyGILState_Check());
97+
#ifdef Py_GIL_DISABLED
98+
if (detail::compat_mutex_held_by_this_thread()) {
99+
detail::compat_mutex_held_by_this_thread() = false;
100+
detail::get_compat_mutex().unlock();
101+
released_compat_mutex_ = true;
102+
}
103+
#endif
30104
state = PyEval_SaveThread();
31105
}
32106
gil_scoped_release_simple(const gil_scoped_release_simple &) = delete;
33107
gil_scoped_release_simple &operator=(const gil_scoped_release_simple &) = delete;
34-
~gil_scoped_release_simple() { PyEval_RestoreThread(state); }
108+
~gil_scoped_release_simple() {
109+
PyEval_RestoreThread(state);
110+
#ifdef Py_GIL_DISABLED
111+
if (released_compat_mutex_) {
112+
detail::get_compat_mutex().lock();
113+
detail::compat_mutex_held_by_this_thread() = true;
114+
}
115+
#endif
116+
}
35117
};
36118

37119
PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)

tests/test_with_catch/test_subinterpreter.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,12 @@ TEST_CASE("Multiple Subinterpreters") {
310310

311311
# ifdef Py_MOD_PER_INTERPRETER_GIL_SUPPORTED
312312
TEST_CASE("Per-Subinterpreter GIL") {
313+
// Test is skipped on free-threaded Python because the pybind11 compat mutex
314+
// (which restores GIL-like mutual exclusion) conflicts with per-interpreter GILs.
315+
# ifdef Py_GIL_DISABLED
316+
PYBIND11_CATCH2_SKIP_IF(true, "Skipped: compat mutex conflicts with per-interpreter GILs");
317+
# endif
318+
313319
auto main_int
314320
= py::module_::import("external_module").attr("internals_at")().cast<uintptr_t>();
315321

0 commit comments

Comments
 (0)