Skip to content
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 PyMutex wrappers #4523

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

Add PyMutex wrappers #4523

wants to merge 16 commits into from

Conversation

ngoldbaum
Copy link
Contributor

Ref #4504 (comment) and #4265.

  • Fixes a spelling error in the PyMutex bindings (oops!)
  • Adds pyo3_ffi::PyMutex::new() to allow pyo3 (and users?) to construct PyMutex instances.
  • Adds a PyMutex rust wrapper based on the RAII guard pattern.

The test might not be doing anything interesting? I couldn't figure out how to re-acquire the GIL inside of allow_threads and the type errors I was seeing make me think it's not possible.

Copy link
Member

@davidhewitt davidhewitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much for implementing this! I have a bunch of initial thoughts, mostly we should understand what the invariant around moving the FFI mutex is before we worry too much about the fine details. (If it is not ok to move it when unlocked, we will need to do more complicated APIs which make use of pinning, I fear...)

}

/// Create a new mutex in an unlocked state ready for use.
pub fn new(value: T) -> Self {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be const

Suggested change
pub fn new(value: T) -> Self {
pub const fn new(value: T) -> Self {

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex) exposing an RAII interface.
#[derive(Debug)]
pub struct PyMutex<T> {
_mutex: crate::ffi::PyMutex,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This design would permit moving the mutex while it's unlocked. As far as the C API docs state, they just say the mutex must never be moved. @colesbury is it permissible to move the mutex while it's unlocked?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's the same restrictions as pthread_mutex_t. I think that's fine if Rust allows it for std::sync::mutex.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the C api documentation as "once you create/use this, you can never move it again", which is why I pushed for the PhantomPinned. If it really means "Instances of PyMutex should not be copied or moved while it is locked.", we can remove that.

Also, while I appreciate your clarification here, would you mind adding it to https://docs.python.org/3.14/c-api/init.html#c.PyMutex ? I would feel a lot better if I can point to official documentation rather than a comment on a PR.

use std::ops::Deref;

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex) exposing an RAII interface.
#[derive(Debug)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a derived Debug implementation here is unsound as it could allow reading while another thread is writing. We should check what the debug implementation for std::sync::Mutex prints.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Std's Mutex' Debug impl uses try_lock, which PyMutex doesn't have. We can have a Debug impl, it just can't print any of its contents.

@@ -0,0 +1,86 @@
use std::ops::Deref;

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex) exposing an RAII interface.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we should probably advise that the advantage of this is that it automatically detaches from python thread states if necessary.

src/types/mutex.rs Show resolved Hide resolved
@mejrs
Copy link
Member

mejrs commented Sep 4, 2024

It wouldn't be obvious to me, if I knew nothing, how or why this mutex works. The design is very unlike the mutex in std or parking_lot.

We should come up with a couple of good use cases and design the api accordingly.

@mejrs
Copy link
Member

mejrs commented Sep 4, 2024

(If it is not ok to move it when unlocked, we will need to do more complicated APIs which make use of pinning, I fear...)

This would be easy, we'd just need to change PyMutex::lock to take self: Pin<&mut Self>

@davidhewitt
Copy link
Member

(If it is not ok to move it when unlocked, we will need to do more complicated APIs which make use of pinning, I fear...)

This would be easy, we'd just need to change PyMutex::lock to take self: Pin<&mut Self>

Right, yes. I think however that probably we should change .lock() to take &self and wrap the FFI mutex in UnsafeCell?

That way we still cannot move the mutex while locked, and we also make the API more like std or parking_lot. The &self receiver also seems necessary so that multiple threads can actually interact with the mutex.

@mejrs
Copy link
Member

mejrs commented Sep 4, 2024

Pin would only be necessary if we'd want to enforce that the mutex is never moved once used.

Anyway, this implementation is not an interior mutability primitive. I think most users will expect that.

@ngoldbaum
Copy link
Contributor Author

It wouldn't be obvious to me, if I knew nothing, how or why this mutex works. The design is very unlike the mutex in std or parking_lot.

We should come up with a couple of good use cases and design the api accordingly.

This mutex has the nice feature that if the thread trying to acquire the mutex blocks and holds the GIL, it will release the GIL. The use case is if you need to lock something while still executing arbitrary python code that might trigger releasing or acquiring the GIL.

It's a good point we should wait to add this until we have more of a need for it. Let's see if it we have a need for it in the internal uses of GILOnceCell that may need to be updated.

@mejrs
Copy link
Member

mejrs commented Sep 4, 2024

If we want this to be "like std's Mutex, but it releases the gil while it blocks" that's fine with me, but this PR needs a lot of work in that case.

@mejrs
Copy link
Member

mejrs commented Sep 5, 2024

@colesbury I have a question about the behavior - if we lock it twice on the same thread, say we do

PyMutex mutex = {0};
PyMutex_Lock(&mutex);
PyMutex_Lock(&mutex);
PyMutex_Unlock(&mutex);
PyMutex_Unlock(&mutex);

will this deadlock? I'm guessing yes, because it doesn't say recursive locking is allowed, and it's what I would expect from pthread-like api. I'm asking because if this does not deadlock it is problematic for us (it lets you get multiple MutexGuards and thus aliasing mutable references).

@colesbury
Copy link

colesbury commented Sep 5, 2024 via email

#[derive(Debug)]
pub struct PyMutex<T> {
_mutex: crate::ffi::PyMutex,
data: T,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be UnsafeCell<T>

@davidhewitt
Copy link
Member

We could probably write a test that it deadlocks by spawning a thread and allowing that thread to deadlock, and can assert that thread doesn't run to completion after e.g. 1 second.

Comment on lines 5 to 18
pub struct PyMutex<T> {
_mutex: crate::ffi::PyMutex,
data: T,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub struct PyMutex<T> {
_mutex: crate::ffi::PyMutex,
data: T,
}
pub struct PyMutex<T> {
_mutex: UnsafeCell<crate::ffi::PyMutex>,
data: UnsafeCell<T>,
}
  • both cells are needed; one for lock / unlock, and one for data to be readable from interior mutability.

Comment on lines 13 to 14
_mutex: &'a mut crate::ffi::PyMutex,
data: &'a T,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_mutex: &'a mut crate::ffi::PyMutex,
data: &'a T,
mutex: &'a PyMutex<T>,

... and then can read from inside the mutex's UnsafeCell for the Deref and DerefMut implementations.

@ngoldbaum
Copy link
Contributor Author

ngoldbaum commented Sep 17, 2024

We could probably write a test that it deadlocks by spawning a thread and allowing that thread to deadlock, and can assert that thread doesn't run to completion after e.g. 1 second.

I'm having trouble writing this test because I run into errors like UnsafeCell<pyo3_ffi::PyMutex> cannot be shared between threads safely, so code that calls lock() simultaneously on two threads doesn't compile. It only works if you explicitly move the mutex between threads, as I do in the test.

Am I missing something?

@davidhewitt
Copy link
Member

Probably missing unsafe impl<T> Sync for PyMutex<T> {} ?

@mejrs
Copy link
Member

mejrs commented Sep 17, 2024

Probably missing unsafe impl<T> Sync for PyMutex<T> {} ?

This is incorrect, it must be unsafe impl<T: Send> Sync for PyMutex<T> {}

@ngoldbaum
Copy link
Contributor Author

Thanks for the hint! With the last push I can write a test that deadlocks:

    #[test]
    fn test_pymutex_deadlocks() {
        static MUTEX: OnceLock<PyMutex<()>> = OnceLock::new();
        static FINISHED: OnceLock<bool> = OnceLock::new();

        MUTEX.get_or_init(|| PyMutex::new(()));

        let _guard = MUTEX.get().unwrap().lock();

        std::thread::spawn(|| {
            MUTEX.get().unwrap().lock();
            FINISHED.get_or_init(|| true);
        })
        .join()
        .unwrap();
    }

but I have no idea how to write a rust test that safely terminates a deadlocked thread after a timeout. Can you point me to an example I can look at somewhere?

Copy link
Member

@mejrs mejrs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should get consensus on what we want this api to look like; do we want it to mirror std's mutex? (the reviews so far assume as much).

The Send and Sync (un)implementations are quite tricky to get right. Even std got this wrong initially. I think the implementations of this Mutex and MutexGuard should be the same as std's.

@@ -8,6 +8,15 @@ pub struct PyMutex {
pub(crate) _pin: PhantomPinned,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This not part of the PR but since colesbury's clarification I believe this is unnecessary, so it can be removed.

use std::ops::Deref;

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex) exposing an RAII interface.
#[derive(Debug)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Std's Mutex' Debug impl uses try_lock, which PyMutex doesn't have. We can have a Debug impl, it just can't print any of its contents.

/// RAII guard to handle releasing a PyMutex lock.
#[derive(Debug)]
pub struct PyMutexGuard<'a, T> {
mutex: &'a mut PyMutex<T>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mutex: &'a mut PyMutex<T>,
mutex: &'a PyMutex<T>,

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex), exposing an RAII guard interface similar to `std::sync::Mutex`.
#[derive(Debug)]
pub struct PyMutex<T> {
_mutex: UnsafeCell<crate::ffi::PyMutex>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_mutex: UnsafeCell<crate::ffi::PyMutex>,
inner: UnsafeCell<crate::ffi::PyMutex>,

To avoid a bunch of self.mutex_mutex or self.mutex.mutex (if the underscore were to be removed)

use std::cell::UnsafeCell;
use std::ops::{Deref, DerefMut};

/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex), exposing an RAII guard interface similar to `std::sync::Mutex`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go into depth about how it's different from std's Mutex as well.


/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex), exposing an RAII guard interface similar to `std::sync::Mutex`.
#[derive(Debug)]
pub struct PyMutex<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub struct PyMutex<T> {
pub struct PyMutex<T: ?Sized> {

We should be able to support unsized types, I think.

@mejrs
Copy link
Member

mejrs commented Sep 17, 2024

safely terminates a deadlocked thread after a timeout

It doesn't exist because there's no safe way to kill threads in general. This test should be in its own process (is that what integration tests are? I'm unsure).

@ngoldbaum
Copy link
Contributor Author

Thanks for the suggestions!

Can you elaborate about what API surface from std::sync::Mutex you'd like to see implemented? For example, what about poisoning?

This test should be in its own process (is that what integration tests are? I'm unsure).

Ah good point, if I write a small python wrapper I can do this using pytest.

Copy link

codspeed-hq bot commented Sep 19, 2024

CodSpeed Performance Report

Merging #4523 will not alter performance

Comparing ngoldbaum:pymutex-wrappers (3d8e8d8) with main (29c6f4b)

Summary

✅ 83 untouched benchmarks

@ngoldbaum ngoldbaum marked this pull request as ready for review September 20, 2024 18:26
@ngoldbaum ngoldbaum mentioned this pull request Sep 20, 2024
3 tasks
@ngoldbaum
Copy link
Contributor Author

While working on something else, I realized just now that rather than creating a new mutex.rs, maybe this should live in sync.rs along with the other synchronization primitives.

let first_thread_locked_once = AtomicBool::new(false);
let second_thread_locked_once = AtomicBool::new(false);
let finished = AtomicBool::new(false);
let (sender, receiver) = sync_channel::<bool>(0);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a std::sync::Barrier

@mejrs
Copy link
Member

mejrs commented Oct 1, 2024

Can you elaborate about what API surface from std::sync::Mutex you'd like to see implemented? For example, what about poisoning?

My view is that lock poisoning is useful for propagating panics across threads, so when writing my own code I would like to have it enabled. But I'm not sure what is best in general.


/// Wrapper for [`PyMutex`](https://docs.python.org/3/c-api/init.html#c.PyMutex), exposing an RAII guard interface.
///
/// Comapred with `std::sync::Mutex` or `parking_lot::Mutex`, this is a very
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comapred -> Compared

@ngoldbaum
Copy link
Contributor Author

@davidhewitt and I chatted about this today and he convinced me that because PyO3 automatically converts rust panics into Python exceptions, it's actually more important to deal with poisoning, since the default behavior is not to crash the process.

I'm going to take a look at extending this to use LockResult and PoisonError from the standard library, hopefully it won't be too bad.

@ngoldbaum
Copy link
Contributor Author

We also decided this doesn't need to block the 0.23 release, since the FFI wrappers also exist.

@davidhewitt davidhewitt added this to the 0.24 milestone Nov 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants