Yes, strictly speaking, we don’t have those, but we can use a rather old trick to work around that.
Say we have an application that has a set of services running inside it. Each service can have a state : Started, Stopping, Stopped. We can express this with an interface.
public interface IServiceLifetime
{
CancellationToken Started { get; }
CancellationToken Stopping { get; }
CancellationToken Stopped { get; }
void Stop();
void Start();
}
This interface is inspired by a IHostApplicationLifetime, which exposes an entire application lifetime states in a similar way.
When service reaches a specific state, the corresponding token gets signalled, and by checking the state of the token we can infer the current state of a service. We can await the desired state of an application instead of polling for it as follows.
public interface IServiceLifetime
{
// ...
static Task WaitForState(Func<IServiceLifetime, CancellationToken> sateSelector)
{
var stateToWaitFor = applicationStateSelector(this);
if (stateToWaitFor.IsCancellationRequested)
{
return Task.CompletedTask;
}
var tcs = new TaskCompletionSource();
stateToWaitFor.Register(() => tcs.TrySetResult());
return tcs.Task;
}
}
But what if the WaitForState is called multiple times with the same selector?
Then a bunch of TaskCompletionSource instances will be created just to be thrown away. We don’t want that. Instead, we want to cache the TaskCompletionSource instance for each awaited state in field. We can create a static interface field to hold this state but when we have multiple IServiceLifetime implementations that can be created and disposed of, tracking lifetimes of those instances and updating our static field becomes a pain.
If only we had a BCL-provided data structure that can attach data to the specific instance in a sort of dynamic way, removing entries for the dead and collected instances… Oh, wait, we have a ConditionalWeakTable that does exactly that!
public sealed class ConditionalWeakTable<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
where TKey : class
where TValue : class?
ConditionalWeakTable can be used to define a mapping from an object instance to any type of value. This allows us to treat any object as an ExpandoObject object, dynamically attaching values to it. When the object instance is garbage collected, any attached values are automatically cleaned up as well. ConditionalWeakTable is notified of object collection by the garbage collector, making this a genuine push model primitive.
There are two nuances to using the ConditionalWeakTable though.
First – since we are attaching the value to an instance of an object, the keys of the table should have reference equality.
Second – IDisposable is ignored on TValue, which means that ConditionalWeakTable will not dispose any IDisposable values attached to TKey instances when they are cleaned up from the table.
We are going to attach a TaskCompletionSource, which is not IDisposable to the instance of a IServiceLifetime which in our case has a reference equality, so we are good to proceed with our final, clean implementation.
public interface IServiceLifetime
{
private static readonly ConditionalWeakTable<
IServiceLifetime, ConcurrentDictionary<CancellationToken, Task>
> _waitTasksByInstance = [];
CancellationToken Started { get; }
CancellationToken Stopping { get; }
CancellationToken Stopped { get; }
void Stop();
void Start();
Task WaitForState(Func<IServiceLifetime, CancellationToken> stateSelector)
{
var stateToWaitFor = stateSelector(this);
var waitTasks = _waitTasksByInstance.GetValue(this, _ => new ConcurrentDictionary<CancellationToken, Task>());
return waitTasks.GetOrAdd(stateToWaitFor, CreateWaitTask);
}
private static Task CreateWaitTask(CancellationToken stateToken)
{
if (stateToken.IsCancellationRequested)
{
return Task.CompletedTask;
}
var tcs = new TaskCompletionSource();
stateToken.Register(() => tcs.TrySetResult());
return tcs.Task;
}
}
Quite a neat structure, that’s been with us since the olden days of .NET Framework 4.0!