From 6d1750ef8113648f63623e2c9b93d745c3bf77f6 Mon Sep 17 00:00:00 2001 From: Thomas Wouters Date: Mon, 27 Oct 2025 14:34:49 +0100 Subject: [PATCH 1/7] Add support for free-threaded Python (PEP 703). The significant change here is the use of thread local instead of a volatile global for the switching_thread_state global (which is otherwise protected by the GIL). There's some overhead to using a thread local, so only do this in the free-threaded build. The only other two bits of shared mutable data are `G_TOTAL_MAIN_GREENLETS and ThreadState::clocks_used_during_gc. Modify the latter to use a std::atomic with relaxed memory order, which should be good enough, and performance probably matters for those updates. For G_MAIN_TOTAL_GREENLETS, switch to a std::atomic without changing the inc/dec operations (which means they use sequential consistency), because they're rare enough that performance doesn't really matter. Also mark the main extension modules and the two test extensions as supporting free-threading (without switching to multi-phase init). The GIL will still temporarily be enabled during module import, but that probably won't matter (modules are usually imported before starting threads). If it does, switching to multi-phase init is always an option. The existing test suite cover threads extensively enough that no extra tests are necessary. There is an intermittent failure (<0.2% of runs) that shows up when running the testsuite in a tight loop, but this happens in regular Python builds (and before 3.14) too. ThreadSanitizer can't be used on greenlet, from what I can tell because of how it gets confused by the stack switching. This is the case for GILful Python builds as well. --- src/greenlet/PyModule.cpp | 8 ++--- src/greenlet/TMainGreenlet.cpp | 13 ++++++-- src/greenlet/TThreadState.hpp | 39 +++++++++++++++++++--- src/greenlet/greenlet.cpp | 3 ++ src/greenlet/greenlet_slp_switch.hpp | 4 +++ src/greenlet/tests/_test_extension.c | 3 ++ src/greenlet/tests/_test_extension_cpp.cpp | 3 ++ 7 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/greenlet/PyModule.cpp b/src/greenlet/PyModule.cpp index 6adcb5c3..1a320389 100644 --- a/src/greenlet/PyModule.cpp +++ b/src/greenlet/PyModule.cpp @@ -144,7 +144,7 @@ PyDoc_STRVAR(mod_get_clocks_used_doing_optional_cleanup_doc, static PyObject* mod_get_clocks_used_doing_optional_cleanup(PyObject* UNUSED(module)) { - std::clock_t& clocks = ThreadState::clocks_used_doing_gc(); + std::clock_t clocks = ThreadState::clocks_used_doing_gc(); if (clocks == std::clock_t(-1)) { Py_RETURN_NONE; @@ -168,15 +168,15 @@ mod_enable_optional_cleanup(PyObject* UNUSED(module), PyObject* flag) return nullptr; } - std::clock_t& clocks = ThreadState::clocks_used_doing_gc(); + std::clock_t clocks = ThreadState::clocks_used_doing_gc(); if (is_true) { // If we already have a value, we don't want to lose it. if (clocks == std::clock_t(-1)) { - clocks = 0; + ThreadState::set_clocks_used_doing_gc(0); } } else { - clocks = std::clock_t(-1); + ThreadState::set_clocks_used_doing_gc(std::clock_t(-1)); } Py_RETURN_NONE; } diff --git a/src/greenlet/TMainGreenlet.cpp b/src/greenlet/TMainGreenlet.cpp index a2a9cfe4..ee014812 100644 --- a/src/greenlet/TMainGreenlet.cpp +++ b/src/greenlet/TMainGreenlet.cpp @@ -14,11 +14,18 @@ #include "TGreenlet.hpp" +#ifdef Py_GIL_DISABLED +#include +#endif - -// Protected by the GIL. Incremented when we create a main greenlet, -// in a new thread, decremented when it is destroyed. +// Incremented when we create a main greenlet, in a new thread, decremented +// when it is destroyed. +#ifdef Py_GIL_DISABLED +static std::atomic G_TOTAL_MAIN_GREENLETS(0); +#else +// Protected by the GIL. static Py_ssize_t G_TOTAL_MAIN_GREENLETS; +#endif namespace greenlet { greenlet::PythonAllocator MainGreenlet::allocator; diff --git a/src/greenlet/TThreadState.hpp b/src/greenlet/TThreadState.hpp index e4e6f6cb..f8bbfe22 100644 --- a/src/greenlet/TThreadState.hpp +++ b/src/greenlet/TThreadState.hpp @@ -3,6 +3,7 @@ #include #include +#include #include "greenlet_internal.hpp" #include "greenlet_refs.hpp" @@ -118,7 +119,11 @@ class ThreadState { void* exception_state; #endif +#ifdef Py_GIL_DISABLED + static std::atomic _clocks_used_doing_gc; +#else static std::clock_t _clocks_used_doing_gc; +#endif static ImmortalString get_referrers_name; static PythonAllocator allocator; @@ -160,7 +165,7 @@ class ThreadState { static void init() { ThreadState::get_referrers_name = "get_referrers"; - ThreadState::_clocks_used_doing_gc = 0; + ThreadState::set_clocks_used_doing_gc(0); } ThreadState() @@ -349,9 +354,31 @@ class ThreadState { /** * Set to std::clock_t(-1) to disable. */ - inline static std::clock_t& clocks_used_doing_gc() + inline static std::clock_t clocks_used_doing_gc() { +#ifdef Py_GIL_DISABLED + return ThreadState::_clocks_used_doing_gc.load(std::memory_order_relaxed); +#else return ThreadState::_clocks_used_doing_gc; +#endif + } + + inline static void set_clocks_used_doing_gc(std::clock_t value) + { +#ifdef Py_GIL_DISABLED + ThreadState::_clocks_used_doing_gc.store(value, std::memory_order_relaxed); +#else + ThreadState::_clocks_used_doing_gc = value; +#endif + } + + inline static void add_clocks_used_doing_gc(std::clock_t value) + { +#ifdef Py_GIL_DISABLED + ThreadState::_clocks_used_doing_gc.fetch_add(value, std::memory_order_relaxed); +#else + ThreadState::_clocks_used_doing_gc += value; +#endif } ~ThreadState() @@ -390,7 +417,7 @@ class ThreadState { PyGreenlet* old_main_greenlet = this->main_greenlet.borrow(); Py_ssize_t cnt = this->main_greenlet.REFCNT(); this->main_greenlet.CLEAR(); - if (ThreadState::_clocks_used_doing_gc != std::clock_t(-1) + if (ThreadState::clocks_used_doing_gc() != std::clock_t(-1) && cnt == 2 && Py_REFCNT(old_main_greenlet) == 1) { // Highly likely that the reference is somewhere on // the stack, not reachable by GC. Verify. @@ -444,7 +471,7 @@ class ThreadState { } } std::clock_t end = std::clock(); - ThreadState::_clocks_used_doing_gc += (end - begin); + ThreadState::add_clocks_used_doing_gc(end - begin); } } } @@ -486,7 +513,11 @@ class ThreadState { ImmortalString ThreadState::get_referrers_name(nullptr); PythonAllocator ThreadState::allocator; +#ifdef Py_GIL_DISABLED +std::atomic ThreadState::_clocks_used_doing_gc(0); +#else std::clock_t ThreadState::_clocks_used_doing_gc(0); +#endif diff --git a/src/greenlet/greenlet.cpp b/src/greenlet/greenlet.cpp index e8d92a00..7722bd00 100644 --- a/src/greenlet/greenlet.cpp +++ b/src/greenlet/greenlet.cpp @@ -291,6 +291,9 @@ greenlet_internal_mod_init() noexcept // << "\n\tPyGreenlet : " << sizeof(PyGreenlet) // << endl; +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m.borrow(), Py_MOD_GIL_NOT_USED); +#endif return m.borrow(); // But really it's the main reference. } catch (const LockInitError& e) { diff --git a/src/greenlet/greenlet_slp_switch.hpp b/src/greenlet/greenlet_slp_switch.hpp index bd4b7ae1..bdffccae 100644 --- a/src/greenlet/greenlet_slp_switch.hpp +++ b/src/greenlet/greenlet_slp_switch.hpp @@ -36,7 +36,11 @@ // running this code, the thread isn't exiting. This also nets us a // 10-12% speed improvement. +#if Py_GIL_DISABLED +thread_local greenlet::Greenlet* switching_thread_state = nullptr; +#else static greenlet::Greenlet* volatile switching_thread_state = nullptr; +#endif extern "C" { diff --git a/src/greenlet/tests/_test_extension.c b/src/greenlet/tests/_test_extension.c index aa96aa40..612b7359 100644 --- a/src/greenlet/tests/_test_extension.c +++ b/src/greenlet/tests/_test_extension.c @@ -251,5 +251,8 @@ PyInit__test_extension(void) } PyGreenlet_Import(); +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED); +#endif return module; } diff --git a/src/greenlet/tests/_test_extension_cpp.cpp b/src/greenlet/tests/_test_extension_cpp.cpp index f4df2bf6..bc3f1783 100644 --- a/src/greenlet/tests/_test_extension_cpp.cpp +++ b/src/greenlet/tests/_test_extension_cpp.cpp @@ -221,6 +221,9 @@ PyInit__test_extension_cpp(void) p_test_exception_throw_nonstd = test_exception_throw_nonstd; p_test_exception_throw_std = test_exception_throw_std; p_test_exception_switch_recurse = test_exception_switch_recurse; +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED); +#endif return module; } From 226e09673034461b2fd4c65dd77e8fd398e99ca2 Mon Sep 17 00:00:00 2001 From: Thomas Wouters Date: Mon, 27 Oct 2025 15:50:21 +0100 Subject: [PATCH 2/7] Address reviewer comments. --- src/greenlet/PyModule.cpp | 2 +- tox.ini | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/greenlet/PyModule.cpp b/src/greenlet/PyModule.cpp index 1a320389..a999dc97 100644 --- a/src/greenlet/PyModule.cpp +++ b/src/greenlet/PyModule.cpp @@ -168,8 +168,8 @@ mod_enable_optional_cleanup(PyObject* UNUSED(module), PyObject* flag) return nullptr; } - std::clock_t clocks = ThreadState::clocks_used_doing_gc(); if (is_true) { + std::clock_t clocks = ThreadState::clocks_used_doing_gc(); // If we already have a value, we don't want to lose it. if (clocks == std::clock_t(-1)) { ThreadState::set_clocks_used_doing_gc(0); diff --git a/tox.ini b/tox.ini index 818ba057..7ec0bb00 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,11 @@ [tox] envlist = - py{37,38,39,310,311,312,313,314},py{310,311,312,313,314}-ns,docs + py{37,38,39,310,311,312,313,314},py{310,311,312,313,314}-ns,docs,py314t,tsan-314#,tsan-314t [testenv] commands = python -c 'import greenlet._greenlet as G; assert G.GREENLET_USE_STANDARD_THREADING' - python -m unittest discover -v greenlet.tests + python -m unittest discover greenlet.tests sphinx-build -b doctest -d docs/_build/doctrees-{envname} docs docs/_build/doctest-{envname} sitepackages = False extras = @@ -21,3 +21,13 @@ commands = sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest extras = docs + +[testenv:tsan-314t] +basepython = /usr/local/python-builds/tsan/bin/python3.14t +passenv = + TSAN_OPTIONS + +[testenv:tsan-314] +basepython = /usr/local/python-builds/default-tsan/bin/python3.14 +passenv = + TSAN_OPTIONS From 61fe8d1cf319b32bb68e62d5d452a6a440a40194 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Tue, 11 Nov 2025 21:42:31 -0500 Subject: [PATCH 3/7] Add no-GIL Python versions and 3.15 to CI --- .github/workflows/tests.yml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a7b99ad7..ee03260a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"] # Recall the macOS builds upload built wheels so all supported versions # need to run on mac. os: [ubuntu-latest, macos-latest] diff --git a/tox.ini b/tox.ini index 7ec0bb00..c5ae14f5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{37,38,39,310,311,312,313,314},py{310,311,312,313,314}-ns,docs,py314t,tsan-314#,tsan-314t + py{37,38,39,310,311,312,313,314,315},py{310,311,312,313,314,315}-ns,docs,py314t,tsan-314#,tsan-314t [testenv] commands = From 2ef0d954870d25680f80aaa3647ffaddf6f8de1a Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Mon, 10 Nov 2025 15:20:52 +0000 Subject: [PATCH 4/7] Explain how to generate `compile_commands.json` --- docs/development.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/development.rst b/docs/development.rst index 651924ab..2fdd5fdd 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -11,6 +11,17 @@ Github The primary development location for greenlet is GitHub: https://github.com/python-greenlet/greenlet/ +Compilation database +==================== + +To get ``compile_commands.json`` (used by ``clang-tidy``, for example), +install ``bear``, then run: + +.. code-block:: shell + + $ bear -- python setup.py + + Releases ======== From df501582aa5f7353a4868037f758ad0973f8c6b3 Mon Sep 17 00:00:00 2001 From: clin1234 Date: Sat, 22 Nov 2025 23:06:50 +0000 Subject: [PATCH 5/7] Add Python 3.15 macros for compatibility --- src/greenlet/greenlet_cpython_compat.hpp | 6 ++++++ src/greenlet/tests/__init__.py | 1 + 2 files changed, 7 insertions(+) diff --git a/src/greenlet/greenlet_cpython_compat.hpp b/src/greenlet/greenlet_cpython_compat.hpp index a3b3850e..f46d9777 100644 --- a/src/greenlet/greenlet_cpython_compat.hpp +++ b/src/greenlet/greenlet_cpython_compat.hpp @@ -67,6 +67,12 @@ Greenlet won't compile on anything older than Python 3.11 alpha 4 (see # define GREENLET_PY314 0 #endif +#if PY_VERSION_HEX >= 0x30F0000 +# define GREENLET_PY315 1 +#else +# define GREENLET_PY315 0 +#endif + #ifndef Py_SET_REFCNT /* Py_REFCNT and Py_SIZE macros are converted to functions https://bugs.python.org/issue39573 */ diff --git a/src/greenlet/tests/__init__.py b/src/greenlet/tests/__init__.py index 1861360b..9633679b 100644 --- a/src/greenlet/tests/__init__.py +++ b/src/greenlet/tests/__init__.py @@ -29,6 +29,7 @@ # XXX: First tested on 3.14a7. Revisit all uses of this on later versions to ensure they # are still valid. PY314 = sys.version_info[:2] >= (3, 14) +PY315 = sys.version_info[:2] >= (3, 15) WIN = sys.platform.startswith("win") RUNNING_ON_GITHUB_ACTIONS = os.environ.get('GITHUB_ACTIONS') From da683e527597b02db8d8b177963abfae0237a65a Mon Sep 17 00:00:00 2001 From: clin1234 Date: Sat, 22 Nov 2025 23:08:11 +0000 Subject: [PATCH 6/7] Use FRAME_OWNED_BY_INTERPRETER for 3.15 in Greenlet::expose_frames As of 3.15a2, FRAME_OWNED_BY_CSTACK has been removed --- src/greenlet/TGreenlet.cpp | 101 ++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src/greenlet/TGreenlet.cpp b/src/greenlet/TGreenlet.cpp index d12722ba..69f454a0 100644 --- a/src/greenlet/TGreenlet.cpp +++ b/src/greenlet/TGreenlet.cpp @@ -611,7 +611,104 @@ bool Greenlet::is_currently_running_in_some_thread() const return this->stack_state.active() && !this->python_state.top_frame(); } -#if GREENLET_PY312 +#if GREENLET_PY315 +void GREENLET_NOINLINE(Greenlet::expose_frames)() +{ + if (!this->python_state.top_frame()) { + return; + } + + _PyInterpreterFrame* last_complete_iframe = nullptr; + _PyInterpreterFrame* iframe = this->python_state.top_frame()->f_frame; + while (iframe) { + // We must make a copy before looking at the iframe contents, + // since iframe might point to a portion of the greenlet's C stack + // that was spilled when switching greenlets. + _PyInterpreterFrame iframe_copy; + this->stack_state.copy_from_stack(&iframe_copy, iframe, sizeof(*iframe)); + if (!_PyFrame_IsIncomplete(&iframe_copy)) { + // If the iframe were OWNED_BY_INTERPRETER then it would always be + // incomplete. Since it's not incomplete, it's not on the C stack + // and we can access it through the original `iframe` pointer + // directly. This is important since GetFrameObject might + // lazily _create_ the frame object and we don't want the + // interpreter to lose track of it. + assert(iframe_copy.owner != FRAME_OWNED_BY_INTERPRETER); + + // We really want to just write: + // PyFrameObject* frame = _PyFrame_GetFrameObject(iframe); + // but _PyFrame_GetFrameObject calls _PyFrame_MakeAndSetFrameObject + // which is not a visible symbol in libpython. The easiest + // way to get a public function to call it is using + // PyFrame_GetBack, which is defined as follows: + // assert(frame != NULL); + // assert(!_PyFrame_IsIncomplete(frame->f_frame)); + // PyFrameObject *back = frame->f_back; + // if (back == NULL) { + // _PyInterpreterFrame *prev = frame->f_frame->previous; + // prev = _PyFrame_GetFirstComplete(prev); + // if (prev) { + // back = _PyFrame_GetFrameObject(prev); + // } + // } + // return (PyFrameObject*)Py_XNewRef(back); + if (!iframe->frame_obj) { + PyFrameObject dummy_frame; + _PyInterpreterFrame dummy_iframe; + dummy_frame.f_back = nullptr; + dummy_frame.f_frame = &dummy_iframe; + // force the iframe to be considered complete without + // needing to check its code object: + dummy_iframe.owner = FRAME_OWNED_BY_GENERATOR; + dummy_iframe.previous = iframe; + assert(!_PyFrame_IsIncomplete(&dummy_iframe)); + // Drop the returned reference immediately; the iframe + // continues to hold a strong reference + Py_XDECREF(PyFrame_GetBack(&dummy_frame)); + assert(iframe->frame_obj); + } + + // This is a complete frame, so make the last one of those we saw + // point at it, bypassing any incomplete frames (which may have + // been on the C stack) in between the two. We're overwriting + // last_complete_iframe->previous and need that to be reversible, + // so we store the original previous ptr in the frame object + // (which we must have created on a previous iteration through + // this loop). The frame object has a bunch of storage that is + // only used when its iframe is OWNED_BY_FRAME_OBJECT, which only + // occurs when the frame object outlives the frame's execution, + // which can't have happened yet because the frame is currently + // executing as far as the interpreter is concerned. So, we can + // reuse it for our own purposes. + assert(iframe->owner == FRAME_OWNED_BY_THREAD + || iframe->owner == FRAME_OWNED_BY_GENERATOR); + if (last_complete_iframe) { + assert(last_complete_iframe->frame_obj); + memcpy(&last_complete_iframe->frame_obj->_f_frame_data[0], + &last_complete_iframe->previous, sizeof(void *)); + last_complete_iframe->previous = iframe; + } + last_complete_iframe = iframe; + } + // Frames that are OWNED_BY_FRAME_OBJECT are linked via the + // frame's f_back while all others are linked via the iframe's + // previous ptr. Since all the frames we traverse are running + // as far as the interpreter is concerned, we don't have to + // worry about the OWNED_BY_FRAME_OBJECT case. + iframe = iframe_copy.previous; + } + + // Give the outermost complete iframe a null previous pointer to + // account for any potential incomplete/C-stack iframes between it + // and the actual top-of-stack + if (last_complete_iframe) { + assert(last_complete_iframe->frame_obj); + memcpy(&last_complete_iframe->frame_obj->_f_frame_data[0], + &last_complete_iframe->previous, sizeof(void *)); + last_complete_iframe->previous = nullptr; + } +} +#elif GREENLET_PY312 void GREENLET_NOINLINE(Greenlet::expose_frames)() { if (!this->python_state.top_frame()) { @@ -633,7 +730,7 @@ void GREENLET_NOINLINE(Greenlet::expose_frames)() // directly. This is important since GetFrameObject might // lazily _create_ the frame object and we don't want the // interpreter to lose track of it. - assert(iframe_copy.owner != FRAME_OWNED_BY_CSTACK); + static_assert(iframe_copy.owner != FRAME_OWNED_BY_CSTACK); // We really want to just write: // PyFrameObject* frame = _PyFrame_GetFrameObject(iframe); From ab4d41865f56ddbf0227f8f5fc13a3168ba5f2a7 Mon Sep 17 00:00:00 2001 From: clin1234 Date: Sat, 22 Nov 2025 23:08:58 +0000 Subject: [PATCH 7/7] Call PyUnstable_Module_SetGIL for main and test extensions for no-GIL interpreters --- src/greenlet/greenlet.cpp | 4 ++++ src/greenlet/tests/_test_extension_cpp.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/greenlet/greenlet.cpp b/src/greenlet/greenlet.cpp index 7722bd00..ab58d716 100644 --- a/src/greenlet/greenlet.cpp +++ b/src/greenlet/greenlet.cpp @@ -213,6 +213,10 @@ greenlet_internal_mod_init() noexcept try { CreatedModule m(greenlet_module_def); +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m.borrow(), Py_MOD_GIL_NOT_USED); +#endif + Require(PyType_Ready(&PyGreenlet_Type)); Require(PyType_Ready(&PyGreenletUnswitchable_Type)); diff --git a/src/greenlet/tests/_test_extension_cpp.cpp b/src/greenlet/tests/_test_extension_cpp.cpp index bc3f1783..e6237a89 100644 --- a/src/greenlet/tests/_test_extension_cpp.cpp +++ b/src/greenlet/tests/_test_extension_cpp.cpp @@ -221,6 +221,10 @@ PyInit__test_extension_cpp(void) p_test_exception_throw_nonstd = test_exception_throw_nonstd; p_test_exception_throw_std = test_exception_throw_std; p_test_exception_switch_recurse = test_exception_switch_recurse; +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED); +#endif + #ifdef Py_GIL_DISABLED PyUnstable_Module_SetGIL(module, Py_MOD_GIL_NOT_USED); #endif