AsyncManualResetEvent
Overview
AsyncManualResetEvent is a pooled async version of ManualResetEvent that uses ValueTask to minimize memory allocations. It provides async signaling by reusing pooled IValueTaskSource instances. Unlike AsyncAutoResetEvent, it releases all waiting threads when signaled and remains signaled until explicitly reset.
Namespace
using CryptoHives.Foundation.Threading.Async.Pooled;
Class Declaration
public sealed class AsyncManualResetEvent
Key Features
- Broadcast signaling: Releases all waiting threads when set
- Persistent state: Remains signaled until explicitly reset
- ValueTask-based API: Low-allocation async operations
- Cancellation support: Full
CancellationTokensupport for queued waiters - Thread-safe: All operations are thread-safe
- Pooled task sources: Reuses
IValueTaskSourceinstances to minimize allocations
Constructor
public AsyncManualResetEvent(
bool set = false,
bool runContinuationAsynchronously = true,
IGetPooledManualResetValueTaskSource<bool>? pool = null)
Parameters
set: The initial state of the event (default:false).runContinuationAsynchronously: Controls whether continuations are forced to run asynchronously (default:true).pool: Optional custom source provider implementingIGetPooledManualResetValueTaskSource<bool>which supplies pooledPooledManualResetValueTaskSource<bool>instances (helps avoid allocations under contention). You may pass aValueTaskSourceObjectPool<bool>or a custom provider that implements the interface.
Properties
IsSet
public bool IsSet { get; }
Gets whether this event is currently in the signaled state.
RunContinuationAsynchronously
public bool RunContinuationAsynchronously { get; set; }
Controls how continuations are executed when the event is signaled:
true(default): Continuations are forced to the thread pool, preventing the signaling thread from being blocked.false: Continuations may execute synchronously on the signaling thread.
Performance Warning: When true, converting returned ValueTask instances to Task via AsTask() before signaling may force asynchronous completion paths and cause severe performance degradation (often 10x-100x slower).
Note: The implementation exposes an internal property
InternalWaiterInUseused by tests to detect whether the fast-path local waiter is currently held. This is not part of the public API surface for consumers.
Methods
WaitAsync
public ValueTask WaitAsync(CancellationToken cancellationToken = default)
Asynchronously waits for the event to be signaled.
Behavior:
- If the event is signaled the call returns a completed
ValueTask(synchronous, zero-allocation). - Otherwise the call enqueues a pooled waiter and returns a
ValueTaskthat completes whenSet()is called.
Parameters:
cancellationToken- Optional cancellation token. If the token is already cancelled, the method returns a canceledValueTask. When a waiter is queued, the token is registered and a later cancellation will complete that waiter with anOperationCanceledExceptionand remove it from the internal queue.
Returns: A ValueTask that completes when the event is signaled.
Throws:
OperationCanceledException- If the operation is canceled via the cancellation token while queuedInvalidOperationException- If a returnedValueTaskinstance is awaited more than once (ValueTask usage restriction)
Important: The returned ValueTask can only be awaited or converted to Task once. Additional attempts throw InvalidOperationException.
Examples:
// Direct await (recommended)
await _event.WaitAsync(ct);
// Single AsTask() with multiple awaits (allowed for Task)
Task t = _event.WaitAsync().AsTask();
await t;
await t; // OK - Task may be awaited multiple times
// BAD: Multiple ValueTask awaits
ValueTask vt = _event.WaitAsync();
await vt;
await vt; // Throws InvalidOperationException!
Set
public void Set()
Signals the event, releasing all waiting threads. The event remains in the signaled state until Reset() is called.
Behavior:
- All current waiters are released immediately.
- All future
WaitAsync()calls complete immediately untilReset()is called. - When
RunContinuationAsynchronouslyisfalse, continuations may run synchronously on the signaling thread.
Reset
public void Reset()
Resets the event to the non-signaled state.
Behavior:
- Future
WaitAsync()calls will wait untilSet()is called again.
Cancellation Notes
- Cancellation is supported for queued waiters. The token is only registered when the waiter is enqueued (fast-path avoids registration). When cancelled, the waiter completes with an
OperationCanceledExceptionand is removed from the internal queue. - Avoid passing cancellation tokens for hot-path uncontended waits to minimize allocation overhead from token registration. If a token is already canceled before calling
WaitAsync, the method returns a canceledValueTask(which may allocate aTaskwrapper on some frameworks).
Thread Safety
✓ Thread-safe. All public methods are thread-safe and can be called concurrently.
Performance Characteristics
- Set(): O(n) where n is the number of waiters (must signal all)
- Reset(): O(1) operation
- WaitAsync(): O(1) when signaled, otherwise enqueues waiter
- Memory: Allocates one
IValueTaskSourceper waiter (unlike Task-based implementations that share a singleTask). When a pool is provided, allocations are avoided when the pool can supply instances. The implementation also provides a local reusable waiter to avoid allocations for the first queued waiter.
Benchmark Results
The following benchmarks compare AsyncManualResetEvent against popular alternatives including Nito.AsyncEx.AsyncManualResetEvent and reference TaskCompletionSource-based implementations.
Set/Reset Cycle Benchmark
Measures the performance of rapid uncontended Set/Reset cycles. No surprises here except for Nito and Refimpl which expose some memory allocations, probably for a TaskCompletionSource instance.
| Description | Mean | Ratio | Allocated |
|---|---|---|---|
| SetReset · ManualResetEventSlim · Slim | 5.617 ns | 0.76 | - |
| SetReset · AsyncManualReset · Pooled | 7.433 ns | 1.00 | - |
| SetReset · AsyncManualReset · RefImpl | 9.681 ns | 1.30 | 96 B |
| SetReset · AsyncManualReset · Nito.AsyncEx | 17.034 ns | 2.29 | 96 B |
| SetReset · ManualResetEvent · Standard | 433.172 ns | 58.28 | - |
Set Then Wait Benchmark
Measures the pattern where the event is set before waiters arrive (synchronous completion path). Again no surprises here; all implementations complete synchronously but Nito and Refimpl require allocations.
| Description | Mean | Ratio | Allocated |
|---|---|---|---|
| SetThenWait · AsyncManualReset · RefImpl | 13.30 ns | 0.85 | 96 B |
| SetThenWait · AsyncManualReset · Pooled (ValueTask) | 15.72 ns | 1.00 | - |
| SetThenWait · AsyncManualReset · Pooled (AsTask) | 16.11 ns | 1.02 | - |
| SetThenWait · AsyncManualReset · Nito.AsyncEx | 23.46 ns | 1.49 | 96 B |
Wait Then Set Benchmark
Measures the pattern where waiters are queued before the event is signaled (asynchronous completion path). The pooled implementation shows strong performance here without allocations, especially when a cancellation token is provided. Nito and Refimpl again show higher allocation counts due to TaskCompletionSource usage. In the tests for non cancellable tokens, Nito is ahead of the pack because it can share a single TaskCompletionSource with all waiters, but falls back when real cancellable tokens are used.
| Description | Iterations | cancellationType | Mean | Ratio | Allocated |
|---|---|---|---|---|---|
| WaitThenSet · AsyncManualReset · RefImpl | 1 | None | 18.75 ns | 0.66 | 96 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 1 | None | 27.07 ns | 0.95 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 1 | None | 27.09 ns | 0.95 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 1 | None | 28.45 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 1 | None | 29.21 ns | 1.03 | 96 B |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 1 | None | 31.92 ns | 1.12 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 1 | None | 40.09 ns | 1.41 | 80 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 1 | None | 446.19 ns | 15.68 | 231 B |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 1 | NotCancelled | 43.02 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 1 | NotCancelled | 43.29 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 1 | NotCancelled | 43.43 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 1 | NotCancelled | 43.59 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 1 | NotCancelled | 62.72 ns | 1.46 | 80 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 1 | NotCancelled | 485.87 ns | 11.29 | 232 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 1 | NotCancelled | 618.54 ns | 14.38 | 808 B |
| WaitThenSet · AsyncManualReset · RefImpl | 2 | None | 22.69 ns | 0.39 | 96 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 2 | None | 36.47 ns | 0.63 | 96 B |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 2 | None | 57.67 ns | 0.99 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 2 | None | 57.98 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 2 | None | 58.04 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 2 | None | 58.19 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 2 | None | 88.81 ns | 1.53 | 160 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 2 | None | 747.36 ns | 12.88 | 343 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 2 | NotCancelled | 86.81 ns | 0.98 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 2 | NotCancelled | 88.41 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 2 | NotCancelled | 88.99 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 2 | NotCancelled | 89.42 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 2 | NotCancelled | 125.41 ns | 1.42 | 160 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 2 | NotCancelled | 857.65 ns | 9.70 | 344 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 2 | NotCancelled | 1,057.36 ns | 11.96 | 1488 B |
| WaitThenSet · AsyncManualReset · RefImpl | 10 | None | 62.36 ns | 0.22 | 96 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 10 | None | 106.82 ns | 0.37 | 96 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 10 | None | 264.02 ns | 0.92 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 10 | None | 266.07 ns | 0.93 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 10 | None | 285.41 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 10 | None | 286.49 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 10 | None | 408.89 ns | 1.43 | 800 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 10 | None | 1,970.00 ns | 6.88 | 1239 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 10 | NotCancelled | 423.00 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 10 | NotCancelled | 423.53 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 10 | NotCancelled | 424.75 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 10 | NotCancelled | 440.19 ns | 1.04 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 10 | NotCancelled | 621.65 ns | 1.47 | 800 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 10 | NotCancelled | 2,576.24 ns | 6.08 | 1240 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 10 | NotCancelled | 3,197.01 ns | 7.55 | 6464 B |
| WaitThenSet · AsyncManualReset · RefImpl | 100 | None | 532.00 ns | 0.19 | 96 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 100 | None | 944.55 ns | 0.34 | 96 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 100 | None | 2,384.95 ns | 0.86 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 100 | None | 2,390.09 ns | 0.86 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 100 | None | 2,777.40 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 100 | None | 2,793.88 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 100 | None | 3,989.42 ns | 1.44 | 8000 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 100 | None | 15,399.93 ns | 5.54 | 11320 B |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask) | 100 | NotCancelled | 4,081.37 ns | 0.97 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) | 100 | NotCancelled | 4,117.51 ns | 0.98 | - |
| WaitThenSet · AsyncManualReset · Pooled (ValueTask) | 100 | NotCancelled | 4,187.88 ns | 1.00 | - |
| WaitThenSet · AsyncManualReset · Pooled (SyncCont) | 100 | NotCancelled | 4,227.72 ns | 1.01 | - |
| WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) | 100 | NotCancelled | 6,076.40 ns | 1.45 | 8000 B |
| WaitThenSet · AsyncManualReset · Nito.AsyncEx | 100 | NotCancelled | 100,944.55 ns | 24.10 | 61612 B |
| WaitThenSet · AsyncManualReset · Pooled (AsTask) | 100 | NotCancelled | 265,666.71 ns | 63.44 | 11326 B |
Benchmark Analysis
Key Findings:
Per-Waiter Overhead: Unlike
Task-based implementations where all waiters share a singleTaskCompletionSource, each waiter in the pooled implementation requires its ownIValueTaskSource. This is an inherent trade-off of theValueTaskmodel, but other implementations only leverage this advantage when non cancellable tokens are used. With cancellable tokens, they also require per-waiter instances and fall back in perf and allocations.Pool Mitigation: The object pool effectively mitigates allocation overhead. The local waiter optimization ensures the first queued waiter incurs no allocation.
Synchronous Fast Path: When the event is already signaled,
WaitAsync()completes synchronously with zero allocations and without entering the lock.Set() Performance: For broadcasts to many waiters, the overhead of signaling each
IValueTaskSourceindividually may be higher than a single sharedTask. Consider the trade-off based on your use case.
When to Choose AsyncManualResetEvent:
- Initialization patterns where you wait for a one-time signal
- Scenarios with few concurrent waiters or where cancellable tokens are widely used
- Memory-sensitive applications where the pooling benefits outweigh per-waiter overhead
When to Consider Alternatives:
- Broadcasting to many concurrent waiters where a shared
Taskwould be more efficient - Scenarios where
ValueTaskrestrictions are inconvenient
Best Practices
✓ DO: Use for Initialization Signals
public class DataService
{
private readonly AsyncManualResetEvent _ready = new(false);
public async Task InitializeAsync()
{
await LoadDataAsync();
_ready.Set(); // Release all waiting callers
}
public async Task<Data> GetDataAsync(CancellationToken ct)
{
await _ready.WaitAsync(ct); // Wait until initialized
return GetData();
}
}
✓ DO: Use for Broadcasting
// Good: Release all waiting threads simultaneously
var start = new AsyncManualResetEvent(false);
var workers = Enumerable.Range(0, 10).Select(async i => {
await start.WaitAsync();
await DoWorkAsync(i);
}).ToArray();
start.Set();
await Task.WhenAll(workers);
✓ DO: Use a custom pool when high contention is expected
Provide a larger object pool to avoid temporary allocations when many waiters are queued simultaneously.
✓ DO: Always await ValueTask directly when possible
// Good: Direct await
await _event.WaitAsync();
// Good: Immediate AsTask()
await _event.WaitAsync().AsTask();
✗ DON'T: Use for One-at-a-Time Signaling
// Bad: Releases ALL waiters, not just one
var evt = new AsyncManualResetEvent(false);
var task1 = evt.WaitAsync();
var task2 = evt.WaitAsync();
evt.Set(); // Both tasks complete!
// Better: Use AsyncAutoResetEvent for one-at-a-time
✗ DON'T: Store AsTask() Before Signaling (when RunContinuationAsynchronously == true)
Storing the Task result of AsTask() before Set() forces an asynchronous completion path that can be much slower. Prefer awaiting the ValueTask directly.
✗ DON'T: Await ValueTask Multiple Times
// Bad: Throws InvalidOperationException
ValueTask vt = _event.WaitAsync();
await vt;
await vt; // Exception!
// Good: Convert to Task for multiple awaits
Task t = _event.WaitAsync().AsTask();
await t;
await t; // OK
Common Patterns
(See examples in this file and asyncautoresetevent.md for additional patterns.)
See Also
- Threading Package Overview
- AsyncAutoResetEvent - Auto-reset event variant
- AsyncReaderWriterLock - Async reader-writer lock
- AsyncLock - Async mutual exclusion lock
- AsyncCountdownEvent - Async countdown event
- AsyncBarrier - Async barrier synchronization primitive
- AsyncSemaphore - Async semaphore primitive
- Benchmarks - Benchmark description
© 2026 The Keepers of the CryptoHives