ExecutionContext does not flow up the call stack from async methods
Asked Answered
K

2

7

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"?
Kinfolk answered 2/4, 2019 at 17:6 Comment(0)
B
7

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.

Burweed answered 2/4, 2019 at 20:45 Comment(3)
Someone please explain this sentence in more details if you don't mind: "using it with IDisposable ensures it will work with both async and synchronous methods"Preternatural
@Ostati: Setting AsyncLocal<T>.Value from a synchronous method actually changes the value for the first asynchronous method travelling up the call stack. So if AAsync() calls B() which sets asyncLocal1.Value, then AAsync() will see that updated value. Using IDisposable gives you an explicit scope within which that AsyncLocal<T>.Value update exists, and it is reset to its previous value when the disposable is disposed.Burweed
I still can't understand this situation completely. Could you please take a look at this question? #66501237Preternatural
F
8

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.

Framboise answered 2/4, 2019 at 17:58 Comment(0)
B
7

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.

Burweed answered 2/4, 2019 at 20:45 Comment(3)
Someone please explain this sentence in more details if you don't mind: "using it with IDisposable ensures it will work with both async and synchronous methods"Preternatural
@Ostati: Setting AsyncLocal<T>.Value from a synchronous method actually changes the value for the first asynchronous method travelling up the call stack. So if AAsync() calls B() which sets asyncLocal1.Value, then AAsync() will see that updated value. Using IDisposable gives you an explicit scope within which that AsyncLocal<T>.Value update exists, and it is reset to its previous value when the disposable is disposed.Burweed
I still can't understand this situation completely. Could you please take a look at this question? #66501237Preternatural

© 2022 - 2024 — McMap. All rights reserved.