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!

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.424 ns 0.70 -
SetReset · AsyncManualReset · Pooled 2.043 ns 1.00 -
SetReset · ManualResetEventSlim · System 5.633 ns 2.76 -
SetReset · AsyncManualReset · RefImpl 10.499 ns 5.14 96 B
SetReset · AsyncManualReset · Nito.AsyncEx 17.007 ns 8.33 96 B
SetReset · ManualResetEvent · System 426.864 ns 208.98 -

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 6.130 ns 0.71 -
SetThenWait · AsyncManualReset · Pooled (ValueTask) 8.679 ns 1.00 -
SetThenWait · AsyncManualReset · Pooled (AsTask) 10.352 ns 1.19 -
SetThenWait · AsyncManualReset · RefImpl 14.084 ns 1.62 96 B
SetThenWait · AsyncManualReset · Nito.AsyncEx 24.532 ns 2.83 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.77 ns 0.85 96 B
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 1 None 23.40 ns 0.92 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 1 None 24.13 ns 0.94 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 1 None 25.07 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 1 None 25.56 ns 1.00 -
WaitThenSet · AsyncManualReset · ProtoPromise 1 None 26.57 ns 1.04 -
WaitThenSet · AsyncManualReset · Nito.AsyncEx 1 None 28.83 ns 1.13 96 B
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 1 None 36.29 ns 1.42 80 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 1 None 442.78 ns 17.33 231 B
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 1 NotCancelled 38.68 ns 0.94 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 1 NotCancelled 40.45 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 1 NotCancelled 40.63 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 1 NotCancelled 41.05 ns 1.00 -
WaitThenSet · AsyncManualReset · ProtoPromise 1 NotCancelled 49.45 ns 1.21 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 1 NotCancelled 60.87 ns 1.48 80 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 1 NotCancelled 492.64 ns 12.01 232 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 1 NotCancelled 650.27 ns 15.85 808 B
WaitThenSet · AsyncManualReset · RefImpl 2 None 26.16 ns 0.46 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 2 None 39.16 ns 0.69 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 2 None 46.20 ns 0.81 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 2 None 51.22 ns 0.90 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 2 None 53.31 ns 0.93 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 2 None 54.87 ns 0.96 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 2 None 57.20 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 2 None 82.64 ns 1.45 160 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 2 None 714.79 ns 12.50 343 B
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 2 NotCancelled 89.91 ns 0.96 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 2 NotCancelled 91.04 ns 0.97 -
WaitThenSet · AsyncManualReset · ProtoPromise 2 NotCancelled 91.89 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 2 NotCancelled 93.81 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 2 NotCancelled 95.38 ns 1.02 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 2 NotCancelled 129.08 ns 1.38 160 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 2 NotCancelled 803.51 ns 8.57 344 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 2 NotCancelled 1,185.20 ns 12.64 1488 B
WaitThenSet · AsyncManualReset · RefImpl 10 None 64.93 ns 0.20 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 10 None 110.41 ns 0.34 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 10 None 214.27 ns 0.66 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 10 None 298.82 ns 0.92 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 10 None 300.43 ns 0.93 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 10 None 324.51 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 10 None 325.91 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 10 None 453.70 ns 1.40 800 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 10 None 1,971.93 ns 6.08 1239 B
WaitThenSet · AsyncManualReset · ProtoPromise 10 NotCancelled 454.68 ns 0.88 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 10 NotCancelled 482.97 ns 0.94 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 10 NotCancelled 497.87 ns 0.97 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 10 NotCancelled 515.24 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 10 NotCancelled 531.65 ns 1.03 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 10 NotCancelled 737.97 ns 1.43 800 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 10 NotCancelled 3,031.89 ns 5.88 1240 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 10 NotCancelled 4,436.31 ns 8.61 6464 B
WaitThenSet · AsyncManualReset · RefImpl 100 None 533.53 ns 0.15 96 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 100 None 928.28 ns 0.27 96 B
WaitThenSet · AsyncManualReset · ProtoPromise 100 None 2,275.95 ns 0.65 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 100 None 2,857.04 ns 0.82 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 100 None 2,901.30 ns 0.83 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 100 None 3,161.49 ns 0.91 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 100 None 3,482.75 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 100 None 4,674.61 ns 1.34 8000 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 100 None 15,704.57 ns 4.51 11320 B
WaitThenSet · AsyncManualReset · ProtoPromise 100 NotCancelled 4,415.36 ns 0.88 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask SyncCont) 100 NotCancelled 4,908.97 ns 0.98 -
WaitThenSet · AsyncManualReset · Pooled (SyncCont) 100 NotCancelled 4,971.60 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (AsValueTask) 100 NotCancelled 4,996.67 ns 0.99 -
WaitThenSet · AsyncManualReset · Pooled (ValueTask) 100 NotCancelled 5,026.19 ns 1.00 -
WaitThenSet · AsyncManualReset · Pooled (AsTask SyncCont) 100 NotCancelled 7,174.00 ns 1.43 8000 B
WaitThenSet · AsyncManualReset · Nito.AsyncEx 100 NotCancelled 115,582.83 ns 23.00 61617 B
WaitThenSet · AsyncManualReset · Pooled (AsTask) 100 NotCancelled 263,666.83 ns 52.46 11327 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

Common Patterns

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

See Also


© 2026 The Keepers of the CryptoHives