Skip to content

[API Proposal]: Additional setting for AsyncLocal Value to cascade upwards #99153

Open

Description

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-System.Threading.Tasksneeds-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions