-
Notifications
You must be signed in to change notification settings - Fork 763
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
Add pyo3::once_cell::GILOnceCell #975
Conversation
a29702e
to
c0958f4
Compare
I managed to use This led to an interesting discovery. I think our original assessment about "recursive" class attributes, as discussed here, might be wrong: #905 (comment) The assumption there was that For "recursive" class attributes I think this shows they are unfortunately not sound, because I don't think we should be using the incomplete type object to create new instances. As a result I now disable recursive class attributes in this PR. cc @scalexm - as original implementor of class attributes, what do you think of this? |
4f95169
to
885b428
Compare
I'm sorry I cannot afford to give a detailed review now. I'll give it later. At a glance, this looks like a really nice refactoring for our internal implementation 👍
Sign... 😓 sorry for my incorrect comment. But could you please leave it as is for now? |
First of all, I’ve just read the current implementation (before this PR) of In particular This PR now does the correct thing, in that it takes a lock token as an argument to Now, according to what @davidhewitt found, it seems to me that a solution for class attributes needs to happen in two steps: for each type object, we’d have two distinct initialization procedures, each one encapsulated in some kind of
I know that @programmerjake expressed concern about changing the |
To clarify, I was thinking about something like this: struct LazyStaticType {
value: OnceCell<Box<ffi::PyTypeObject>>,
// Does not need to be atomic, an `UnsafeCell` would suffice in reality,
// but it's less convenient. Initially set to `false`.
init_dict: AtomicBool,
}
impl LazyStaticType {
...
pub fn get_or_init<T: PyClass>(&self, py: Python) -> &ffi::PyTypeObject {
let type_object = self.value
.get_or_init(py, || {
let mut type_object = Box::new(ffi::PyTypeObject_INIT);
// Recursive call inside `initialize_type_object` will loop indefinitely.
// We can also make it panic if wanted, as in the PR.
initialize_type_object::<T>(py, T::MODULE, type_object.as_mut()).unwrap_or_else(
|e| {
e.print(py);
panic!("An error occurred while initializing class {}", T::NAME)
},
);
type_object
})
.as_ref()
// Note: we're still holding the GIL lock here.
if self.init_dict.load(Ordering::Relaxed) {
// Re-entrant call: just return the type object even if `tp_dict` is
// not yet filled. Since `ffi::pyTypeObject` is not `Send`, there are
// also no risks of concurrent read/writes to the `tp_dict`.
return type_object;
}
self.init_dict.store(true, Ordering::Relaxed);
// Maybe add a RAII guard here for cleanup in case of panic.
init_tp_dict(type_object.tp_dict, py);
type_object
}
...
}
fn init_tp_dict(tp_dict: *mut PyObject, py: Python) {
// Only use FFI functions to manipulate the dict here.
// Note: we must not release the GIL while we’re making changes to the dict!
...
} ——————— I still believe a correct solution will be similar to what I proposed, either by managing to guarantee some synchronization invariants about the |
src/type_object.rs
Outdated
// | ||
// That could lead to all sorts of unsafety such as using incomplete type objects | ||
// to initialize class instances, so recursive initialization is prevented. | ||
let thread_not_already_initializing = self |
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.
I believe that, in conjunction with an appropriate RAII guard in case of panic (so that one thread which panicks can still let another thread perform the initialization), we could just use a boolean flag: we have an exclusive GIL so we are the only one which can recursively call the method.
I believe that a map keyed with thread IDs is only necessary for e.g. multi reader locks.
Also, without this check, a recursive call will just overflow the stack, so maybe we don’t need the check at all, unless you prefer it for debugging purposes.
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.
I think because of the above issue that f()
can release the GIL, we can't necessarily prevent multiple threads from attempting to initialize the type object. What the OnceCell
implementation above can guarantee is that all values returned from get_or_init
are the same reference - even if init
runs multiple times...
Trying to work around this by blocking all threads except the first with a lock is exactly how we get to the deadlock scenario in lazy_static
😢
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.
Also, without this check, a recursive call will just overflow the stack, so maybe we don’t need the check at all, unless you prefer it for debugging purposes.
I'm torn. I don't expect the check to cause much overhead (will usually be a HashSet
with just one element and never read.)
The stack overflow is a little harder to debug, but I guess it's fine :)
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.
Yes it’s not much a matter of overhead but rather of code simplification :)
also about the boolean flag, in that case we control the f
that is being called (initialize_type_object
) so we can assume it won’t release the GIL.
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.
We almost control the f
being called but I don't know if we can guarantee it won't release the GIL. User could write a custom implementation of just one part (e.g. class attribute initialization) which might easily break this assumption in a very subtle way...
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.
As long as we don’t give the user access to the Python token (which we don’t in class attributes, exactly because we were afraid to do so!), we’re safe.
I believe Python::acquire_gil().python().allow_threads(|| { ... })
will temporarily release the GIL, even in class attributes. So I think we don't have to give class attribute user the Python
token for stuff to happen!
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.
Ok. Thanks for the clarification. Then I understand that the discussion we had on #905 of whether or not to allow passing a Python
parameter to class attr methods was a bit pointless :)
Also the solution I proposed cannot work as is, as I need to make the assumption that the GIL is held during the whole tp_dict initialization.
Do you know whether we have some kind of guarantees about the tp_dict synchronization? For example, do we guarantee that whenever we read or write to it (either directly, or indirectly through FFI functions), we hold a GIL?
Also, I’m in favor of leaving your check for recursive initialization as is. We can revisit when we sort out this recursive class attribute thing.
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.
I'm feeling it difficult to understand why f()
matters here. Now f()
is this closure and we don't call any user-defined function in this closure, right?
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.
We initialize class attributes inside initialize_type_object
, and the initialization calls into user-defined code.
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.
We initialize class attributes inside initialize_type_object, and the initialization calls into user-defined code.
Ah, I understand, thanks.
But it looks the only candidate is #[classattr]
🤔 ...
I'm not sure exactly how to interpret the docs:
|
It gives users a way to avoid the |
I’m in favor of merging this PR as it is (modulo review from @kngwyu), as it fixes an important soundness issue in It’s ok to disable recursive class attributes for now, as it seems clear that a solution to this problem is out of scope for this PR. |
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.
I left a few comments.
In addition, can the name OnceCell
be confusing?
Thank you @davidhewitt and @scalexm for the discussion and pointing out the unsoundness! 👍
Agreed. I want to adopt @scalexm's 'two-stage' initialization strategy if it's possible, but in this PR it's more important to concentrate on how |
I chose the name But I would be very happy to use a different name if anyone can find one. A nice thing about calling it |
What I meant is about using the same name as the famous |
Not sure exactly what you mean by this? I can create a deadlock with Click to expand
That's fair. What about |
I like |
I think let's have |
(I was thinking maybe in the future |
Renamed to Let's create a follow-up issue to solve self-typed class attributes? |
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.
Mostly on doc comments.
Rebased on master and reworded the docs as suggested - thanks for the feedback! |
LGTM, thanks! |
Opened #982. |
Use some kind of two-stage initialization as described in PyO3#975, by being very cautious about when to allow the GIL to be released.
Use some kind of two-stage initialization as described in PyO3#975, by being very cautious about when to allow the GIL to be released.
This is a draft idea of how to get something similar to
lazy_static
which avoids deadlocks related to the GIL.It's based on
once_cell
API, but uses GIL to guarantee thread safety instead of its own locks.I was able to prove it works by replacing both our datetime's
static mut
as well asLazyHeapType
. I thinkLazyStaticType
should be able to be replaced too, but I had some complications around GIL safety which I think #970 will solve - so I'll wait for that to merge first.