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

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.

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

  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