Table of Contents

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 : IResettable

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 CancellationToken support for queued waiters
  • Thread-safe: All operations are thread-safe
  • Pooled task sources: Reuses IValueTaskSource instances 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 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.

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 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 signaled the call returns a completed ValueTask (synchronous, zero-allocation).
  • Otherwise the call enqueues a pooled waiter and returns a ValueTask that completes when Set() is called.

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 and 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 queued
  • InvalidOperationException - If a returned ValueTask instance 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!

WaitAsync (timeout)

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

Asynchronously waits for the event to be set, 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 allocating a TimeProvider).

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

Throws:

  • TimeoutException — If the timeout elapses before the event is set.
  • 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 set 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 CTS 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 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 until Reset() is called.
  • When RunContinuationAsynchronously is false, 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 until Set() is called again.

TryReset

public bool TryReset()

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

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 and options are reset to initial defaults 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 AsyncManualResetEvent with an object pool
var pool = new DefaultObjectPool<AsyncManualResetEvent>(
    new DefaultPooledObjectPolicy<AsyncManualResetEvent>());

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.
  • 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 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(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 IValueTaskSource per waiter (unlike Task-based implementations that share a single Task). 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 · AsyncManualReset · ProtoPromise 1.447 ns 0.71 -
SetReset · AsyncManualReset · Pooled 2.050 ns 1.00 -
SetReset · ManualResetEventSlim · System 5.438 ns 2.65 -
SetReset · AsyncManualReset · RefImpl 9.979 ns 4.87 96 B
SetReset · AsyncManualReset · Nito.AsyncEx 17.013 ns 8.30 96 B
SetReset · ManualResetEvent · System 432.047 ns 210.74 -

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 · ProtoPromise 5.864 ns 0.64 -
SetThenWait · AsyncManualReset · Pooled (ValueTask) 9.203 ns 1.00 -
SetThenWait · AsyncManualReset · Pooled (AsTask) 9.251 ns 1.01 -
SetThenWait · AsyncManualReset · RefImpl 13.676 ns 1.49 96 B
SetThenWait · AsyncManualReset · Nito.AsyncEx 24.142 ns 2.62 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 21.31 ns 0.73 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 1 None 26.05 ns 0.90 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 1 None 26.90 ns 0.93 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 1 None 27.19 ns 0.94 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 1 None 28.86 ns 0.99 -
WaitThenSet · AsyncManualReset · Nito.AsyncEx 1 None 29.00 ns 1.00 96 B
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 1 None 29.01 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 1 None 41.05 ns 1.42 80 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 1 None 439.28 ns 15.14 231 B
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 1 NotCancelled 43.70 ns 0.96 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 1 NotCancelled 44.63 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 1 NotCancelled 45.36 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 1 NotCancelled 45.72 ns 1.01 -
WaitThenSet · AsyncManualReset · ProtoPromise 1 NotCancelled 47.92 ns 1.06 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 1 NotCancelled 63.31 ns 1.40 80 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 1 NotCancelled 487.66 ns 10.75 232 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 1 NotCancelled 619.69 ns 13.66 808 B
WaitThenSet · AsyncManualReset · RefImpl 2 None 24.12 ns 0.37 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 2 None 38.01 ns 0.58 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 2 None 45.85 ns 0.70 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 2 None 59.82 ns 0.92 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 2 None 60.29 ns 0.92 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 2 None 65.21 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 2 None 65.37 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 2 None 91.73 ns 1.40 160 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 2 None 769.23 ns 11.77 344 B
WaitThenSet · AsyncManualReset · ProtoPromise 2 NotCancelled 89.35 ns 0.88 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 2 NotCancelled 93.97 ns 0.92 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 2 NotCancelled 99.86 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 2 NotCancelled 101.01 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 2 NotCancelled 101.79 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 2 NotCancelled 140.33 ns 1.38 160 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 2 NotCancelled 900.51 ns 8.85 344 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 2 NotCancelled 1,075.26 ns 10.56 1488 B
WaitThenSet · AsyncManualReset · RefImpl 10 None 65.32 ns 0.18 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 10 None 111.64 ns 0.31 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 10 None 213.75 ns 0.60 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 10 None 319.12 ns 0.90 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 10 None 322.67 ns 0.91 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 10 None 351.34 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 10 None 354.82 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 10 None 486.56 ns 1.37 800 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 10 None 2,053.86 ns 5.79 1239 B
WaitThenSet · AsyncManualReset · ProtoPromise 10 NotCancelled 426.86 ns 0.81 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 10 NotCancelled 518.51 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 10 NotCancelled 527.70 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 10 NotCancelled 531.19 ns 1.01 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 10 NotCancelled 552.29 ns 1.05 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 10 NotCancelled 725.76 ns 1.38 800 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 10 NotCancelled 2,835.69 ns 5.37 1240 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 10 NotCancelled 3,459.05 ns 6.56 6464 B
WaitThenSet · AsyncManualReset · RefImpl 100 None 545.01 ns 0.16 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 100 None 929.35 ns 0.27 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 100 None 2,117.15 ns 0.62 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 100 None 3,064.17 ns 0.89 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 100 None 3,078.54 ns 0.90 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 100 None 3,435.63 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 100 None 3,459.37 ns 1.01 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 100 None 4,804.34 ns 1.40 8000 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 100 None 15,421.83 ns 4.49 11320 B
WaitThenSet · AsyncManualReset · ProtoPromise 100 NotCancelled 4,210.12 ns 0.81 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 100 NotCancelled 5,195.50 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 100 NotCancelled 5,212.95 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 100 NotCancelled 5,218.04 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 100 NotCancelled 5,247.35 ns 1.01 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 100 NotCancelled 7,302.66 ns 1.40 8000 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 100 NotCancelled 128,084.00 ns 24.57 61615 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 100 NotCancelled 309,059.35 ns 59.29 11326 B

Benchmark Analysis

Key Findings:

  1. Per-Waiter Overhead: Unlike Task-based implementations where all waiters share a single TaskCompletionSource, each waiter in the pooled implementation requires its own IValueTaskSource. This is an inherent trade-off of the ValueTask model, 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.

  2. Pool Mitigation: The object pool effectively mitigates allocation overhead. The local waiter optimization ensures the first queued waiter incurs no allocation.

  3. Synchronous Fast Path: When the event is already signaled, WaitAsync() completes synchronously with zero allocations and without entering the lock.

  4. Set() Performance: For broadcasts to many waiters, the overhead of signaling each IValueTaskSource individually may be higher than a single shared Task. 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 Task would be more efficient
  • Scenarios where ValueTask restrictions 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

✓ DO: Use WaitAsync(TimeSpan) for timed waits

try
{
    await _event.WaitAsync(TimeSpan.FromSeconds(10));
    ProcessData();
}
catch (TimeoutException)
{
    HandleTimeout();
}

Common Patterns

(See examples in this file and asyncautoresetevent.md for additional patterns.)

See Also


© 2026 The Keepers of the CryptoHives