Class AsyncConditionVariable
- Namespace
- CryptoHives.Foundation.Threading.Async.Pooled
- Assembly
- CryptoHives.Foundation.Threading.dll
An async condition variable that pairs with AsyncLock to provide "wait until condition" semantics without blocking a thread, equivalent to Wait(object) for async code.
public sealed class AsyncConditionVariable : IResettable
- Inheritance
-
AsyncConditionVariable
- Implements
- Inherited Members
Remarks
A condition variable must always be used in conjunction with an AsyncLock. The caller must hold the lock when calling WaitAsync(AsyncLock, CancellationToken). The wait atomically releases the lock and suspends the caller until Signal() or SignalAll() is called, after which it re-acquires the lock before returning. The caller is guaranteed to hold the lock on every return path, including when an exception is thrown.
An instance binds to the first AsyncLock it is used with. Passing a different lock to a later wait throws an InvalidOperationException: a condition variable shared across two locks cannot make the atomic release/re-acquire guarantee, and the resulting corruption is otherwise silent. The binding is cleared by TryReset().
Unlike AsyncManualResetEvent and AsyncAutoResetEvent,
a signal that fires when no waiters are present is not stored - it is lost.
Always use a while loop to re-check the predicate after returning from a wait:
private readonly AsyncLock _lock = new AsyncLock();
private readonly AsyncConditionVariable _ready = new AsyncConditionVariable();
private bool _hasItem;
public async Task ProduceAsync(CancellationToken ct)
{
using (await _lock.LockAsync(ct))
{
_hasItem = true;
_ready.Signal();
}
}
public async Task ConsumeAsync(CancellationToken ct)
{
using (await _lock.LockAsync(ct))
{
while (!_hasItem)
await _ready.WaitAsync(_lock, ct);
_hasItem = false;
}
}
Cancellation and timeout bound the wait for a signal, not the re-acquisition of the lock. Once a waiter has consumed a signal it always completes successfully, even if the token is cancelled or the timeout elapses while it is queued behind the lock. Reporting cancellation at that point would swallow the signal the waiter has already taken and the next waiter would never be woken. Cancelling therefore only fails a wait that has not been signalled; a caller that must observe cancellation promptly does so in its own predicate loop, which is where the token belongs anyway.
Allocation behaviour: the first concurrent waiter uses an instance-local IValueTaskSource<TResult> and further concurrent waiters are served from a pool, so waiting allocates nothing on .NET 6.0+ beyond the state machine described below. Specifying a finite timeout allocates a timer that is disposed when the wait completes.
Note: the wait is an async method, so it boxes a state machine when it suspends: it awaits two things in sequence, the signal and then the re-acquisition of the lock. Removing that allocation means transferring a signalled waiter directly into the lock's own wait queue instead of completing it and re-acquiring - a deliberate future change rather than an oversight.
The IResettable interface is implemented to allow resetting the state of the instance for reuse by an ObjectPool<T> using the DefaultObjectPool<T> implementation.
Constructors
AsyncConditionVariable(bool, IGetPooledManualResetValueTaskSource<bool>?)
Constructs a new AsyncConditionVariable.
public AsyncConditionVariable(bool runContinuationAsynchronously = true, IGetPooledManualResetValueTaskSource<bool>? pool = null)
Parameters
runContinuationAsynchronouslyboolWhen true (default), continuations are forced to the thread pool when a signal is received, preventing the signaling thread from being hijacked.
poolIGetPooledManualResetValueTaskSource<bool>Custom pool for waiter instances.
Properties
RunContinuationAsynchronously
Gets or sets whether continuations are forced to run asynchronously after a signal.
public bool RunContinuationAsynchronously { get; set; }
Property Value
WaiterCount
Gets the number of tasks currently waiting for a signal.
public int WaiterCount { get; }
Property Value
Methods
Signal()
Wakes one waiting task. If no tasks are waiting, the signal is lost.
public void Signal()
SignalAll()
Wakes all waiting tasks. If no tasks are waiting, the signal is lost.
public void SignalAll()
TryReset()
Reset the object to a neutral state, semantically similar to when the object was first constructed.
public bool TryReset()
Returns
Remarks
In general, this method is not expected to be thread-safe.
WaitAsync(AsyncLock, CancellationToken)
Atomically releases asyncLock and waits for a signal, then
re-acquires asyncLock before returning. The caller must hold
asyncLock on entry and will always hold it on return,
including when an exception is thrown.
public ValueTask WaitAsync(AsyncLock asyncLock, CancellationToken cancellationToken = default)
Parameters
asyncLockAsyncLockThe lock to release while waiting and re-acquire after signaling.
cancellationTokenCancellationTokenToken to cancel the wait. A wait that has already consumed a signal completes successfully; see the type remarks. The lock is re-acquired before the resulting OperationCanceledException is propagated.
Returns
Exceptions
- ArgumentNullException
Thrown when
asyncLockis null.- InvalidOperationException
Thrown when this instance has already been used with a different AsyncLock.
- SynchronizationLockException
Thrown when
asyncLockis not held on entry.- OperationCanceledException
Thrown when
cancellationTokenis cancelled before a signal is received. The lock is always re-acquired before this exception propagates.
WaitAsync(AsyncLock, TimeSpan, CancellationToken)
Atomically releases asyncLock and waits for a signal or for
timeout to elapse, then re-acquires asyncLock before
returning. The caller must hold asyncLock on entry and will always hold
it on return, including when an exception is thrown.
public ValueTask WaitAsync(AsyncLock asyncLock, TimeSpan timeout, CancellationToken cancellationToken = default)
Parameters
asyncLockAsyncLockThe lock to release while waiting and re-acquire after signaling.
timeoutTimeSpanThe maximum time to wait for a signal. Use InfiniteTimeSpan to wait indefinitely. The timeout bounds the wait for the signal only, not the re-acquisition of the lock. Zero always times out: a condition variable holds no state that a zero-length wait could observe.
cancellationTokenCancellationTokenToken to cancel the wait. A wait that has already consumed a signal completes successfully; see the type remarks.
Returns
Exceptions
- ArgumentNullException
Thrown when
asyncLockis null.- ArgumentOutOfRangeException
Thrown when
timeoutis negative and not equal to InfiniteTimeSpan.- InvalidOperationException
Thrown when this instance has already been used with a different AsyncLock.
- SynchronizationLockException
Thrown when
asyncLockis not held on entry.- TimeoutException
Thrown when
timeoutelapses before a signal is received. The lock is always re-acquired before this exception propagates.- OperationCanceledException
Thrown when
cancellationTokenis cancelled before a signal is received. The lock is always re-acquired before this exception propagates.