Skip to content

perf(event): avoid locking manual-reset state reads and ready waits - #356

Merged
tisonkun merged 6 commits into
apache:mainfrom
Kaileshwar16:perf/252-manual-reset-event
Oct 12, 2026
Merged

tisonkun merged 6 commits into
apache:mainfrom
Kaileshwar16:perf/252-manual-reset-event

Conversation

@Kaileshwar16

Copy link
Copy Markdown
Contributor

Summary

Let ManualResetEvent::is_set, try_wait, and already-set waits complete without taking
the waiter mutex. Keep the public API and existing waiter storage, with tests covering
registration races, rapid set/reset, cancellation, reentrant callbacks, and memory
publication.

Local benchmarks show single-thread state reads improving from 13.09 to 1.04 ns and
already-set waits from 15.03 to 5.83 ns. Replacing a pending waker costs about 28% more
because cloning outside the lock requires another lock acquisition. The benchmark report
includes methodology and tradeoffs.

Design Notes

State transitions and waiter registration remain serialized by the same mutex. Atomic
reads use Acquire and set publishes with Release. Released waiters retain their commitment
after reset.

Validation: all 597 tests passed, along with Clippy, formatting, documentation, event-
feature checks, and benchmark runs.

Related to #252; references #315. Waiter-storage replacement remains a separate
experiment.

Comment thread benchmarks/manual-reset-event.md Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We don't set a similar document for other benchmarks.

Typically, I'd prefer to make the benchmark suite self-described, or you may put necessary comments inline.

@Kaileshwar16

Copy link
Copy Markdown
Contributor Author

Removed the separate doc and added the useful notes as comments in the benchmarks. Thanks for the suggestion

@orthur2
orthur2 self-requested a review October 11, 2026 08:06

@orthur2 orthur2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks. The atomic fast path looks sound to me. I left a question on the waker slow path.

Comment thread asyncband/src/event/manual_reset.rs Outdated
// Clone only a changed waker, outside the lock, then recheck commitment:
// set/reset may run while preparing the replacement.
drop(waiters);
prepared_waker = Some(cx.waker().clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Moving the clone outside the lock only helps a waker whose clone calls back into this event. Executor wakers don’t do that (their clone is typically a refcount increment), but with the retry every waker change now takes the lock twice, about 6.0 → 9.8 ns locally(M5 macOS), and has to recheck set/reset after relocking.

Could we keep the atomic fast path and clone under the lock again via register_waker?

///
/// # Synchronization
///
/// An unset-to-set transition synchronizes with the waits it releases, with waits first polled

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small docs note: the new publication test relies on is_set() synchronizing with set, but this section only promises that for waits and try_wait. Could we either mention is_set here or drop that branch from the test?

@Kaileshwar16

Copy link
Copy Markdown
Contributor Author

@orthur2 Thanks for benchmarking this. I agree that the extra lock acquisition is a meaningful regression for waker replacement, especially since typical executor waker clones are cheap.

One concern is that #252 explicitly asks us to keep waker clone callbacks outside internal locks. The new waker_replacement_rechecks_commitment_after_set_and_reset test exercises a clone callback that re-enters the event, which would deadlock if we switched back to register_waker under the mutex.

The original implementation did clone under the lock, though, so I understand the trade-off. Would you prefer restoring that behavior and prioritizing the common executor case, or keeping the stronger reentrancy guarantee from #252?

I'll also update the synchronization docs to mention is_set().

@orthur2

orthur2 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

You’re right that #252 lists that requirement. I’d still restore the clone under the lock, as main does today. Waking and dropping the replaced waker would stay outside the lock, so only the clone moves.

I had an agent go through the waker implementations in tokio, async-task, futures, std’s Wake, glommio and embassy. In all of them clone only bumps a refcount or copies a pointer, except tokio’s tracing instrumentation under tokio_unstable. AutoResetEvent and the other primitives also clone under their locks, so the guarantee would only hold for this one type. Meanwhile the retry makes every waker change take the lock twice. The fast-path gains don’t depend on it, so this can be decided separately.

@orthur2

orthur2 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

You’re right that #252 lists that requirement. I’d still restore the clone under the lock, as main does today. Waking and dropping the replaced waker would stay outside the lock, so only the clone moves.

I had an agent go through the waker implementations in tokio, async-task, futures, std’s Wake, glommio and embassy. In all of them clone only bumps a refcount or copies a pointer, except tokio’s tracing instrumentation under tokio_unstable. AutoResetEvent and the other primitives also clone under their locks, so the guarantee would only hold for this one type. Meanwhile the retry makes every waker change take the lock twice. The fast-path gains don’t depend on it, so this can be decided separately.

@tisonkun since you wrote #252, could you confirm whether that requirement still holds?

@tisonkun

Copy link
Copy Markdown
Member

@tisonkun since you wrote #252, could you confirm whether that requirement still holds?

We later decide to assume waker's clone and wake as non-panic actions. See #342

Sorry not to update #252.

@orthur2 orthur2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, looks good to me now.

I reran the event benchmarks against main on an Apple M5 MacBook Pro (10-core CPU, macOS ), with --sample-size 65536 so the sub-nanosecond results stay above the timer resolution. Ranges are Divan medians across two runs.

Benchmark main This PR
replace_pending_waker 5.9–6.0 ns 6.0 ns
is_set, 1 thread 3.6–3.8 ns 0.36–0.38 ns
is_set, 8 threads 131–134 ns 0.56–0.61 ns
wait_already_set, 1 thread 4.9–5.1 ns 3.4 ns
wait_already_set, 8 threads 182–184 ns 4.1–4.9 ns

@tisonkun tisonkun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you!

I may consider the integration tests can be simplified a bit. But I don't look into that. If you have tokens left you may investigate it.

@tisonkun
tisonkun enabled auto-merge (squash) October 12, 2026 15:16
@tisonkun
tisonkun merged commit c6ade57 into apache:main Oct 12, 2026
9 checks passed
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