5

I use a similar pattern as HttpContextAccessor

The simplified version is as follows, Console.WriteLine(SimpleStringHolder.StringValue) is not supposed to be null.

public class SimpleStringHolder
{
    private static readonly AsyncLocal<ValueHolder> CurrentHolder = new AsyncLocal<ValueHolder>();

    public static string StringValue
    {
        get => CurrentHolder.Value?.StringValue;

        set
        {
            var holder = CurrentHolder.Value;
            if (holder != null)
            {
                holder.StringValue = null;
            }

            if (value != null)
            {
                CurrentHolder.Value = new ValueHolder() { StringValue = value };
            }
        }
    }

    private class ValueHolder
    {
        public string StringValue;
    }
}
class Program
{
    private static readonly AsyncLocal<string> currentValue = new AsyncLocal<string>();

    public static void Main(string[] args)
    {
        var task = Task.Run(async () => await OutterAsync());
        task.Wait();
    }

    public static async Task OutterAsync()
    {
        SimpleStringHolder.StringValue = "1";
        await InnerAsync();
        Console.WriteLine(SimpleStringHolder.StringValue); //##### the value is gone ######
    }

    public static async Task InnerAsync()
    {
        var lastValue = SimpleStringHolder.StringValue;
        await Task.Delay(1).ConfigureAwait(false);
        SimpleStringHolder.StringValue = lastValue; // comment this line will make it work
        Console.WriteLine(SimpleStringHolder.StringValue); //the value is still here
    }
}

In the above code, OutterAsync invokes an async method InnerAsync, in InnerAsync the StringValue is set, which makes AsyncLocal loses its context in OutterAsync Console.WriteLine(SimpleStringHolder.StringValue); is null.

I think the magic is in the SimpleStringHolder's property set, removing the following code will make things right.

if (holder != null)
{
    holder.StringValue = null;
}

The above code works as expected.

Please help me understand what sorcery is this?

HooYao
  • 554
  • 5
  • 19
  • 1
    @mjwills: I couched my answer below in more mechanical terms. Like I said, the parameter-passing metaphor is good (I find it very useful conceptually), but since it's not really a parameter, I can see how it might not be the best path to understanding for everyone. – Peter Duniho Nov 13 '20 at 06:34
  • Also possibly useful: [a post I wrote about the semantics of "async local" (before that type existed)](https://blog.stephencleary.com/2013/04/implicit-async-context-asynclocal.html). – Stephen Cleary Nov 24 '20 at 22:59

1 Answers1

10

AsyncLocal<T> exists to provide a mechanism to preserve values within an asynchronous execution context. Key to this are two factors involved in your example:

  1. An await allows a method to return to the caller, which could change the context. With the older ThreadLocal<T> type, when execution returns control to the method, it could be in a different thread, even though from the async point of view the context is the same. Using AsyncLocal<T> ensures that the state of the context is restored when the await returns control to the method after the awaitable object has completed.
  2. Until something happens that would require the context to change, the current state of an AsyncLocal<T> object is whatever it was previously. I.e. a method essentially inherits the state the object was in when it was called. If you're dealing with simple values, no surprises lurk, but in the case of a reference type like your ValueHolder type, the only thing that AsyncLocal<T> is keeping track of is the reference to that object. There is still only one copy of the object, and changes to the state of any given such object work as they always do with or without asynchronous contexts floating around (i.e. they are seen by any reference to that object).

So, in the code example you've provided:

  1. OutterAsync() sets the StringValue property to "1", which results in a new ValueHolder object being created, and the StringValue property of that object being set to "1".
  2. OutterAsync() calls InnerAsync(). That method then retrieves the string reference from the holder (indirectly…i.e. by going through the SimpleStringHolder.StringValue property). Since no changes to the value nor the context have been made at this point, the same ValueHolder object is used in this case, so you get "1" back.
  3. InnerAsync() awaits an asynchronous task, which causes a new execution context to be created for the purposes of isolating changes made to the AsyncValue<T> object to that context. From this point on, changes to the object are not seen by code in a different context. For example, the code executing in the OutterAsync() method.
  4. After the async task completes in InnerAsync(), that method then sets a new value to the SimpleStringHolder.StringValue property. Because the earlier context was inherited, when the setter sets holder.StringValue to null, it is setting the property of the object that was created in OutterAsync(). But… because the code is in a new context, when the setter then assigns a new value to the CurrentHolder.Value property, that change is isolated to that context.
  5. When the InnerAsync() method finally completes, this completes the task that the OutterAsync() method's await was waiting on. This causes the AsyncValue<T> to restore its state to the OutterAsync() method's context, which is different from the context that was in InnerAsync() when it updated the SimpleStringHolder.StringValue value. And specifically, this restored state is a reference to the ValueHolder object that was originally set in the SimpleStringHolder when the holder.StringValue property got set to null.
  6. So, when OutterAsync() then goes to look at the property value, it finds it set to null. Because it was set to null.

In your own experimenting, you can either remove the null assignment altogether, or simply omit the assignment to SimpleStringHolder.StringValue after the InnerAsync()'s await statement (because if you don't make the assignment, then the null assignment is never executed). Either way, the null assignment doesn't happen, and so the previously-assigned value remains.

But if you do make the null assignment, the caller OutterAsync() is going to have its context restored, and have the holder object reference then restored, and that holder object's own string reference had already been set to null, so that's what OutterAsync() sees.

Related reading:
What's the effect of AsyncLocal in non async/await code?
Why does AsyncLocal return different results when code is refactored slightly?
Does AsyncLocal also do the things that ThreadLocal does?

Peter Duniho
  • 68,759
  • 7
  • 102
  • 136