-
Notifications
You must be signed in to change notification settings - Fork 2.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add pybind11/gil_safe_call_once.h (to fix deadlocks in pybind11/numpy.h) #4877
Changes from 30 commits
38317c3
e7b8c4f
74ac0d9
109a165
88cec11
1ce2715
e7be9c2
f07b28b
36be645
7bc16a6
78f4e93
6d9441d
a864f21
6689b06
d965f29
398a42c
ab2cf8e
6d5bdd8
4c5dd1b
8633c5b
82f3efc
3ebd139
c33712d
dcf2b92
4557dce
704fe13
b2f87a8
fad1017
8453302
66bbc67
7cd9390
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
// Copyright (c) 2023 The pybind Community. | ||
|
||
#pragma once | ||
|
||
#include "detail/common.h" | ||
#include "gil.h" | ||
|
||
#include <cassert> | ||
#include <mutex> | ||
|
||
PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE) | ||
|
||
// Use the `gil_safe_call_once_and_store` class below instead of the naive | ||
// | ||
// static auto imported_obj = py::module_::import("module_name"); // BAD, DO NOT USE! | ||
// | ||
// which has two serious issues: | ||
// | ||
// 1. Py_DECREF() calls potentially after the Python interpreter was finalized already, and | ||
// 2. deadlocks in multi-threaded processes (because of missing lock ordering). | ||
// | ||
// The following alternative avoids both problems: | ||
// | ||
// PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store<py::object> storage; | ||
// auto &imported_obj = storage // Do NOT make this `static`! | ||
// .call_once_and_store_result([]() { | ||
// return py::module_::import("module_name"); | ||
// }) | ||
// .get_stored(); | ||
// | ||
// The parameter of `call_once_and_store_result()` must be callable. It can make | ||
// CPython API calls, and in particular, it can temporarily release the GIL. | ||
// | ||
// `T` can be any C++ type, it does not have to involve CPython API types. | ||
// | ||
// The behavior with regard to signals, e.g. `SIGINT` (`KeyboardInterrupt`), | ||
// is not ideal. If the main thread is the one to actually run the `Callable`, | ||
// then a `KeyboardInterrupt` will interrupt it if it is running normal Python | ||
// code. The situation is different if a non-main thread runs the | ||
// `Callable`, and then the main thread starts waiting for it to complete: | ||
// a `KeyboardInterrupt` will not interrupt the non-main thread, but it will | ||
// get processed only when it is the main thread's turn again and it is running | ||
// normal Python code. However, this will be unnoticeable for quick call-once | ||
// functions, which is usually the case. | ||
template <typename T> | ||
class gil_safe_call_once_and_store { | ||
public: | ||
// PRECONDITION: The GIL must be held when `call_once_and_store_result()` is called. | ||
template <typename Callable> | ||
gil_safe_call_once_and_store &call_once_and_store_result(Callable &&fn) { | ||
if (!is_initialized_) { // This read is guarded by the GIL. | ||
// Multiple threads may enter here, because CPython API calls in the | ||
// `fn()` call below may release and reacquire the GIL. | ||
gil_scoped_release gil_rel; // Needed to establish lock ordering. | ||
std::call_once(once_flag_, [&] { | ||
// Only one thread will ever enter here. | ||
gil_scoped_acquire gil_acq; | ||
::new (storage_) T(fn()); | ||
is_initialized_ = true; // This write is guarded by the GIL. | ||
}); | ||
// The C++ standard guarantees that all threads entering above will observe | ||
// `is_initialized_` as true here. | ||
} | ||
// Intentionally not returning `T &` to ensure the calling code is self-documenting. | ||
return *this; | ||
} | ||
|
||
// This must only be called after `call_once_and_store_result()` was called. | ||
// Not const for simplicity. (Could be made const if there is an unforeseen need.) | ||
T &get_stored() { | ||
assert(is_initialized_); | ||
PYBIND11_WARNING_PUSH | ||
#if !defined(__clang__) && defined(__GNUC__) && __GNUC__ < 5 | ||
// Needed for gcc 4.8.5 | ||
PYBIND11_WARNING_DISABLE_GCC("-Wstrict-aliasing") | ||
#endif | ||
return *reinterpret_cast<T *>(storage_); | ||
PYBIND11_WARNING_POP | ||
} | ||
|
||
constexpr gil_safe_call_once_and_store() = default; | ||
PYBIND11_DTOR_CONSTEXPR ~gil_safe_call_once_and_store() = default; | ||
|
||
private: | ||
alignas(T) char storage_[sizeof(T)] = {}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would make this a std::optional and avoid both the atomic flag and the storage here. You don't need the atomic since call_once is an inherent mutex that asserts ordering. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not the access inside the However, I think you have a point that because we assume that the GIL is held on function entry, the boolean doesn't need to be atomic: the GIL serializes access to it (both read and write). The code with the atomic variable would work even without any external synchronisation, but since we already have the GIL on entry, I agree that we should be able to use a non-atomic boolean. (And then the boolean + placement new could be replaced by std::optional, indeed.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And just to make this point clearly: the first, outer boolean check provides a fast path on which we don't interact with the GIL any further. We only do the release and reacquire dance on the (hopefully rare) slow path. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks Thomas! I'll definitely make the change to the simple
Except that pybind11 still supports C++11, and There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In that case leave it as bool + placement new :-) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, and also we do actually want the "constant-initializable, trivial destructible" behaviour of bool + placement new. Not only do we not want to destroy this object, but this also gives us cheaper static variables (no static guard, and no global destructructor list entry). |
||
std::once_flag once_flag_ = {}; | ||
bool is_initialized_ = false; | ||
// The `is_initialized_`-`storage_` pair is very similar to `std::optional`, | ||
// but the latter does not have the triviality properties of former, | ||
// therefore `std::optional` is not a viable alternative here. | ||
}; | ||
|
||
PYBIND11_NAMESPACE_END(PYBIND11_NAMESPACE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be a good idea to add a debug assertion that the GIL is actually held here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, thanks! I actually globally tested that change in isolation about a week ago already and it passed. Which makes me think: if the precondition is not met the failures are critical, therefore such code never actually makes it into the code base. However, the extra
assert()
is a great and easy way to guard against accidents when developing new code.