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

make GILOnceCell threadsafe #4512

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

davidhewitt
Copy link
Member

This changes GILOnceCell to be thread-safe. I do this by adding a std::sync::Once to the GILOnceCell, which blocks multiple writers from concurrently writing to the GIL (this is almost exactly how std::sync::OnceLock works).

This comes at making accesses to the GILOnceCell cost an atomic load. I do this on all builds for simplicity of our implementation, so there is a bit of slowdown on the non-free threaded builds, but I don't think this will be catastrophic. I also think it's better to make the performance characteristics the same for consistency.

I considered using a Python critical section here instead of the Once, but I came to realise that was not necessary for the short-lived lock around the write.

cc @ngoldbaum
(cc @colesbury)

@alex
Copy link
Contributor

alex commented Sep 2, 2024

A few questions:

  1. AFAICT, GILOnceCell no longer meaningfully relies on the GIL at all, and is more or less the same as OnceLock. Am I understanding correctly?
  2. I'm not sure I understand why the deadlock scenario described in https://pyo3.rs/v0.22.2/faq#im-experiencing-deadlocks-using-pyo3-with-lazy_static-or-once_cell is no longer possible.

@alex
Copy link
Contributor

alex commented Sep 2, 2024

Ok, on deeper review, I believe there's a difference between this behavior and OnceLock:

When get_or_init() is called, if the cell is not already initialized, this will call f() without the Once lock held, thus multiple threads may call f() concurrently, and only the first writer's value is retained. This is distinct from OnceLock, where f() is called with the lock held.

This actually addresses both of my questions. However, I think it makes the documentation a bit misleading: the deadlock prevention here has nothing to do with GILOnceCell's reliance on the GIL, and everything to do with the behavior I just described.

@davidhewitt
Copy link
Member Author

Yes exactly, this change preserves the existing runtime semantics while removing reliance on the GIL. Agreed the documentation is now out of date, will correct that. The name is also unfortunate now that there is no reliance on the GIL.

I think that multiple concurrent calls to f() is likely to be unhelpful, so I think we should also add PyOnceLock which guarantees only a single call to f(). I think I have an implementation which is a reasonably thin wrapper around std::sync::OnceLock (with a backport for MSRV).

A possible alternative is to disable GILOnceCell on the freethreaded build (like we have decided to do so with GILProtected), and recommend migration to PyOnceLock.

@davidhewitt
Copy link
Member Author

Actually, I started writing PyOnceLock and found that for all operations except for initialization I wanted to forward to OnceLock. I also imagined that it was possible there might be a mix of initialization under Python and outside of Python, so forcing users to initialize only under Python was potentially unhelpful (i.e. by making get_or_init take py: Python<'_>).

Instead, I wonder if an extension trait to add a helper method to OnceLock to do a dance with the GIL (or the GC) is sufficient: #4513

Copy link

@colesbury colesbury left a comment

Choose a reason for hiding this comment

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

In init, there are two lines:

        let value = f()?;
        let _ = self.set(py, value);

You can get behavior that's closer to the GIL build by wrapping the two lines in a Py_BEGIN/END_CRITICAL_SECTION. Basically, for some (but not all) implementations of f(), the GIL ensured that f() was only called once. A Py_BEGIN/END_CRITICAL_SECTION should ensure those same f's are called only once without the risk of introducing a deadlock.

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.

3 participants