Table of Contents

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 CancellationToken support for queued waiters. Allocation free registration for .NET versions >= 6.0.
  • Timeout support: Direct WaitAsync(TimeSpan) overload — no Task conversion required. Allocates only one TimeProvider per contended timed wait; disposed automatically.
  • Thread-safe: All operations are thread-safe
  • FIFO queue: Waiters are released in first-in-first-out order

Known Issues

  • When RunContinuationAsynchronously is true, storing the Task from AsTask() before signaling causes significant performance degradation due to forced asynchronous completion. Always await the ValueTask directly 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 implementing IGetPooledManualResetValueTaskSource<bool> which supplies pooled PooledManualResetValueTaskSource<bool> instances (helps avoid allocations under contention). You may pass a ValueTaskSourceObjectPool<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 blocked
  • false: 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 InternalWaiterInUse used 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 ValueTask that completes when signaled.

Parameters:

  • cancellationToken - Optional cancellation token. If the token is already cancelled, the method returns a canceled ValueTask. When a waiter is queued, the token is registered and a later cancellation will complete that waiter with an OperationCanceledException.

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!

WaitAsync (timeout)

public ValueTask WaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default)

Asynchronously waits for the event to be signaled, or throws TimeoutException if the timeout elapses first.

Parameters:

  • timeout — The maximum time to wait. Pass Timeout.InfiniteTimeSpan to wait indefinitely (delegates to WaitAsync() without allocation).

Returns: A ValueTask that completes when the event is signaled.

Throws:

  • TimeoutException — If the timeout elapses before the event is signaled.
  • OperationCanceledException — If the operation is canceled via the cancellation token.
  • ArgumentOutOfRangeException — If timeout is negative and not equal to Timeout.InfiniteTimeSpan.

Allocation notes:

Scenario TimeProvider allocated?
Event already signaled No
Timeout.InfiniteTimeSpan No
TimeSpan.Zero and not signaled No (immediate exception)
Finite positive timeout Yes — one instance, disposed on await

Examples:

// Preferred: direct timeout await — no Task conversion
await _event.WaitAsync(TimeSpan.FromSeconds(5));

// Previously required — now unnecessary:
// await _event.WaitAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5));

// Infinite timeout delegates to WaitAsync() — no timer allocation
await _event.WaitAsync(Timeout.InfiniteTimeSpan);

Allocation Behavior

Immediate waits are completely allocation-free using atomic operations. When the event is contended, waiting without a timeout is allocation-free on .NET 6.0+ (using UnsafeRegister for cancellation), while older frameworks may allocate for cancellation registration. Specifying a finite timeout allocates a timer that is automatically disposed when the operation completes. Exception and task allocations occur only if a timeout actually elapses or cancellation is triggered; successful acquisitions are otherwise allocation-free. Pooled IValueTaskSource<bool> instances are reused to minimize allocation pressure across repeated operations.

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.

PulseAll

public void PulseAll()

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.

TryReset

public bool TryReset()

Implements IResettable to allow returning this instance to a DefaultObjectPool<AsyncAutoResetEvent>.

Behavior:

  • Attempts to acquire the internal spin lock. If the lock is already held (a concurrent Set() or WaitAsync() is in progress), the method returns false immediately and the pool discards the instance.
  • If the lock is acquired and waiters are currently queued, the method returns false — the instance is still in active use and must not be recycled.
  • If the lock is acquired and no waiters are queued, the signaled flag is cleared and the local waiter is reset; the method returns true.

Thread Safety: TryReset() is safe to call concurrently with other operations. It will simply return false if the instance is in use.

Example:

// Using AsyncAutoResetEvent with an object pool
var pool = new DefaultObjectPool<AsyncAutoResetEvent>(
    new DefaultPooledObjectPolicy<AsyncAutoResetEvent>());

var ev = pool.Get();
try
{
    await ev.WaitAsync(ct);
}
finally
{
    pool.Return(ev); // calls TryReset() internally
}

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 OperationCanceledException and 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 canceled ValueTask (which may allocate a Task wrapper on some frameworks).

Thread Safety

Thread-safe. All public methods are thread-safe and can be called concurrently.

Performance Characteristics

  • Set(): O(1) operation
  • PulseAll(): 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
  • ProtoPromiseAsyncAutoResetEvent: The implementation from the Proto.Promises.Threading library which uses custom awaiter and cancelation tokens
  • 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 AutoResetEvent which 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 · ProtoPromise 0.5506 ns 0.73 -
Set · AsyncAutoReset · Pooled 0.7530 ns 1.00 -
Set · AsyncAutoReset · Nito.AsyncEx 4.4771 ns 5.95 -
Set · AsyncAutoReset · RefImpl 4.5253 ns 6.02 -
Set · AutoResetEvent · System 215.6038 ns 286.67 -

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 · ProtoPromise 5.450 ns 0.90 -
SetThenWait · AsyncAutoReset · Pooled (ValueTask) 6.083 ns 1.00 -
SetThenWait · AsyncAutoReset · Pooled (AsTask) 8.694 ns 1.43 -
SetThenWait · AsyncAutoReset · Nito.AsyncEx 14.165 ns 2.33 -
SetThenWait · AsyncAutoReset · RefImpl 15.447 ns 2.54 -

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. ProtoPromise is now included as an additional low-allocation competitor and is often the fastest published implementation for the wait-then-set pattern. The caveat of the ProtoPromise library is the custom implementation of Promises as replacement for ValueTask and the custom cancellation tokens. The RefImpl implementation is also 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.AsyncEx 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 · ProtoPromise 1 None 23.66 ns 0.83 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 1 None 25.86 ns 0.90 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 1 None 26.07 ns 0.91 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 1 None 28.53 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 1 None 28.63 ns 1.00 -
WaitThenSet · AsyncAutoReset · RefImpl 1 None 29.02 ns 1.01 96 B
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 1 None 35.46 ns 1.24 160 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 1 None 42.84 ns 1.50 80 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 1 None 449.80 ns 15.71 231 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 1 NotCancelled 43.33 ns 0.99 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 1 NotCancelled 43.54 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 1 NotCancelled 43.71 ns 1.00 -
WaitThenSet · AsyncAutoReset · ProtoPromise 1 NotCancelled 46.97 ns 1.07 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 1 NotCancelled 66.61 ns 1.52 80 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 1 NotCancelled 77.09 ns 1.76 -
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 1 NotCancelled 337.09 ns 7.71 400 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 1 NotCancelled 514.33 ns 11.77 232 B
WaitThenSet · AsyncAutoReset · ProtoPromise 2 None 46.85 ns 0.66 -
WaitThenSet · AsyncAutoReset · RefImpl 2 None 52.64 ns 0.74 192 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 2 None 63.24 ns 0.89 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 2 None 66.85 ns 0.94 -
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 2 None 67.80 ns 0.95 320 B
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 2 None 71.21 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 2 None 72.04 ns 1.01 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 2 None 101.12 ns 1.42 160 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 2 None 745.18 ns 10.47 343 B
WaitThenSet · AsyncAutoReset · ProtoPromise 2 NotCancelled 91.95 ns 0.90 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 2 NotCancelled 102.11 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 2 NotCancelled 102.94 ns 1.01 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 2 NotCancelled 104.05 ns 1.02 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 2 NotCancelled 106.53 ns 1.04 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 2 NotCancelled 146.62 ns 1.44 160 B
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 2 NotCancelled 575.66 ns 5.64 800 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 2 NotCancelled 903.74 ns 8.85 344 B
WaitThenSet · AsyncAutoReset · ProtoPromise 10 None 245.19 ns 0.69 -
WaitThenSet · AsyncAutoReset · RefImpl 10 None 281.16 ns 0.79 960 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 10 None 340.86 ns 0.96 -
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 10 None 349.20 ns 0.98 1600 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 10 None 349.23 ns 0.98 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 10 None 356.04 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 10 None 372.92 ns 1.05 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 10 None 533.28 ns 1.50 800 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 10 None 2,115.63 ns 5.94 1237 B
WaitThenSet · AsyncAutoReset · ProtoPromise 10 NotCancelled 458.40 ns 0.83 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 10 NotCancelled 535.05 ns 0.96 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 10 NotCancelled 545.01 ns 0.98 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 10 NotCancelled 549.83 ns 0.99 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 10 NotCancelled 554.84 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 10 NotCancelled 768.56 ns 1.39 800 B
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 10 NotCancelled 2,908.98 ns 5.24 4000 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 10 NotCancelled 3,374.46 ns 6.08 1239 B
WaitThenSet · AsyncAutoReset · ProtoPromise 100 None 2,267.45 ns 0.65 -
WaitThenSet · AsyncAutoReset · RefImpl 100 None 2,767.11 ns 0.79 9600 B
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 100 None 3,149.15 ns 0.90 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 100 None 3,185.54 ns 0.91 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 100 None 3,508.92 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 100 None 3,532.61 ns 1.01 -
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 100 None 3,555.60 ns 1.01 16000 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 100 None 5,238.56 ns 1.49 8000 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 100 None 16,210.37 ns 4.62 11320 B
WaitThenSet · AsyncAutoReset · ProtoPromise 100 NotCancelled 4,746.20 ns 0.90 -
WaitThenSet · AsyncAutoReset · Pooled (ValueTask) 100 NotCancelled 5,263.17 ns 1.00 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask) 100 NotCancelled 5,327.80 ns 1.01 -
WaitThenSet · AsyncAutoReset · Pooled (SyncCont) 100 NotCancelled 5,349.26 ns 1.02 -
WaitThenSet · AsyncAutoReset · Pooled (AsValueTask SyncCont) 100 NotCancelled 5,645.75 ns 1.07 -
WaitThenSet · AsyncAutoReset · Pooled (AsTask SyncCont) 100 NotCancelled 7,803.78 ns 1.48 8000 B
WaitThenSet · AsyncAutoReset · Nito.AsyncEx 100 NotCancelled 28,920.30 ns 5.50 40000 B
WaitThenSet · AsyncAutoReset · Pooled (AsTask) 100 NotCancelled 290,521.43 ns 55.20 11324 B

Benchmark Analysis

Key Findings:

  1. Synchronous Completion: When the event is already signaled, WaitAsync() completes synchronously with zero allocations, matching or exceeding Nito.AsyncEx performance.

  2. 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 even though ProtoPromise can currently win some throughput-only comparisons.

  3. 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.

  4. AsTask() Overhead: When RunContinuationAsynchronously=true, calling AsTask() before signaling introduces significant overhead. Always await ValueTask directly 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:

  1. If waiters exist: Release one waiter, event returns to non-signaled state
  2. 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

✓ DO: Use WaitAsync(TimeSpan) for timed waits

Avoid the AsTask().WaitAsync(timeout) pattern — it forces a Task allocation and may cause the 10x–100x slowdown described above. Use the direct timeout overload instead:

// Good: direct timeout, ValueTask stays allocation-light
try
{
    await _event.WaitAsync(TimeSpan.FromSeconds(2));
    ProcessSignal();
}
catch (TimeoutException)
{
    HandleTimeout();
}

// Bad: allocates Task, may degrade performance under RunContinuationAsynchronously=true
await _event.WaitAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2));

Common Patterns

(Examples omitted - see asyncmanualresetevent.md for manual-reset patterns and broadcasting examples.)

See Also


© 2026 The Keepers of the CryptoHives