Description
openedon Mar 1, 2024
Background and motivation
This is based on my own misunderstanding of AsyncLocal<T>
.
I was expecting AsyncLocal<T>
to be a single piece of state that is passed across all classes/methods operating on the same async scope (e.g. a web request all the way down and back up again). This assumption was based on how ThreadLocal<T>
works - I assumed AsyncLocal<T>
was an equivalent for async
code.
I was using this to add a collection of events in my domain classes that propagate upwards to my web request handler to be recorded in the database. I have now come to understand that the value is cascaded down, but not upwards.
So if something down the call chain is the first to access my ThreadLocal<List<Func<object>>>
then the value will never make its way back up to the initiator so it can receive the values added and store them in the database.
A workaround is to ensure the TheadLocal<T>.Value
is accessed early on in the request - but as this is a library this is not something I can ensure, and a default null
value means "No data was added" so I can't guard against null
.
Example of incorrect assumption
SomeClass.Sequence = 'A';
await SetSecondValueAsync();
Console.WriteLine($"Step 5={SomeClass.Sequence}");
static async Task SetSecondValueAsync()
{
Console.WriteLine($"Step 1={SomeClass.Sequence}");
SomeClass.Sequence = 'B';
await SetThirdValueAsync();
Console.WriteLine($"Step 4={SomeClass.Sequence}");
}
static Task SetThirdValueAsync()
{
Console.WriteLine($"Step 2={SomeClass.Sequence}");
SomeClass.Sequence = 'C';
Console.WriteLine($"Step 3={SomeClass.Sequence}");
return Task.CompletedTask;
}
public static class SomeClass
{
#if UseAsyncLocal
private static readonly AsyncLocal<char> SequenceHolder = new();
#else
private static readonly ThreadLocal<char> SequenceHolder = new();
#endif
public static char Sequence
{
get => SequenceHolder.Value; set => SequenceHolder.Value = value;
}
}
ThreadLocal<T>
output
- Step 1=A
- Step 2=B
- Step 3=C
- Step 4=C
- Step 5=C
AsyncLocal<T>
output
- Step 1=A
- Step 2=B
- Step 3=C
- Step 4=C
- Step 5=A
API Proposal
public class AsyncLocal<T>
{
public AsyncLocal(bool propagateUpwards = false)
{
....
}
public AsyncLocal(Action<AsyncLocalValueChangedArgs<T>>? valueChangedHandler, bool propagateUpwards = false)
{
...
}
// other code
API Usage
// My static DomainEvents class, using `propagateUpwards = `true`
public static class DomainEvents
{
private static readonly AsyncLocal<List<Func<object>> Holder = new(propagateUpwards: true);
public static void Add(Func<object> factory)
{
if (Holder.Value is null)
Holder.Value = new List<Func<object>>();
Holder.Value.Add(factory);
}
public static void GetEventAndClear(out Func<object>[] eventFactories)
{
eventFactories = Holder.Value?.ToArray() ?? [];
Holder.Value.Clear();
}
}
// Some code executed from a web request
public async Task EntryPoint()
{
await HandleAsync();
DomainEvents.GetAndClear(out Func<object>[] eventFactories);
DbContext.DomainEvents.AddRange(eventFactories.Select(factory => factory.Invoke());
await DbContext.SaveChangesAsync();
}
private async Task HandleAsync()
{
Customer customer = await DbContext.Customers.FirstAsync();
customer.Deactivate();
}
// Code in the Customer entity
public void Deactivate()
{
Customer.Status = CustomerStatus.Deactivated
DomainEvents.Add(() => new CustomerDeactivatedEvent());
}
Alternative Designs
Another possibility would be to create an alternative class named something like AsyncStatic<T>
that works in the same way as ThreadStatic<T>
but across async calls.
Risks
No response