Repository navigation
Creating an ungodly amount of sub interpreters in a short amount of time causes memory debug assertions. #123134
Description
Bug report
Bug description:
Hello. While working on a small joke program, I found a possible memory corruption issue (it could also be a threading issue?) when using the Python C API in a debug only build to quickly create, execute python code, and then destroy 463 sub interpreters. Before I post the code sample and the debug output I'm using a somewhat unique build environment for a Windows developer.
- clang++ 18.1.7 for x86_64-pc-windows-msvc
- Visual Studio Build Tools 2022 17.11.0
- CMake 3.30
- Python 3.12.5
- ninja 1.12.1
When running the code sample I've attached at the bottom of this post, I am unable to get the exact same output each time, though the traceback does fire in the same location (Due to the size of the traceback I've not attached it, as it's about 10 MB of text for each thread). Additionally, I sometimes have to run the executable several times to get the error to occur. Lastly, release builds do not exhibit any thread crashes or issues as the debug assertions never fire or execute.
The error output seems to also halt in some cases, either because of stack failure or some other issue I was unable to determine and seemed to possibly be outside the scope of the issue presented here. I have the entire error output from one execution where I was able to save the output.
Debug memory block at address p=00000267272D6030: API 'p'
16718206241729413120 bytes originally requested
The 7 pad bytes at p-7 are not all FORBIDDENBYTE (0xfd):
at p-7: 0xa0 *** OUCH
at p-6: 0x00 *** OUCH
at p-5: 0x00Hello, world! From Thread 31
Hello, world! From Thread 87
*** OUCH
at p-4: 0xfd
Hello, world! From Thread 43
Hello, world! From Thread 279
Generating thread state 314
at p-3: 0xfd
at p-2: 0xfd
Hello, world! From Thread 168
Generating thread state 315
at p-1: 0xfd
Because memory is corrupted at the start, the count of bytes requested
may be bogus, and checking the trailing pad bytes may segfault.
Generating thread state 316
Generating thread state 317
The 8 pad bytes at tail=E8030267272D6030 are
The output cut off after this, as the entire program crashed, taking my terminal with it 😅
You'll find the MRE code below. I've also added a minimal version of CMakeLists.txt file I used so anyone can recreate the build with the code below (Any warnings, or additional settings I have do not affect whether the error occurs or not). The code appears to breaks inside of _PyObject_DebugDumpAddress, based on what debugging I was able to do with WinDbg.
Important
std::jthread calls .join() on destruction, so all threads auto-join once the std::vector goes out of scope.
Additionally this code exhibits the same behavior regardless of whether it is a thread_local or declared within the lambda passed to std::thread
main.cxx
#include <vector>
#include <thread>
#include <cstdlib>
#include <print>
#include <Python.h>
namespace {
static thread_local inline PyThreadState* state = nullptr;
static inline constexpr auto MAX_STATES = 463;
static inline constexpr auto config = PyInterpreterConfig {
.use_main_obmalloc = 0,
.allow_fork = 0,
.allow_exec = 0,
.allow_threads = 0,
.allow_daemon_threads = 0,
.check_multi_interp_extensions = 1,
.gil = PyInterpreterConfig_OWN_GIL,
};
} /* nameless namespace */
void execute () {
std::vector<std::jthread> tasks { };
tasks.reserve(MAX_STATES);
for (auto count = 0zu; count < tasks.capacity(); count++) {
std::println("Generating thread state {}", count);
tasks.emplace_back([count] {
if (auto status = Py_NewInterpreterFromConfig(&state, &config); PyStatus_IsError(status)) {
std::println("Failed to initialize thread state {}", count);
return;
}
auto text = std::format(R"(print("Hello, world! From Thread {}"))", count);
auto globals = PyDict_New();
auto code = Py_CompileString(text.data(), __FILE__, Py_eval_input);
auto result = PyEval_EvalCode(code, globals, globals);
Py_DecRef(result);
Py_DecRef(code);
Py_DecRef(globals);
Py_EndInterpreter(state);
state = nullptr;
});
}
}
int main() {
PyConfig config {};
PyConfig_InitIsolatedConfig(&config);
if (auto status = Py_InitializeFromConfig(&config); PyStatus_IsError(status)) {
std::println("Failed to initialize with isolated config: {}", status.err_msg);
return EXIT_FAILURE;
}
PyConfig_Clear(&config);
execute();
Py_Finalize();
}CMakeLists.txt
cmake_minimum_required(VERSION 3.30)
project(463-interpreters LANGUAGES C CXX)
find_package(Python 3.12 REQUIRED COMPONENTS Development.Embed)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
add_executable(${PROJECT_NAME})
target_sources(${PROJECT_NAME} PRIVATE main.cxx)
target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_23)
target_precompile_headers(${PROJECT_NAME} PRIVATE <Python.h>)
target_link_libraries(${PROJECT_NAME} PRIVATE Python::Python)Command to build + run
$ cmake -Bbuild -S. -G "Ninja"
$ cmake --build build && .\build\463-interpreters.exeCPython versions tested on:
3.12
Operating systems tested on:
Windows
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
picnixz commentedon Aug 19, 2024
Can you reproduce this behaviour on Linux? or MacOS? Or what happens if you sleep a bit before spawning the sub-interpreters? I'm not an expert in threads but I'd say that interpreters are spawning too fast for the OS to follow up =/ Can you check whether the same occurs with HEAD instead of 3.12.5? (maybe there are some bugfixes that are not yet available)
ZeroIntensity commentedon Aug 19, 2024
At a glance, this could possibly be related to a memory allocation failure or something similar.
These lines need a
NULLcheck if an error occurred, otherwise there will be aNULLpointer dereference in the call toPy_DecRef.bruxisma commentedon Aug 19, 2024
Py_DecRefis the function version ofPy_XDECREF. Though I can see the confusion betweenPy_DecRefandPy_DECREF. If there was anullptr, we'd have a different set of errors, and the debug memory assertions wouldn't be complaining that all memory was not written with theFORBIDDENBYTEvalue.bruxisma commentedon Aug 19, 2024
Sleeping might fix it, but might also hide the issue. The goal here was just to run a bunch of interpreters, not sleep for the sake of working around possible synchronization issues
This is not how threads or operating systems work. There is a synchronization issue or an ABA problem with the debug memory assertions in the C API's internals
I will also work on giving this a go.
ZeroIntensity commentedon Aug 19, 2024
Oh! TIL. I still would suggest adding a
NULLcheck -- an error indicator could be set, which would break subsequent calls toPyErr_Occurred.bruxisma commentedon Aug 19, 2024
The error is outside of the Python C API as one might usually interact with it. i.e., only on subinterpreter initialization (i.e., creating a
PyThreadState) or on subinterpreter destruction (Py_EndInterpreter) does the error occur (typically the latter).PyErr_Occurredis not being called anywhere in the MRE, nor in the actual traceback for the failure.ZeroIntensity commentedon Aug 19, 2024
Right, but it could cause memory corruption somewhere, and cause the error somewhere else down the line. Regardless, I wasn't able to reproduce this on Linux, so this might be Windows-specific.
bruxisma commentedon Aug 20, 2024
@picnixz I can confirm the behavior still exists on Windows on
HEAD. Same errors can occurPyErr_Occurredonly performs a read on the current thread state object. If an error is present when it shouldn't be that still points to a memory corruption error occurring between the destruction and initialization of aPyThreadState.bruxisma commentedon Aug 20, 2024
Pleased to back up @ZeroIntensity and say that this appears to be a Windows specific issue. I was unable to replicate this memory assertion error on Linux.
However, when executing this code under TSan, I got the following data race error that seems to indicate the same kind of memory corruption COULD be taking place.
This only happened 3 times, and around the 42 thread generation step on average.
TBBle commentedon Aug 20, 2024
A quick look suggests this TSan failure is a local data-race to posixmodule.c: There appears to be no protection from multiple threads importing posixmodule simultaneously, and hence multiple threads trying to simultaneously qsort in-place one of the three static tables.
This module is used on Windows, but I'm not sure if any of the macros enabling those
qsortcalls (HAVE_FPATHCONF,HAVE_PATHCONF,HAVE_CONFSTR, orHAVE_SYSCONF) are enabled in the Windows build. My guess would be no...I don't know if overlapping qsorts on the same array will actually corrupt memory, but I doubt there's any guarantee it won't. (
qsort_rdoesn't help, that makes the comparison function thread-safe, not the array being sorted, according to the manpage.)So probably a separate issue, and a more-focused/reliable MRE could be made for it.
67 remaining items
ZeroIntensity commentedon Oct 9, 2024
I'm going to focus on the allocator issue for the time being, there's a lot of uncharted water in this, I wouldn't be surprised if like, 4 new issues are needed 😅
(If you would like to author PRs for docs change and whatnot, that would be great! You've clearly put much more time into this issue than I have.)
Ok, regarding the allocation issue, I'm worried about breaking code here, because theoretically someone could be relying on all threads being affected by modifying the
Rawdomain. Speculatively, we could modify what allocatorPyMem_Raw*reaches for depending on the current thread state -- for example, if it'sNULL, then it's not safe to try and call whatever Python has registered, and should just try and go formalloc. But even that could break code (think if someone allocates something without the GIL, such as in aPy_BEGIN_ALLOW_THREADSblock, and then tries to free that memory after the GIL has been re-acquired), and wouldn't play well with debuggers.ZeroIntensity commentedon Oct 13, 2024
I guess a fix could be to try and make the uses of
_PyMem_SetDefaultAllocatorthread-local somehow, but that's going to be a PITA.TBBle commentedon Oct 13, 2024
I couldn't think of any way to make
_PyMem_SetDefaultAllocatorgenerally useable; I suspect our best bet would be to make it assert that it's only called at safe times: Before/during initialisation and finalisation of the main interpreter. And we can also note thatPyMem_Raw*is not threadsafe during those times.Although that's both pretty messy, somewhat complicated, and may break existing non-crashing code.
There's probably a similar reproduction case (in release builds only) even without the
_PyMem_SetDefaultAllocatorissue:(UNTESTED)
This is currently valid: As I recall the only valid place to call
PyMem_SetupDebugHooks()(orPyMem_SetAllocator()when not a trivial wrapper for existing allocator) is betweenPy_PreInitializeandPy_Initialize.However, this will reproduce the same bug, as
PyMem_SetupDebugHooks()will change the allocate\free function pointer and a block allocated under the normal allocator will be free'd by the debug allocator.So we can possibly fix both holes by noting that
PyMem_Raw*is not thread-safe before the main interpreter'sPy_InitializeFromConfig, and must not be called before any use ofPyMem_SetAllocatorwhich does not meet the stronger version ofPyMem_SetAllocator's contract I proposed in the second paragraph of #123134 (comment):We would also need to extend the case of "Python has finish initializing" to also include "or PyMem_Raw* APIs have been called".
PyMem_SetupDebugHooks()does not currently meet this requirement, so it must not be called afterPyMem_RawMallochas been called, making this repro case invalid.A stronger change that closes this second hole would be to deprecate-remove
PyMem_SetupDebugHooks()in favour of the existingPyPreConfigparameter (like we did forPy_SetProgramName), plus a public API that can wrap a given allocator with debug hooks and the inverse unwrapping API, which could also be used to fix the small race condition in_PyMem_SetDefaultAllocator, i.e. the "new wrinkle".picrin commentedon Oct 13, 2024
Speaking as a user here: I've given up on the
subinterpreter API-- for my specific use case, which involves spawning ~8 python threads for parallel processing of a bioinformatics workload (the solution is embedded in Go) -- subinterpreters weren't ready at all.Obviously these were unchartered waters, but my reading of the subinterpreter API was that this was ready for multithreaded use. It's perhaps worth updating the documentation:
ZeroIntensity commentedon Oct 13, 2024
My idea was to make internal uses of
_PyMem_SetDefaultAllocatoronly affect the current thread, so functions that need to use the raw allocator wouldn't be breaking thread safety. But if there are issues withPyMem_SetupDebugHooksas well, then that wouldn't work. I was able to confirm your reproducer withPyMem_SetupDebugHooks.A deprecation in favor of a config option is probably better, and then I guess updating thread safety notes about
PyMem_Raw*is the best way to go.It is ready, in the sense that we have all the necessary subinterpreter isolation ready, but my understanding of the problem is that we sort of don't have public APIs to really take advantage of it yet. I think my solution with manually attaching a thread state for the main interpreter and then switching to the subinterpreter was the valid way to do it--it's just not very pretty.
Specifically, which parts of subinterpreters "did not work", and what needs to be documented better? As far as I can see, most of the people that know about subinterpreters are the ones working on them, so it would be good to get some feedback from users on what needs to be made clearer.
picrin commentedon Oct 13, 2024
I posted a C-only version of my pthreads code earlier on in this thread, but it must have been deleted (the thread got quite unwieldy so maybe somebody tried to clean it up and deleted that post). I wrote the code using the C API documentation as a starting point I believe @tbbie was commenting on it.
My personal question would have been: is that code correct?
I think having a pthreads template (which is effectively a multithreading standard for C) with works.
I've tried to dig out that code and it's something like this (the idea is run a tight loop from each subinterpreter to test for potential segfaults, etc...). I believe that in this particular instance I was able to call Py_Finalise (I wasn't able to call that in Cgo without segfault):
ZeroIntensity commentedon Oct 13, 2024
At a glance, the bug there is calling
Py_NewInterpreterFromConfigwithout the GIL. See what I did earlier in the thread withPyThreadState_Newto get around that.picrin commentedon Oct 13, 2024
As I said earlier, I've essentially moved on from this at this stage, and I'm using OS-level processes to manage the interaction between my Go and Python code, and it's been 2x faster than working with subinterpreters and much easier to work with. Personally I'm happy to leave it at that.
But again, speaking from the user perspective, I think having a working example in the documentation would have been helpful at the time (and by this I mean a full example: starting the threads, doing calculation and cleaning up, holding the GIL correctly, etc...). And I think the example should use the most standard multithreading library, like pthreads. I realise that the example will be quite long, and perhaps it will need to be linked from the documentation, but I think as it stands, it's essentially impossible to just read the documentation and come up with a correct example of multithreaded subinterpreter code.
TBBle commentedon Oct 14, 2024
GitHub folds the middle of long issues, clicking this link should show it.
What appears to be the necessary dance for holding the GIL to create an interpreter on the new thread is shown in #123134 (comment), everything from
auto tstate = PyThreadState_New(interp);toPyThreadState_Delete(tstate);. The first three lines are what the threading module'sThreadHandle_Startandthread_rundo, the rest of the stuff afterPy_NewInterpreterFromConfigis basically to clean up the temporary thread state becausePy_NewInterpreterFromConfighas created a new one for our separate-GIL interpreter. We've approximately open-codedPyGILState_Ensurefor an explicit interpreter (the default is the main interpreter anyway) and andPyGILState_Releasefor an explicit thread state, but without a bunch of safety rails for nested calls.So I believe something like this would work, but is not supported:
I've just realised
Py_EndInterpreterprobably doesn't clean up the thread state, we probably should end with something like this, butPyThreadState_Cleardepends on holding the GIL, so does that need to be done beforePy_EndInterpreter? (Or per below, maybe that cleanup is done byPy_EndInterpreteralready...)Exploring more:
As it happens, the new module for exposing interpreter creation to Python drops the GIL before calling
Py_NewInterpreterFromConfigso maybe holding the GIL or even having an active thread state is not necessary forPy_NewInterpreterFromConfigafter all? This code-flow also throws away the new interpreter's thread state (except in a unit test, which is actually quite close to our use-case here), and then creates a new thread state on-demand for executing code.Anyway, as I just linked, there's a smaller and safer C API prefixed with
_PyXI_which wraps up a bunch of this stuff but with a more-structured approach (and not public, I think; hopefully it will become so in future), and that's then exposed to Python code by the in-tree_interpretersmodule, and that's wrapped by theinterpreters-PEP-734module on pypi. In this new API,_PyXI_Enterand_PyXI_Exitare roughly analogous toPyGILState_EnsureandPyGILState_Releasebut for interpreters, and simplify a bunch of this stuff.I also note that
_PyXI_EndInterpreteralso does not clear up a temporary thread state used forPy_EndInterpreter(nor does the open-coded version in the unit tests), so maybePy_EndInterpreterdoes already clear up the thread state for the interpreter, and I just didn't notice the code to do that.ZeroIntensity commentedon Oct 14, 2024
Yes,
_PyXI*are Eric's magic functions that he's using to develop PEP-734, I think. We can't use them yet :)It might be a bug. It's all private, so I doubt it's had the kind of stress testing like what we're doing here. Crossinterpreter stuff is all undocumented at the moment. If you read the discussion in my PR about the GIL and subinterpreters, the behavior that you see right now isn't necessarily intended, because it's untested and support is explicitly disallowed.
TBBle commentedon Oct 14, 2024
I was only noting that if one puts up the code we got working here side-by-side with the implementation of
PyGILState_EnsureandPyGILState_Release(and with the threading module) one can see that they're all doing the same operations, as a reassurance that we've ended up with the right way of getting a GIL established in a new thread and cleaning up afterwards. That's not really specific to sub-interpreters, it just happens that we create a sub-interpreter between the two calls, and then switch back to the thread state we had before that happened.I'm aware that
PyGILState_*is not supported in combination with sub-interpreters. The reason I described_PyXI_Enteras "PyGILState_Ensurefor interpreters" is because it does exactly the same thing asPyGILState_Ensureexcept it takes an interpreter state as an argument, whilePyGILState_Ensureuses the runtime's "gilstate.autoInterpreterState" pointer, which is always the main interpreter.To the best of my knowledge, at no point did taking the GIL or not before
Py_NewInterpreterFromConfigaffect the results of any repro case we've had here. So the stress testing we've done indicates that it's not required, even though the docs ofPy_NewInterpreterFromConfigstate that it is. (They also at one point say it's not required, but the overall vibe seems to be that it is formally required, so that's probably an oversight or an abstraction-break.)That's also what I understood from reading the function, since before the point where it drops the GIL internally anyway, it does nothing but GIL-unneeded actions as far as I recall. The problems we saw inside
Py_NewInterpreterFromConfigin 3.12 occur later in the function, when the new GIL has already been established (or the old GIL retaken), and are not related to the act of interpreter creation itself.Anyway, it may become technically required in the future, and an API that is formally more restricted than its implementation is the correct way 'round IMHO.
I also don't want future discussions of this issue to get bogged down in unrelated API discussions, so being formally correct in repro cases is desirable.
ZeroIntensity commentedon Oct 30, 2024
Ok, @TBBle, I think it's time to get some PRs down. I don't know if there's anything that can be done about the
PyMem_SetupDebugHooksproblem, but we can fix the issues with_PyMem_SetDefaultAllocatorinternally by making it only affect the current thread, or possibly switch the internal uses that need the true systemmallocover to_PyMem_RawMalloc, as that can't be hooked. Would you like to deal with updating the documentation for the allocator contract?ZeroIntensity commentedon Feb 19, 2025
I'm pretty sure that #130287 will fix this.