Search code examples
c#.netasync-awaitexecutioncontext

ExecutionContext does not flow up the call stack from async methods


Consider the following code:

private static async Task Main(string[] args)
{
    await SetValueInAsyncMethod();
    PrintValue();

    await SetValueInNonAsyncMethod();
    PrintValue();
}

private static readonly AsyncLocal<int> asyncLocal = new AsyncLocal<int>();

private static void PrintValue([CallerMemberName] string callingMemberName = "")
{
    Console.WriteLine($"{callingMemberName}: {asyncLocal.Value}");
}

private static async Task SetValueInAsyncMethod()
{
    asyncLocal.Value = 1;
    PrintValue();

    await Task.CompletedTask;
}

private static Task SetValueInNonAsyncMethod()
{
    asyncLocal.Value = 2;
    PrintValue();

    return Task.CompletedTask;
}

If you run this code inside a .NET 4.7.2 console application, you will get the following output:

SetValueInAsyncMethod: 1
Main: 0
SetValueInNonAsyncMethod: 2
Main: 2

I do understand that the differences in the output arise from the fact that SetValueInAsyncMethod is not really a method, but a state machine executed by AsyncTaskMethodBuilder which captures ExecutionContext internally and SetValueInNonAsyncMethod is just a regular method.

But even with this understanding in mind I still have some questions:

  1. Is this a bug / missing feature or an intentional design decision?
  2. Do I need to worry about this behavior while writing code that depends on AsyncLocal? Say, I want to write my TransactionScope-wannabe that flows some ambient data though await points. Is AsyncLocal enough here?
  3. Are there any other alternatives to AsyncLocal and CallContext.LogicalGetData / CallContext.LogicalSetData in .NET when it comes down to preserving values throughout the "logical code flow"?

Solution

  • Is this a bug / missing feature or an intentional design decision?

    It's an intentional design decision. Specifically, the async state machine sets the "copy on write" flag for its logical context.

    A correlation of this is that all synchronous methods belong to their closest ancestor async method.

    Do I need to worry about this behavior while writing code that depends on AsyncLocal? Say, I want to write my TransactionScope-wannabe that flows some ambient data though await points. Is AsyncLocal enough here?

    Most systems like this use AsyncLocal<T> combined with an IDisposable pattern that clears the AsyncLocal<T> value. Combining these patterns ensures it will work with either synchronous or asynchronous code. AsyncLocal<T> will work fine by itself if the consuming code is an async method; using it with IDisposable ensures it will work with both async and synchronous methods.

    Are there any other alternatives to AsyncLocal and CallContext.LogicalGetData / CallContext.LogicalSetData in .NET when it comes down to preserving values throughout the "logical code flow"?

    No.