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

sync: add OnceCell::wait_initialized method #7161

Open
wants to merge 20 commits into
base: master
Choose a base branch
from

Conversation

wyfo
Copy link
Contributor

@wyfo wyfo commented Feb 15, 2025

Motivation

Such a method was mentioned in #7151 as nice to have, so here it is.

Solution

The previous OnceCell implementation was using a semaphore, but if it was convenient to synchronize initialization, it is not suited to wait for it. That's why the synchronization has completely been rewritten to handle it.

There is now 3 possible states: unset, locked, and set. The cell is initialized to unset. When it is initialized, the cell is first locked, i.e. its state set to locked, then the value is written, and finally the state is set to set. As the other synchronization primitives, it uses a mutex-protected linked list of waiters, which is drained when initialization succeeds. If initialization fails, the state is rolled back to unset and one awaiting initializer is wakened up.

The new implementation takes one less word in memory, and it has been optimized for ZST case. In fact, another goal of this new implementation is to serve as base for the Event primitive of #7151, so basically a OnceCell<()>.
By the way, a proper tracing implementation has been added to OnceCell.

Such a method was mentioned in tokio-rs#7151 as nice to have, so here it is.
The previous `OnceCell` implementation was using a semaphore, but if it
was convenient to synchronize initialization, it is not suited to wait
for it. That's why the synchronization has completely been rewritten to
handle it.

There is now 3 possible states: *unset*, *locked*, and *set*. The cell
is initialized to *unset*. When it is initialized, the cell is first
locked, i.e. its state set to *locked*, then the value is written, and
finally the state is set to *set*. As the other synchronization
primitives, it uses a mutex-protected linked list of waiters, which is
drained when initialization succeeds. If initialization fails, the
state is rolled back to *unset* and one awaiting initializer is wakened
up.

The new implementation takes one less word in memory, and it has been
optimized for ZST case. In fact, another goal of this new
implementation is to serve as base for the `Event` primitive of tokio-rs#7151,
so basically a `OnceCell<()>`.
By the way, a proper tracing implementation has been added to
`OnceCell`.
@github-actions github-actions bot added the R-loom-sync Run loom sync tests on this PR label Feb 15, 2025
@Darksonn Darksonn added A-tokio Area: The main tokio crate M-sync Module: tokio/sync labels Feb 15, 2025
Copy link
Contributor

@Darksonn Darksonn left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. I agree that this approach is viable, but it significantly increases the complexity. Be prepared that this will take a while to review.

@wyfo wyfo marked this pull request as draft February 16, 2025 01:14
@wyfo wyfo marked this pull request as ready for review February 16, 2025 08:09
@wyfo
Copy link
Contributor Author

wyfo commented Feb 17, 2025

cc8c284 fixes a bug of a waiter not being able to be enqueued a second time.
In the commit, I've clarify the invariant of Waiter: there is a waker if and only if the node is queued, and I use it to remove the node from the queue when the future is ready, or to (re)queue it when pending.
I've also removed the first state check in poll_wait without the lock acquired. In fact, saving a lock here would prevent the maybe_queued flag to be updated, so dropping the future would have to lock, so there was no gain in the end.

There was no loom test catching the bug, but it found it while writing another one about happens-before between initialization attempts. I'm still working on it, so I will push it after. The test: 33754c2

Comment on lines 420 to 423
if node.waker.take().is_some() {
// SAFETY: The node is contained in the list.
unsafe { waiters.remove(NonNull::from(node)) };
}
Copy link
Contributor

Choose a reason for hiding this comment

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

You don't need this condition, you can just call remove unconditionally. It's okay to call remove if the node is not in any list afair.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in cac4ee0

} else {
// If there is no waker, it means the node is not queued,
// so it is pushed in the list.
if node.waker.replace(waker).is_none() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Here (and above) you run the destructor of the waker with the lock held.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cac4ee0

Comment on lines 393 to 400
/// Notify one initializer task.
fn notify_unlocked(&self) {
let mut waiters = self.waiters.lock();
if let Some(mut waiter) = waiters.drain_filter(|w| w.is_initializer).next() {
// SAFETY: we hold the lock, so we can access the waker.
let waker = unsafe { waiter.as_mut().waker.take() };
drop(waiters);
waker.unwrap().wake();
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm .. this could be slow if there are many waiting for completion. It's O(n) every time we fail, which could become O(n^2) with many failures.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed, but it only happens when get_or[_try]_init and wait_initialized are mixed together. The current cell doesn't provide wait_initialized, so existing workflows using it should not be impacted.
I would expect wait_initialized to be more used in combination with set (as in the Event primitive from #7151), so this kind of workflow would also not be impacted.

To address the issue of the "mixed" workflow, we could consider the current solution as an acceptable tradeoff, at least to begin with, for the reasons above (no regression). Otherwise, i see several solutions:

  • add a second linked list (behind the same lock), simple but would increase the size of the struct by 2 words, so one more than the current struct, which seems still acceptable
  • use a single linked list, but keep an additional node reference, let's name it "middle node", which would be the first "initializer" node;
    • initializer node are pushed to the front, setting the middle node if it is unset
    • wait_initialized node are pushed to the back
    • awaking a initializer removes the middle node from the list, the node on its left becoming the new middle node
    • awaking all nodes just drain the list
      this implementation is obviously more complex, but reduce the additional size to 1 word only (same as current)
  • another slightly more complex implementation would be to use the state as tagged pointer to store the "middle node" above, so there would no need to increase the struct size. I admit I like this solution, but that will be for another time.

By the way, I've noticed that my use of drain_filter is bad, because it breaks the fairness of the initialization locking, as it starts from the front, while I'm using push_front (as there is no push_back). If we keep a single linked list, I will then write a new method to pop back with a predicate.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm. If we were okay with breaking the fairness, we could push initializers to the front and waiters to the back. This would mean that we could just pop the first element of the list to find an initializer. But we probably should not break fairness. Can you think of any similar tricks that don't break fairness?

Also, it sounds like we're missing a test for the fairness.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we should preserve fairness. But I don't have any other tricks than the ones I described above, with a "middle node". Do you think I should try it? Or go with two lists? Or keep it like this for now, just fixing fairness?

Copy link
Contributor

Choose a reason for hiding this comment

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

To be honest ... I think we should just introduce a new separate type rather than try to extend OnceCell. By having one type that only provides set and wait, and another type that only provides get_or[_try]_init, this problem just disappears.

Or does your use-case actually need a single type with both features?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-tokio Area: The main tokio crate M-sync Module: tokio/sync R-loom-sync Run loom sync tests on this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants