AsyncAutoResetEvent
Overview
AsyncAutoResetEvent is a pooled async version of AutoResetEvent that uses ValueTask to minimize memory allocations in high-throughput scenarios. It provides allocation-free async signaling by reusing pooled IValueTaskSource instances.
Namespace
using CryptoHives.Foundation.Threading.Async.Pooled;
Class Declaration
public sealed class AsyncAutoResetEvent
Key Features
- Zero-allocation waits: Uses pooled
IValueTaskSource<bool>instances - Local waiter optimization: First queued waiter uses a pre-allocated local waiter to avoid allocations under low contention
- ValueTask-based API: Low-allocation async operations
- Cancellation support: Full
CancellationTokensupport for queued waiters. Allocation free registration for .NET versions >= 6.0. - Thread-safe: All operations are thread-safe
- FIFO queue: Waiters are released in first-in-first-out order
Known Issues
- When
RunContinuationAsynchronouslyistrue, storing theTaskfromAsTask()before signaling causes significant performance degradation due to forced asynchronous completion. Always await theValueTaskdirectly when possible.
Constructor
public AsyncAutoResetEvent(
bool initialState = false,
bool runContinuationAsynchronously = true,
IGetPooledManualResetValueTaskSource<bool>? pool = null)
Parameters
initialState: 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. A successful WaitAsync() consumes the signal (auto-reset semantics) and returns false after a wait consumes the signal.
RunContinuationAsynchronously
public bool RunContinuationAsynchronously { get; set; }
Controls how continuations are executed when the event is signaled:
true(default): Continuations queue to the thread pool, preventing the signaling thread from being blockedfalse: Continuations may execute synchronously on the signaling thread
Performance Warning: When true, storing AsTask() results before signaling causes severe performance degradation (10x-100x slower) because the underlying value task source must create a Task wrapper that forces asynchronous completion.
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 currently signaled the call returns a completed
ValueTask(synchronous, zero-allocation) and the event is immediately reset. - Otherwise the call enqueues a pooled waiter and returns a
ValueTaskthat completes when signaled.
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 anOperationCanceledException.
Returns: A ValueTask that completes when the event is signaled.
Throws:
OperationCanceledException- If the operation is canceled via the cancellation token while queued
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 one waiting waiter if any are queued. If no waiters are queued the event is set to a signaled state so that the next WaitAsync() completes synchronously.
SetAll
public void SetAll()
Signals all currently queued waiters. If no waiters are queued the event becomes signaled so that the next WaitAsync() completes synchronously. This method is useful when broadcasting a single notification to all waiters.
(Internal) Reset
The implementation provides an internal Reset() helper used in tests/benchmarks to clear the signaled flag. Consumers typically do not call a reset on an auto-reset event since each Set() releases a single waiter.
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. - Passing cancellation tokens for hot-path contended waits does not add allocation overhead for .NET versions >= 6.0. 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(1) operation
- SetAll(): O(n) for n waiters
- WaitAsync(): O(1) when signaled, otherwise enqueues waiter
- Memory: Zero allocations when waiters can be satisfied from the local waiter or the configured pool; allocations happen only when the pool is exhausted or when cancellation registrations/Task wrappers are required.
Benchmark Results
The benchmarks compare various AsyncAutoResetEvent implementations:
- PooledAsyncAutoResetEvent: The pooled implementation from this library
- RefImplAsyncAutoResetEvent: The reference
TaskCompletionSource-based implementation from Stephen Toub's blog, which does not support cancellation tokens - NitoAsyncAutoResetEvent: The implementation from Nito.AsyncEx library
- AutoResetEvent: The .NET built-in
AutoResetEventwhich lacks the async API
Set Operation Benchmark
Measures the performance of signaling the event when no waiters are queued. There is no contention and no allocation cost in all implementations.
| Description | Mean | Ratio | Allocated |
|---|---|---|---|
| Set · AsyncAutoReset · RefImpl | 4.129 ns | 0.33 | - |
| Set · AsyncAutoReset · Nito.AsyncEx | 4.242 ns | 0.34 | - |
| Set · AsyncAutoReset · Pooled | 12.376 ns | 1.00 | - |
| Set · AutoResetEvent · Standard | 220.138 ns | 17.79 | - |
Set Then Wait Benchmark
Measures the pattern where the event is set before a waiter arrives (synchronous completion path). For the pooled implementation this is the fast path and an immediate return from WaitAsync is possible. There is no contention and no allocation cost in all implementations.
| Description | Mean | Ratio | Allocated |
|---|---|---|---|
| SetThenWait · AsyncAutoReset · Pooled (ValueTask) | 10.03 ns | 1.00 | - |
| SetThenWait · AsyncAutoReset · Pooled (AsTask) | 11.03 ns | 1.10 | - |
| SetThenWait · AsyncAutoReset · Nito.AsyncEx | 14.70 ns | 1.47 | - |
| SetThenWait · AsyncAutoReset · RefImpl | 15.96 ns | 1.59 | - |
Wait Then Set Benchmark
Measures the pattern where a waiter is queued before the event is signaled (asynchronous completion path) with varying contention levels (Iterations). Each iteration level is also measured with a default and a cancellable token to show the overhead of cancellation support. Due to the different behavior of the pooled implementations with AsTask(), ValueTask and the RunContinuationAsynchronously flag, these variations are measured separately. The RefImpl and Nito implementations do not have the RunContinuationAsynchronously option and always complete asynchronously. The RefImpl implementation is sometimes the fastest despite a memory allocation per waiter for a TaskCompletionSource. Also it does not support cancellation tokens and is out of contest for cancellable waits. The Nito implementation uses a custom waiter type and allocates memory per waiter in any contested wait, beside being a lot slower than the pooled implementation. The pooled implementation starts to allocate memory only when the pool is exhausted (high contention), when the ValueTask is converted to Task by AsTask() or when cancellable tokens are used in legacy .NET versions prior to .NET 6 (due to registration overhead).
| Description | Iterations | cancellationType | Mean | Ratio | Allocated |
|---|---|---|---|---|---|
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 1 | None | 24.92 ns | 0.96 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 1 | None | 25.19 ns | 0.97 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 1 | None | 26.01 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 1 | None | 26.43 ns | 1.02 | - |
| WaitThenSet · AsyncAutoReset · RefImpl | 1 | None | 28.36 ns | 1.09 | 96 B |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 1 | None | 36.42 ns | 1.40 | 160 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 1 | None | 37.86 ns | 1.46 | 80 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 1 | None | 448.93 ns | 17.26 | 231 B |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 1 | NotCancelled | 39.20 ns | 0.99 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 1 | NotCancelled | 39.69 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 1 | NotCancelled | 40.45 ns | 1.02 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 1 | NotCancelled | 41.14 ns | 1.04 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 1 | NotCancelled | 62.83 ns | 1.58 | 80 B |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 1 | NotCancelled | 302.75 ns | 7.63 | 400 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 1 | NotCancelled | 495.08 ns | 12.47 | 232 B |
| WaitThenSet · AsyncAutoReset · RefImpl | 2 | None | 51.35 ns | 0.83 | 192 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 2 | None | 59.22 ns | 0.95 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 2 | None | 60.48 ns | 0.97 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 2 | None | 60.89 ns | 0.98 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 2 | None | 62.12 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 2 | None | 64.39 ns | 1.04 | 320 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 2 | None | 96.56 ns | 1.55 | 160 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 2 | None | 719.91 ns | 11.59 | 343 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 2 | NotCancelled | 88.08 ns | 0.97 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 2 | NotCancelled | 90.48 ns | 0.99 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 2 | NotCancelled | 91.23 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 2 | NotCancelled | 94.90 ns | 1.04 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 2 | NotCancelled | 149.77 ns | 1.64 | 160 B |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 2 | NotCancelled | 551.88 ns | 6.05 | 800 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 2 | NotCancelled | 822.69 ns | 9.02 | 344 B |
| WaitThenSet · AsyncAutoReset · RefImpl | 10 | None | 260.05 ns | 0.75 | 960 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 10 | None | 302.32 ns | 0.87 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 10 | None | 304.33 ns | 0.88 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 10 | None | 322.52 ns | 0.93 | - |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 10 | None | 327.22 ns | 0.94 | 1600 B |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 10 | None | 347.12 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 10 | None | 484.39 ns | 1.40 | 800 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 10 | None | 2,020.30 ns | 5.82 | 1233 B |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 10 | NotCancelled | 463.29 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 10 | NotCancelled | 467.38 ns | 1.01 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 10 | NotCancelled | 470.15 ns | 1.01 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 10 | NotCancelled | 472.73 ns | 1.02 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 10 | NotCancelled | 662.84 ns | 1.43 | 800 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 10 | NotCancelled | 2,510.53 ns | 5.42 | 1239 B |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 10 | NotCancelled | 2,649.41 ns | 5.72 | 4000 B |
| WaitThenSet · AsyncAutoReset · RefImpl | 100 | None | 2,596.71 ns | 0.80 | 9600 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 100 | None | 2,870.80 ns | 0.89 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 100 | None | 2,878.55 ns | 0.89 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 100 | None | 3,209.05 ns | 0.99 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 100 | None | 3,231.49 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 100 | None | 3,420.56 ns | 1.06 | 16000 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 100 | None | 4,400.77 ns | 1.36 | 8000 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 100 | None | 17,091.51 ns | 5.29 | 11317 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) | 100 | NotCancelled | 4,515.95 ns | 0.96 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) | 100 | NotCancelled | 4,573.71 ns | 0.97 | - |
| WaitThenSet · AsyncAutoReset · Pooled (SyncCont) | 100 | NotCancelled | 4,640.74 ns | 0.99 | - |
| WaitThenSet · AsyncAutoReset · Pooled (ValueTask) | 100 | NotCancelled | 4,700.58 ns | 1.00 | - |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) | 100 | NotCancelled | 6,421.38 ns | 1.37 | 8000 B |
| WaitThenSet · AsyncAutoReset · Nito.AsyncEx | 100 | NotCancelled | 26,491.03 ns | 5.64 | 40000 B |
| WaitThenSet · AsyncAutoReset · Pooled (AsTask) | 100 | NotCancelled | 239,519.31 ns | 50.96 | 11324 B |
Benchmark Analysis
Key Findings:
Synchronous Completion: When the event is already signaled,
WaitAsync()completes synchronously with zero allocations, matching or exceedingNito.AsyncExperformance.Pooled Waiter Advantage: The local waiter optimization ensures the first queued waiter incurs no allocation. Under typical producer-consumer patterns, this covers the common case.
Memory Efficiency: Compared to
TaskCompletionSource-based implementations, the pooled approach significantly reduces GC pressure in high-frequency signaling scenarios. For fined tuned approaches, the memory allocations can be zeroed out entirely.AsTask() Overhead: When
RunContinuationAsynchronously=true, callingAsTask()before signaling introduces significant overhead. Always awaitValueTaskdirectly when possible.
When to Choose AsyncAutoResetEvent:
- Producer-consumer patterns with frequent signaling
- Scenarios where memory allocation is a concern
- High-throughput event-driven architectures
Auto-Reset Behavior
After each Set() call:
- If waiters exist: Release one waiter, event returns to non-signaled state
- If no waiters: Event becomes signaled, next
WaitAsync()completes immediately and resets
var evt = new AsyncAutoResetEvent(false);
// No waiters
evt.Set(); // Event is now signaled
// Next wait completes immediately
await evt.WaitAsync(); // Completes synchronously, event resets
// Subsequent waits block
await evt.WaitAsync(); // Blocks until next Set()
Best Practices
✓ DO: Use for Producer-Consumer
public class WorkQueue<T>
{
private readonly ConcurrentQueue<T> _items = new();
private readonly AsyncAutoResetEvent _itemReady = new(false);
public void Enqueue(T item)
{
_items.Enqueue(item);
_itemReady.Set(); // Signal one consumer
}
public async Task<T> DequeueAsync(CancellationToken ct = default)
{
await _itemReady.WaitAsync(ct);
_items.TryDequeue(out var item);
return item;
}
}
✓ DO: Always await ValueTask directly when possible
// Good: Direct await
await _event.WaitAsync();
// Good: Immediate AsTask()
await _event.WaitAsync().AsTask();
✓ 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.
✗ 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
ValueTask vt = _event.WaitAsync();
await vt;
await vt; // throws InvalidOperationException
Common Patterns
(Examples omitted - see asyncmanualresetevent.md for manual-reset patterns and broadcasting examples.)
See Also
- Threading Package Overview
- AsyncManualResetEvent - Manual-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