Skip to content

Commit 46ebf50

Browse files
ymwang78claudepre-commit-ci[bot]
authored
feat(subinterpreter): reusable PyThreadState via subinterpreter_thread_state (#6073)
* feat(subinterpreter): add opt-in TLS-cached thread state mode subinterpreter_scoped_activate previously created and destroyed a fresh PyThreadState on every activation when the calling OS thread was not already running the target interpreter. Workloads that repeatedly re-enter the same sub-interpreter from the same thread therefore churn thread states and lose per-thread interpreter state between activations (see #6040). Add an opt-in subinterpreter_thread_state::cached policy: on first use a PyThreadState is created and stored in OS-thread-local storage keyed by the target interpreter; subsequent activations on that thread only swap it in/out and never destroy it. The default stays transient, so existing behavior is unchanged. Since pybind11 does not control thread lifetime, cleanup is explicit: subinterpreter::release_cached_thread_state() releases the calling thread's cached state for one interpreter, and the static release_all_cached_thread_states() releases all of the calling thread's cached states as an end-of-thread hook. The TLS map's destructor only frees its own nodes and never touches the Python C API, so an unreleased state leaks rather than crashing at thread exit. Includes test coverage and embedding docs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: pre-commit fixes * refactor(subinterpreter): replace cached enum/TLS with subinterpreter_thread_state RAII Address review feedback on the original "cached" mode by switching to an explicit two-RAII design suggested by @b-pass: "Create a class ... to RAII-manage the PyThreadState but start its lifetime in an already released state. You could create another class (or modify scoped_activate) to scoped/RAII activate the inactive threadstate." Removed - enum subinterpreter_thread_state { transient, cached } and the defaulted ctor parameter on subinterpreter_scoped_activate. - detail::subinterpreter_thread_state_cache thread_local map. - subinterpreter::release_cached_thread_state() and subinterpreter::release_all_cached_thread_states(). This eliminates: the hidden per-thread map, the "release_all" footgun across pybind11 modules (the cache was module-local), and the implicit "must not be active when called" contract on the release functions. Added - Public class subinterpreter_thread_state that owns one PyThreadState for a given subinterpreter on its constructing OS thread, created in a released state (not current, no GIL). Non-copyable, non-movable (PyThreadState is bound to its creating OS thread). - subinterpreter_scoped_activate(subinterpreter_thread_state &) overload: swaps the owned PyThreadState in on entry, swaps it out on exit, does not touch its lifetime. Behavior - The existing subinterpreter_scoped_activate(subinterpreter const &) overload is unchanged (still transient: New on entry, Delete on exit). All previously-working code keeps working. - With subinterpreter_thread_state, one OS thread can alternate between multiple subinterpreters and each PyThreadState is preserved across activations -- the use case that gil_scoped_release/acquire + a long-lived scoped_activate cannot solve alone (the per-thread internals.tstate slot holds only one inactive tstate). - The dtor of subinterpreter_thread_state guards against the "destroyed-while-active" contract violation: if Swap reveals the cached tstate was current, do not Swap back to a now-deleted pointer (the safe-when-active fix b-pass requested for the old release_* functions, applied at the natural location instead). Lifetime contract is enforced by ordinary C++ scope: typical placement is `thread_local`. No new release/cleanup APIs are required. Tests cover (a) tstate identity preserved across activations on a thread, (b) transient and reusing modes do not share state, (c) different OS threads get distinct PyThreadStates, and (d) the multi-subinterpreter alternation case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(subinterpreter): address review on #6073 (same-thread checks, test scoping) Per @b-pass's review: - ~subinterpreter_thread_state(): add a PYBIND11_DETAILED_ERROR_MESSAGES- guarded check that destruction happens on the OS thread that created the PyThreadState (same PyThread_get_thread_native_id pattern as ~subinterpreter), failing with pybind11_fail otherwise. - subinterpreter_scoped_activate(subinterpreter_thread_state &): add the matching DETAILED_ERROR_MESSAGES check that activation happens on the creating OS thread, enforcing the newly documented rule. - docs: document that activating a subinterpreter_thread_state on another OS thread is illegal. - tests: keep each subinterpreter (and its subinterpreter_thread_state) in an enclosing scope so destruction order is thread-state -> subinterpreter -> unsafe_reset_internals_for_single_interpreter(). The previous top-level declarations ran the reset while the subinterpreters were still alive, which is the likely cause of the CI crashes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: fix codespell (re-used -> reused) in embedding.rst Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f891299 commit 46ebf50

3 files changed

Lines changed: 351 additions & 2 deletions

File tree

docs/advanced/embedding.rst

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,75 @@ Example:
345345
}
346346
347347
348+
Reusing a thread state across activations
349+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
350+
351+
By default, :class:`subinterpreter_scoped_activate` creates a fresh
352+
``PyThreadState`` on entry and destroys it on exit. A thread that repeatedly
353+
re-enters the same sub-interpreter therefore allocates and frees a thread state
354+
every time, and does **not** preserve any per-thread interpreter state between
355+
activations.
356+
357+
For workloads where a single OS thread re-enters one or more sub-interpreters
358+
many times, pybind11 provides :class:`subinterpreter_thread_state` — an RAII
359+
object that owns a ``PyThreadState`` and lets you swap it in for the duration
360+
of each :class:`subinterpreter_scoped_activate` scope without destroying it
361+
between activations:
362+
363+
.. code-block:: cpp
364+
365+
// Create the PyThreadState once. It is created in a "released" state:
366+
// not current, no GIL acquired.
367+
thread_local py::subinterpreter_thread_state ts(sub);
368+
369+
{
370+
// Swap it in; the subinterpreter's GIL is acquired.
371+
py::subinterpreter_scoped_activate guard(ts);
372+
// ... use the sub-interpreter ...
373+
}
374+
// Swap-out only; the PyThreadState is kept alive in `ts`.
375+
376+
{
377+
py::subinterpreter_scoped_activate guard(ts);
378+
// The same PyThreadState is reused; its per-thread interpreter state
379+
// is preserved across activations.
380+
}
381+
382+
This composes naturally with multiple sub-interpreters on the same OS thread:
383+
hold one :class:`subinterpreter_thread_state` per sub-interpreter and alternate
384+
between them. Each ``PyThreadState`` is independent and is preserved across
385+
activations.
386+
387+
.. code-block:: cpp
388+
389+
thread_local py::subinterpreter_thread_state ts_a(sub_a);
390+
thread_local py::subinterpreter_thread_state ts_b(sub_b);
391+
392+
{ py::subinterpreter_scoped_activate guard(ts_a); /* in sub_a */ }
393+
{ py::subinterpreter_scoped_activate guard(ts_b); /* in sub_b */ }
394+
{ py::subinterpreter_scoped_activate guard(ts_a); /* same PyThreadState as before */ }
395+
396+
The default behavior is unchanged: the
397+
:class:`subinterpreter_scoped_activate(subinterpreter const&)` overload still
398+
creates and destroys a transient ``PyThreadState`` per scope, and it never
399+
shares a thread state with any :class:`subinterpreter_thread_state` that may
400+
also exist for the same sub-interpreter on the same thread.
401+
402+
.. warning::
403+
404+
Lifetime and threading requirements for :class:`subinterpreter_thread_state`:
405+
406+
- It must be constructed and destroyed on the **same OS thread**. A
407+
``PyThreadState`` is bound to its creating thread; deleting it on another
408+
thread is undefined behavior. Holding the object as a ``thread_local``
409+
satisfies this automatically.
410+
- It must only be activated (with :class:`subinterpreter_scoped_activate`)
411+
on the **same OS thread** that constructed it. Activating it on a
412+
different thread is illegal.
413+
- It must be destroyed while its sub-interpreter is still alive.
414+
- It must **not** be destroyed while a :class:`subinterpreter_scoped_activate`
415+
referring to it is alive — the activator holds a reference into it.
416+
348417
GIL API for sub-interpreters
349418
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
350419

include/pybind11/subinterpreter.h

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,27 @@
2222
PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE)
2323

2424
class subinterpreter;
25+
class subinterpreter_thread_state;
2526

2627
/// Activate the subinterpreter and acquire its GIL, while also releasing any GIL and interpreter
2728
/// currently held. Upon exiting the scope, the previous subinterpreter (if any) and its
2829
/// associated GIL are restored to their state as they were before the scope was entered.
30+
///
31+
/// Two construction modes are supported:
32+
///
33+
/// 1. `subinterpreter_scoped_activate(subinterpreter const &)`:
34+
/// Transient mode (the default). A fresh PyThreadState is created on entry and destroyed on
35+
/// exit. This is the established behavior; existing code is unaffected.
36+
///
37+
/// 2. `subinterpreter_scoped_activate(subinterpreter_thread_state &)`:
38+
/// Reuse mode. The PyThreadState owned by the given subinterpreter_thread_state is swapped
39+
/// in on entry and swapped out (but NOT destroyed) on exit, so repeated activations on the
40+
/// same OS thread reuse the same PyThreadState and preserve its per-thread interpreter state.
41+
/// Use this when a single OS thread re-enters one or more subinterpreters many times.
2942
class subinterpreter_scoped_activate {
3043
public:
3144
explicit subinterpreter_scoped_activate(subinterpreter const &si);
45+
explicit subinterpreter_scoped_activate(subinterpreter_thread_state &ts);
3246
~subinterpreter_scoped_activate();
3347

3448
subinterpreter_scoped_activate(subinterpreter_scoped_activate &&) = delete;
@@ -41,6 +55,9 @@ class subinterpreter_scoped_activate {
4155
PyThreadState *tstate_ = nullptr;
4256
PyGILState_STATE gil_state_;
4357
bool simple_gil_ = false;
58+
// When true, tstate_ is owned by a subinterpreter_thread_state and must NOT be destroyed
59+
// when this scope exits (only swapped out).
60+
bool borrowed_ = false;
4461
};
4562

4663
/// Holds a Python subinterpreter instance
@@ -228,10 +245,71 @@ class subinterpreter {
228245

229246
private:
230247
friend class subinterpreter_scoped_activate;
248+
friend class subinterpreter_thread_state;
231249
PyInterpreterState *istate_ = nullptr;
232250
PyThreadState *creation_tstate_ = nullptr;
233251
};
234252

253+
/// RAII wrapper that owns a PyThreadState bound to a specific subinterpreter on the OS thread
254+
/// that constructed it. Intended to be held long-lived (e.g. as a `thread_local`, or inside a
255+
/// per-thread struct) so that many subinterpreter_scoped_activate scopes on the same OS thread
256+
/// can reuse a single PyThreadState instead of creating and destroying one each time.
257+
///
258+
/// The PyThreadState is created on construction in a *released* state: it is NOT made current,
259+
/// and no GIL is acquired. Activation is the job of subinterpreter_scoped_activate.
260+
///
261+
/// A single OS thread can hold one of these per subinterpreter and alternate between them via
262+
/// subinterpreter_scoped_activate without churning PyThreadState objects.
263+
///
264+
/// Lifetime / threading requirements:
265+
///
266+
/// - Construction and destruction must happen on the SAME OS thread (a PyThreadState is bound
267+
/// to the OS thread that created it; deleting it on a different thread is undefined behavior).
268+
/// - The owning subinterpreter must still be alive when this object is destroyed.
269+
/// - This object must NOT be destroyed while a subinterpreter_scoped_activate referring to it is
270+
/// still alive (the activator holds a reference into it).
271+
///
272+
/// Typical usage:
273+
///
274+
/// @code
275+
/// thread_local py::subinterpreter_thread_state ts(sub);
276+
/// {
277+
/// py::subinterpreter_scoped_activate guard(ts); // swap-in only
278+
/// // ... use the subinterpreter ...
279+
/// } // swap-out, tstate kept alive
280+
/// {
281+
/// py::subinterpreter_scoped_activate guard(ts); // reuses the same PyThreadState
282+
/// // ...
283+
/// }
284+
/// @endcode
285+
class subinterpreter_thread_state {
286+
public:
287+
/// Create a PyThreadState for `si` on the calling OS thread. The new state is left in a
288+
/// released state (not current, no GIL acquired).
289+
explicit subinterpreter_thread_state(subinterpreter const &si);
290+
291+
/// Destroy the owned PyThreadState. Must run on the same OS thread that constructed this
292+
/// object, while the owning subinterpreter is still alive, and while no
293+
/// subinterpreter_scoped_activate referring to this object is alive.
294+
~subinterpreter_thread_state();
295+
296+
subinterpreter_thread_state(subinterpreter_thread_state const &) = delete;
297+
subinterpreter_thread_state(subinterpreter_thread_state &&) = delete;
298+
subinterpreter_thread_state &operator=(subinterpreter_thread_state const &) = delete;
299+
subinterpreter_thread_state &operator=(subinterpreter_thread_state &&) = delete;
300+
301+
/// The interpreter this thread state belongs to.
302+
PyInterpreterState *interpreter_state() const { return istate_; }
303+
304+
/// The owned PyThreadState pointer; valid for the lifetime of this object.
305+
PyThreadState *raw_thread_state() const { return tstate_; }
306+
307+
private:
308+
friend class subinterpreter_scoped_activate;
309+
PyThreadState *tstate_ = nullptr;
310+
PyInterpreterState *istate_ = nullptr;
311+
};
312+
235313
class scoped_subinterpreter {
236314
public:
237315
scoped_subinterpreter() : si_(subinterpreter::create()), scope_(si_) {}
@@ -244,6 +322,8 @@ class scoped_subinterpreter {
244322
subinterpreter_scoped_activate scope_;
245323
};
246324

325+
// --- subinterpreter_scoped_activate -----------------------------------------------------------
326+
247327
inline subinterpreter_scoped_activate::subinterpreter_scoped_activate(subinterpreter const &si) {
248328
if (!si.istate_) {
249329
pybind11_fail("null subinterpreter");
@@ -267,6 +347,47 @@ inline subinterpreter_scoped_activate::subinterpreter_scoped_activate(subinterpr
267347
detail::get_internals().tstate = tstate_;
268348
}
269349

350+
inline subinterpreter_scoped_activate::subinterpreter_scoped_activate(
351+
subinterpreter_thread_state &ts) {
352+
if (ts.tstate_ == nullptr) {
353+
pybind11_fail("subinterpreter_scoped_activate: empty subinterpreter_thread_state");
354+
}
355+
356+
if (detail::get_interpreter_state_unchecked() == ts.istate_) {
357+
// We are already on this interpreter -- e.g. nested activation, or a different
358+
// PyThreadState for the same interpreter is already current on this thread. Match the
359+
// fast path of the (subinterpreter const&) overload: just ensure the GIL is held. The
360+
// `ts` argument's PyThreadState is intentionally NOT swapped to here; the already-current
361+
// tstate keeps being used until the outer scope exits.
362+
simple_gil_ = true;
363+
gil_state_ = PyGILState_Ensure();
364+
return;
365+
}
366+
367+
#if defined(PYBIND11_DETAILED_ERROR_MESSAGES)
368+
{
369+
// A PyThreadState is bound to its creating OS thread; it may only be activated there.
370+
bool same_thread = true;
371+
# ifdef PY_HAVE_THREAD_NATIVE_ID
372+
same_thread = PyThread_get_thread_native_id() == ts.tstate_->native_thread_id;
373+
# endif
374+
if (!same_thread) {
375+
pybind11_fail("subinterpreter_scoped_activate: a subinterpreter_thread_state must be "
376+
"activated on the same OS thread that constructed it!");
377+
}
378+
}
379+
#endif
380+
381+
tstate_ = ts.tstate_;
382+
borrowed_ = true;
383+
384+
// make the interpreter active and acquire the GIL
385+
old_tstate_ = PyThreadState_Swap(tstate_);
386+
387+
// save this in internals for scoped_gil calls (see also: PR #5870)
388+
detail::get_internals().tstate = tstate_;
389+
}
390+
270391
inline subinterpreter_scoped_activate::~subinterpreter_scoped_activate() {
271392
if (simple_gil_) {
272393
// We were on this interpreter already, so just make sure the GIL goes back as it was
@@ -279,13 +400,63 @@ inline subinterpreter_scoped_activate::~subinterpreter_scoped_activate() {
279400
}
280401
#endif
281402
detail::get_internals().tstate.reset();
282-
PyThreadState_Clear(tstate_);
283-
PyThreadState_DeleteCurrent();
403+
if (!borrowed_) {
404+
PyThreadState_Clear(tstate_);
405+
PyThreadState_DeleteCurrent();
406+
}
407+
// When borrowed_, tstate_ stays alive in its owning subinterpreter_thread_state for
408+
// reuse; the PyThreadState_Swap below merely detaches it from this thread.
284409
}
285410

286411
// Go back the previous interpreter (if any) and acquire THAT gil
287412
PyThreadState_Swap(old_tstate_);
288413
}
289414
}
290415

416+
// --- subinterpreter_thread_state --------------------------------------------------------------
417+
418+
inline subinterpreter_thread_state::subinterpreter_thread_state(subinterpreter const &si) {
419+
if (!si.istate_) {
420+
pybind11_fail("subinterpreter_thread_state: null subinterpreter");
421+
}
422+
istate_ = si.istate_;
423+
// PyThreadState_New does not require holding any GIL and does not make the new state current.
424+
tstate_ = PyThreadState_New(istate_);
425+
if (tstate_ == nullptr) {
426+
pybind11_fail("subinterpreter_thread_state: PyThreadState_New returned null");
427+
}
428+
}
429+
430+
inline subinterpreter_thread_state::~subinterpreter_thread_state() {
431+
if (tstate_ == nullptr) {
432+
return;
433+
}
434+
#if defined(PYBIND11_DETAILED_ERROR_MESSAGES)
435+
{
436+
// A PyThreadState must be cleared and deleted on the OS thread that created it.
437+
bool same_thread = true;
438+
# ifdef PY_HAVE_THREAD_NATIVE_ID
439+
same_thread = PyThread_get_thread_native_id() == tstate_->native_thread_id;
440+
# endif
441+
if (!same_thread) {
442+
pybind11_fail("~subinterpreter_thread_state: must be destroyed on the same OS thread "
443+
"that constructed it!");
444+
}
445+
}
446+
#endif
447+
// The PyThreadState must be made current to be cleared and deleted on the owning OS thread.
448+
// Swap it in (which acquires the subinterpreter's GIL), clear+delete, then restore whatever
449+
// was active before.
450+
PyThreadState *prev = PyThreadState_Swap(tstate_);
451+
PyThreadState_Clear(tstate_);
452+
PyThreadState_DeleteCurrent();
453+
// If `prev` is tstate_ itself, the user destroyed this object while it was active via a
454+
// subinterpreter_scoped_activate -- a contract violation, but be defensive: do NOT swap back
455+
// to a now-deleted pointer. Leaving the thread with no current interpreter is consistent
456+
// with the cached state having just been destroyed.
457+
if (prev != nullptr && prev != tstate_) {
458+
PyThreadState_Swap(prev);
459+
}
460+
}
461+
291462
PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE)

0 commit comments

Comments
 (0)