Table of Contents

CryptoHives.Foundation.Threading Package

Overview

The Threading package provides pooled, ValueTask-based async synchronization primitives for .NET applications where high-throughput workloads make per-waiter allocations matter. They're meant to complement the existing async synchronization libraries for .NET, not replace them.

Most popular async synchronization libraries allocate a Task and/or TaskCompletionSource per waiter, and cancellation handling often adds more. ValueTask and IValueTaskSource, introduced a few years ago, make it possible to build low-allocation primitives that can be pooled and reused instead. This library is what came out of the Keepers of the CryptoHives digging into that approach. More primitives may get added as the need comes up.

Core Guarantees

  • Pooled primitives — synchronization objects backed by object pools
  • ValueTask-based — low-allocation async operations
  • Thread-safe — every operation is safe under concurrent access, using interlocked state transitions
  • Drop-in replacement — swap the namespace to migrate from other popular libraries
  • Cancellation support — full CancellationToken support across all primitives
  • Timeout support — optional timeout parameters on every lock acquisition method
  • Configurable continuations — control synchronous vs. asynchronous continuation execution
  • Custom object pools — supply your own for fine-grained control
  • Optional analyzers — Roslyn analyzers that catch common ValueTask misuse at compile time

Installation

dotnet add package CryptoHives.Foundation.Threading

Target frameworks: net462, netstandard2.0, netstandard2.1, net8.0, net10.0. No dependency on any other CryptoHives package.

Note: This package does not include Threading Analyzers automatically.

Namespaces

Async Synchronization Primitives

using CryptoHives.Foundation.Threading.Async.Pooled;

Pooling Infrastructure

using CryptoHives.Foundation.Threading.Pools;

Classes

Synchronization Primitives

Class Description Documentation
AsyncLock Pooled async mutual exclusion lock Details
AsyncKeyedLock<TKey> Pooled per-key async exclusive lock (different keys never block each other) Details
AsyncAutoResetEvent Pooled async auto-reset event (one waiter per signal) Details
AsyncManualResetEvent Pooled async manual-reset event (all waiters per signal) Details
AsyncSemaphore Pooled async semaphore with configurable permit count Details
AsyncCountdownEvent Pooled async countdown event (signals when count reaches zero) Details
AsyncBarrier Pooled async barrier (synchronizes multiple participants) Details
AsyncConditionVariable Pooled async condition variable (wait until a condition guarded by an AsyncLock holds) Details
AsyncExchange<T> Pooled two-party rendezvous that swaps a value between two tasks Details
AsyncReaderWriterLock Pooled async reader-writer lock (multiple readers or single writer) Details

Pooling Support Classes

Class Description Namespace
IGetPooledManualResetValueTaskSource<T> Interface to get pooled IValueTaskSource<T> implementations (providers return PooledManualResetValueTaskSource<T> instances) CryptoHives.Foundation.Threading.Pools
ManualResetValueTaskSource<T> Abstract base for pooled IValueTaskSource<T> implementations CryptoHives.Foundation.Threading.Pools
PooledManualResetValueTaskSource<T> Pooled IValueTaskSource<T> implementation with automatic pool return CryptoHives.Foundation.Threading.Pools
LocalManualResetValueTaskSource<T> Object-local IValueTaskSource<T> without pool integration CryptoHives.Foundation.Threading.Pools
PooledValueTaskSourceObjectPolicy<T> Object pool policy for PooledManualResetValueTaskSource<T> CryptoHives.Foundation.Threading.Pools
ValueTaskSourceObjectPool<T> Specialized provider that implements IGetPooledManualResetValueTaskSource<T> and returns pooled task sources CryptoHives.Foundation.Threading.Pools
ValueTaskSourceObjectPools Static helper with shared pool instances and constants CryptoHives.Foundation.Threading.Pools

Known Issues and Caveats

  1. Await a ValueTask exactly once. A second await or AsTask() call may throw InvalidOperationException.
  2. Use AsTask() at most once, and only when you actually need a Task. Beyond the same InvalidOperationException risk, it also adds a Task allocation under contention.
  3. Pool exhaustion. Under extreme concurrency with many waiters, the pool can run dry. Watch usage patterns and adjust, or supply a custom pool if needed.
  4. Always await. If a ValueTask or Task waiter isn't awaited, its underlying IValueTaskSource never makes it back to the pool — that's a leak.

Performance Characteristics

  • Synchronization objects are backed by object pools to keep GC pressure down.
  • ValueTask-based operations avoid heap allocation on the fast path.
  • Uncontended access uses lock-free atomic operations.
  • Continuations run via RunContinuationsAsynchronously by default, to avoid deadlocks.

Not every primitive here beats its popular-library equivalent in every scenario — most of the time it does, but there are exceptions. AsyncManualResetEvent, for instance, pays for one IValueTaskSource per waiter because a single ValueTask can't be awaited by more than one caller. A Task-based implementation can let every waiter share the same underlying Task/TaskCompletionSource instead.

See the Benchmarks overview for numbers. A local run writes its reports to tests/Threading/BenchmarkDotNet.Artifacts/results/; recorded runs are archived on the benchmarks branch, which is what the published dashboard is built from.

Quick Examples

AsyncLock

private readonly AsyncLock _lock = new AsyncLock();

public async Task AccessSharedResourceAsync(CancellationToken ct)
{
    using (await _lock.LockAsync(ct))
    {
        // Critical section - only one task at a time
        await ModifySharedStateAsync();
    }
}

AsyncKeyedLock

private readonly AsyncKeyedLock<string> _locksByAccount = new AsyncKeyedLock<string>();

public async Task UpdateAccountAsync(string accountId, CancellationToken ct)
{
    using (await _locksByAccount.LockAsync(accountId, ct))
    {
        // Serialized per account - operations on other accounts run in parallel
        await ApplyChangesAsync(accountId);
    }
}

TryLock acquires only if the key is free right now. It never waits and never allocates, which suits opportunistic work that can be skipped when someone else already holds the key:

public void RefreshIfIdle(string accountId)
{
    if (_locksByAccount.TryLock(accountId, out var releaser))
    {
        using (releaser)
        {
            RefreshCache(accountId);
        }
    }
    // Held by someone else - skip; the refresh will happen on their release
}

AsyncAutoResetEvent

private readonly AsyncAutoResetEvent _event = new AsyncAutoResetEvent(false);

// Producer
public async Task ProduceAsync()
{
    await ProduceItemAsync();
    _event.Set(); // Signal one waiter
}

// Consumer
public async Task ConsumeAsync(CancellationToken ct)
{
    await _event.WaitAsync(ct); // Wait for signal
    await ProcessItemAsync();
}

AsyncManualResetEvent

private readonly AsyncManualResetEvent _event = new AsyncManualResetEvent(false);

// Controller
public void SignalReady()
{
    _event.Set(); // Signal all waiters
}

// Worker
public async Task WaitForReadyAsync(CancellationToken ct)
{
    await _event.WaitAsync(ct); // Multiple tasks can wait
    await DoWorkAsync();
}

AsyncSemaphore

private readonly AsyncSemaphore _semaphore = new AsyncSemaphore(3);

// Limited concurrent access
public async Task AccessLimitedResourceAsync(CancellationToken ct)
{
    await _semaphore.WaitAsync(ct);
    try
    {
        // Max 3 concurrent tasks can access this section
        await AccessResourceAsync();
    }
    finally
    {
        _semaphore.Release();
    }
}

// Shed load instead of queueing indefinitely: give up after two seconds
public async Task<bool> TryAccessAsync(CancellationToken ct)
{
    try
    {
        await _semaphore.WaitAsync(TimeSpan.FromSeconds(2), ct);
    }
    catch (TimeoutException)
    {
        return false; // no permit came free in time
    }

    try
    {
        await AccessResourceAsync();
        return true;
    }
    finally
    {
        _semaphore.Release();
    }
}

AsyncCountdownEvent

private readonly AsyncCountdownEvent _countdown = new AsyncCountdownEvent(3);

// Coordinator
public async Task WaitForWorkersAsync(CancellationToken ct)
{
    await _countdown.WaitAsync(ct);
    // All workers have signaled
}

// Worker
public void WorkerCompleted()
{
    _countdown.Signal();
}

AsyncBarrier

private readonly AsyncBarrier _barrier = new AsyncBarrier(3);

// Participant
public async Task ParticipantWorkAsync(CancellationToken ct)
{
    await DoPhase1WorkAsync();
    await _barrier.SignalAndWaitAsync(ct); // Wait for all participants
    await DoPhase2WorkAsync();
}

AsyncReaderWriterLock

private readonly AsyncReaderWriterLock _rwLock = new AsyncReaderWriterLock();

// Reader
public async Task ReadAsync(CancellationToken ct)
{
    using (await _rwLock.ReaderLockAsync(ct))
    {
        // Multiple readers can hold the lock concurrently
        await ReadDataAsync();
    }
}

// Writer
public async Task WriteAsync(CancellationToken ct)
{
    using (await _rwLock.WriterLockAsync(ct))
    {
        // Exclusive access
        await WriteDataAsync();
    }
}

// Writer that would rather fail fast than block a request thread behind long readers
public async Task<bool> TryWriteAsync(CancellationToken ct)
{
    try
    {
        using (await _rwLock.WriterLockAsync(TimeSpan.FromMilliseconds(250), ct))
        {
            await WriteDataAsync();
            return true;
        }
    }
    catch (TimeoutException)
    {
        return false;
    }
}

Timeout Support

Every wait and every acquisition takes an optional timeout, as an overload that sits in front of the CancellationToken:

Primitive Method taking a timeout
AsyncLock LockAsync(TimeSpan, CancellationToken)
AsyncKeyedLock<TKey> LockAsync(TKey, TimeSpan, CancellationToken) — plus TryLock(TKey, out Releaser), which never waits at all
AsyncSemaphore WaitAsync(TimeSpan, CancellationToken)
AsyncAutoResetEvent WaitAsync(TimeSpan, CancellationToken)
AsyncManualResetEvent WaitAsync(TimeSpan, CancellationToken)
AsyncCountdownEvent WaitAsync(TimeSpan, CancellationToken)
AsyncBarrier SignalAndWaitAsync(TimeSpan, CancellationToken)
AsyncConditionVariable WaitAsync(AsyncLock, TimeSpan, CancellationToken)
AsyncExchange<T> ExchangeAsync(T, TimeSpan, CancellationToken)
AsyncReaderWriterLock ReaderLockAsync / UpgradeableReaderLockAsync / WriterLockAsync(TimeSpan, CancellationToken), and UpgradeToWriterLockAsync(TimeSpan, CancellationToken) on an upgradeable releaser

The semantics are the same across every primitive:

  • Elapsing throws TimeoutException; the wait is abandoned and nothing is acquired.
  • Timeout.InfiniteTimeSpan waits indefinitely, exactly like the overload without a timeout.
  • TimeSpan.Zero is an immediate attempt — it either succeeds or throws right away.
  • Any other negative value throws ArgumentOutOfRangeException.
  • An already-cancelled token wins over a zero timeout: you get OperationCanceledException, which carries the token, rather than a TimeoutException that names neither.
  • The timer costs nothing until it is needed. Nothing is allocated when the primitive is available immediately, when the timeout is infinite, or when a zero timeout fails outright — only a genuine wait on a finite timeout allocates one, and it is disposed when the ValueTask is awaited.
// Non-blocking attempt using TimeSpan.Zero
try
{
    using (await _lock.LockAsync(TimeSpan.Zero))
    {
        await DoWorkAsync();
    }
}
catch (TimeoutException)
{
    // Lock not immediately available
}
// Per-key timeout: a slow tenant delays only callers working on that tenant
private readonly AsyncKeyedLock<string> _locksByTenant = new AsyncKeyedLock<string>();

public async Task<bool> TryImportAsync(string tenantId, CancellationToken ct)
{
    try
    {
        using (await _locksByTenant.LockAsync(tenantId, TimeSpan.FromSeconds(5), ct))
        {
            await ImportAsync(tenantId);
            return true;
        }
    }
    catch (TimeoutException)
    {
        return false; // another import for this tenant is still running
    }
}
// Timeout-based acquisition with retry
private readonly AsyncLock _lock = new AsyncLock();

public async Task<bool> TryAcquireWithRetryAsync(TimeSpan timeout, int maxRetries, CancellationToken ct)
{
    for (int i = 0; i < maxRetries; i++)
    {
        try
        {
            using (await _lock.LockAsync(timeout, ct))
            {
                return await PerformWorkAsync();
            }
        }
        catch (TimeoutException) when (i < maxRetries - 1)
        {
            // Timeout occurred, retry
            continue;
        }
    }
    return false; // All retries exhausted
}

Non-Blocking Attempts (Try*)

Where a zero timeout answers "can I have it right now?" by throwing TimeoutException on a miss, the Try* methods answer the same question by returning false. They are synchronous, never allocate an exception or a faulted ValueTask, and there is nothing to await — the call either succeeds immediately or it doesn't. Reach for them on paths that shed work rather than queue it, where contention is an expected outcome instead of an exceptional one.

Primitive Non-blocking attempt Consumes on success?
AsyncLock bool TryLock(out Releaser) Yes — dispose the releaser to release
AsyncKeyedLock<TKey> bool TryLock(TKey, out Releaser) Yes — dispose the releaser to release
AsyncSemaphore bool TryWait() Yes — call Release() exactly once on success
AsyncAutoResetEvent bool TryWait() Yes — consumes the pending signal, exactly as a completed WaitAsync() would
AsyncManualResetEvent bool TryWait() No — non-consuming, identical to reading IsSet
AsyncCountdownEvent bool TryWait() No — non-consuming, identical to reading IsSet
AsyncReaderWriterLock bool TryReaderLock(out Releaser), bool TryUpgradeableReaderLock(out Releaser), bool TryWriterLock(out Releaser) Yes — dispose the releaser to release
AsyncReaderWriterLock.Releaser bool TryUpgradeToWriterLock(out Releaser) on an upgradeable releaser Yes — dispose the returned releaser to demote back
AsyncExchange<T> bool TryExchange(T, out T) Pairs only with a party already waiting; never occupies the slot otherwise

Semantics worth calling out:

  • AsyncAutoResetEvent.TryWait() consumes the signal. That is what separates it from IsSet, which only peeks and lets two callers both proceed on one signal. Prefer TryWait() whenever the answer decides who does the work.
  • AsyncManualResetEvent.TryWait() / AsyncCountdownEvent.TryWait() do not consume anything — a manual-reset event and a countdown hold their state until explicitly reset. These are exact aliases of IsSet, provided for naming symmetry.
  • AsyncReaderWriterLock honours writer priority on the try path too. TryReaderLock and TryUpgradeableReaderLock decline while a writer is queued, even though the lock is only read-held, so a caller polling in a loop cannot starve a writer. TryUpgradeableReaderLock also declines while another upgradeable reader holds the lock (only one is allowed at a time).
  • A failed Try* hands back default. For the releaser-returning overloads that means a releaser with no associated lock — do not dispose it. In DEBUG builds disposing a default AsyncLock.Releaser or AsyncReaderWriterLock.Releaser throws InvalidOperationException to catch the mistake; release builds ignore it.
// Opportunistic cache refresh — skip entirely if another writer is active
if (_rwLock.TryWriterLock(out var releaser))
{
    using (releaser)
    {
        RebuildCache();
    }
}

// Rate limiter that reports "busy" instead of throwing or awaiting
public bool TryHandle(Request request)
{
    if (!_permits.TryWait())
    {
        return false; // shed load
    }

    try { Process(request); }
    finally { _permits.Release(); }
    return true;
}

Performance Characteristics by Primitive

  • AsyncLock: O(1) acquire when uncontended, FIFO queue for waiters
  • AsyncKeyedLock: O(1) acquire per key when uncontended; one administrative lock guards the key registry
  • AsyncAutoResetEvent: O(1) Set/Wait, FIFO queue for single waiter release
  • AsyncManualResetEvent: O(n) Set broadcast to all n waiters, O(1) Reset
  • AsyncConditionVariable: O(1) Signal/Wait; releases and re-acquires the paired AsyncLock on every return path
  • AsyncExchange<T>: O(1) — the arriving party never suspends and returns a synchronously-completed ValueTask<T>

Best Practices

  1. Confirm pooled primitives and ValueTask actually help your workload before switching — they shine under high throughput, less so at low concurrency (see the architecture caveats above).
  2. Create synchronization primitives once and reuse them rather than constructing per call.
  3. Await or call AsTask() on each ValueTask exactly once — never both, never twice.
  4. Always await a ValueTask/Task waiter; otherwise its resources never return to the pool.
  5. Keep critical sections short — don't hold a lock across unrelated work.
  6. Pass a CancellationToken for any wait that could be long — the check is nearly free.
  7. Use ConfigureAwait(false) in library code to avoid capturing the sync context.
  8. Don't call AsTask() before the primitive signals when RunContinuationAsynchronously=true — it causes a severe performance hit.

Common Patterns

Producer-Consumer

private readonly AsyncAutoResetEvent _itemAvailable = new AsyncAutoResetEvent(false);
private readonly Queue<Item> _queue = new();

public async Task ProducerAsync(Item item)
{
    _queue.Enqueue(item);
    _itemAvailable.Set();
}

public async Task<Item> ConsumerAsync(CancellationToken ct)
{
    await _itemAvailable.WaitAsync(ct);
    return _queue.Dequeue();
}

Async Initialization

private readonly AsyncManualResetEvent _initialized = new AsyncManualResetEvent(false);

public async Task InitializeAsync()
{
    await DoInitializationAsync();
    _initialized.Set();
}

public async Task UseServiceAsync(CancellationToken ct)
{
    await _initialized.WaitAsync(ct);
    // Service is now initialized
}

Rate Limiting

private readonly AsyncLock _rateLimiter = new AsyncLock();

public async Task<T> RateLimitedOperationAsync<T>(Func<Task<T>> operation, CancellationToken ct)
{
    using (await _rateLimiter.LockAsync(ct))
    {
        await Task.Delay(100, ct); // Rate limit
        return await operation();
    }
}

Comparison with Standard Library

Feature Threading Package System.Threading
Allocation overhead Minimal (pooled) Higher (per operation)
ValueTask support Yes Partial
Pooling Built-in Manual
Performance Optimized for high-throughput Standard
Cancellation Full support Varies

Advanced: Custom Pooling

You can supply your own provider implementing IGetPooledManualResetValueTaskSource<T> for fine-grained control over pool behavior. The built-in ValueTaskSourceObjectPool<T> already implements this interface and can be used directly.

using CryptoHives.Foundation.Threading.Pools;
using Microsoft.Extensions.ObjectPool;

// Create a custom pool provider (ValueTaskSourceObjectPool implements IGetPooledManualResetValueTaskSource<T>)
var customPolicy = new PooledValueTaskSourceObjectPolicy<bool>();
var customPool = new ValueTaskSourceObjectPool<bool>(customPolicy, maximumRetained: 64);

// Use custom provider with event
var evt = new AsyncAutoResetEvent(
    initialState: false,
    runContinuationAsynchronously: true,
    pool: customPool); // accepts any IGetPooledManualResetValueTaskSource<bool>

ValueTaskSource Details

ManualResetValueTaskSource<T> (abstract base) provides:

  • Version for versioning support
  • RunContinuationsAsynchronously to control continuation scheduling
  • CancellationToken and CancellationTokenRegistration for cancellation support
  • SetResult() / SetException() for completion
  • TryReset() for pool reuse

PooledManualResetValueTaskSource<T> (sealed implementation):

  • Returns to the pool automatically after GetResult() is called
  • Integrates with IResettable for pool compatibility
  • Manages the cancellation token registration lifecycle

See Also


© 2026 The Keepers of the CryptoHives