Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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"?
like image 621
RX_DID_RX Avatar asked Apr 02 '19 17:04

RX_DID_RX


2 Answers

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.

like image 50
Stephen Cleary Avatar answered Nov 14 '22 23:11

Stephen Cleary


This seems like an intentional decision to me.

As you already know, SetValueInAsyncMethod gets compiled into a state-machine that implicitly captures the current ExecutionContext. When you change the AsyncLocal-variable, that change does not get "flowed" back to the calling function. In contrast, SetValueInNonAsyncMethod is not async and therefore not compiled into a state-machine. Therefore the ExecutionContext is not captured and any changes to AsyncLocal-variables are visible to the caller.

You can capture the ExecutionContext yourself as well, if you need this for any reason:

private static Task SetValueInNonAsyncMethodWithEC()
{
    var ec = ExecutionContext.Capture(); // Capture current context into ec
    ExecutionContext.Run(ec, _ => // Use ec to run the lambda
    {
        asyncLocal.Value = 3;
        PrintValue();
    });
    return Task.CompletedTask;
}

This will output a value of 3, while the Main will output 2.

Of course it is way easier to simply convert SetValueInNonAsyncMethod to async to have the compiler do this for you.

With regards to code that uses AsyncLocal (or CallContext.LogicalGetData for that matter), it is important to know that changing the value in a called async method (or any captured ExecutionContext) will not "flow back". But you can of course still access and modify the AsyncLocal as long as you do not reassign it.

like image 25
user1781290 Avatar answered Nov 15 '22 00:11

user1781290