Do I need to manually change the value of AsyncLocal variable to "Dispose"/"Release" it on logical request end
Asked Answered
S

2

5

I read about AsyncLocal<T> from the MSDN documentation, but one point is still not clear for me.

I'm working on something like a context-bound caching/memoization, which has the simple purpose of storing data across a logical request. This is similar to the old HttpContext.Current, where data is stored across a request and will be released at the end of the request. In my case, however, I want to be environment agnostic, so the implementation isn't bound to e.g. ASP.NET MVC, ASP.NET Core, WCF, etc., while still have the ability to store and retrieve the data which is bound to the logical request without sharing it across logically different requests.

To simplify my code according to question, it looks somewhat like this:

class ContextualStorageAccessor
{
    // ConcurrentDictionary since it's okay if some parallel operations are used per logical request 
    private readonly AsyncLocal<ConcurrentDictionary<string, object>> _csm = new AsyncLocal<ConcurrentDictionary<string, object>>();

    public ConcurrentDictionary<string, object> Storage
    { 
        get
        {
            if (_csm.Value != null)
                return _csm.Value;

            _csm.Value = new ConcurrentDictionary<string, object>();

            return _csm.Value;
        }
    } 
}

The lifecycle of ContextualStorageAccessor is a singleton.

And now the question: Will I have a unique instance of Value per request? In other words, do I need to continue assigning a default value to _csm.Value manually? Or I can rely on the type of application itself (e.g., ASP.NET MVC, WCF, etc.) which will take care of it?

Or, to rephrase: Where is the end of the "async flow" and does the ExecutionContext guarantee unique values per logical call which will be automatically invalidated—in a simple scenario null will be assigned to AsyncLocal.Value—by the end of the logical call (for ASP.NET MVC, a web request; for WCF, an operation) if AsyncLocal.Value is used?

Sulfuric answered 21/4, 2020 at 10:39 Comment(0)
S
6

If you try this code you'll see that every new async flow creates a new value. So the answer should be: Yes you should have a unique value per request.

private static readonly AsyncLocal<object> Item = new AsyncLocal<object>();

public static async Task Main()
{
    async Task Request()
    {
        if (Item.Value is {})
        {
            Console.WriteLine("This should never happen.");
            throw new InvalidOperationException("Value should be null here.");
        }

        Item.Value = new object();
    }

    await Task.Run(Request); // Just to be sure that Item.Value is initialized once.

    await Task.WhenAll(
        Task.Run(Request),
        Task.Run(Request),
        Task.Run(Request),
        Task.Run(Request),
        Task.Run(Request));

    Console.WriteLine("finished");
}

DEMO

But I tried a little more complex example to determine where the async flow ends. The code is very simple but the massive use of Console.WriteLine makes it a little bit confusing.

public class Program
{
    public static async Task Main()
    {
        await Task.Run(async () => 
            {
                Console.WriteLine("Async flow entered...");             

                // Init async value
                if (Cache.Instance.Item.Value is {})
                    throw new InvalidOperationException("The async flow has just startet. A value should not be initialized.");

                var newValue = new object();
                Console.WriteLine($"Create: value = #{RuntimeHelpers.GetHashCode(newValue)}");

                Cache.Instance.Item.Value = newValue;
                await Foo();

                Console.WriteLine("Async flow exitted.");
            });

        Console.WriteLine("Main finished.\n\n");
    }

    private static async Task Foo()
    {
        Console.WriteLine($"Foo: entered...");
        await Bar();

        Console.WriteLine($"Foo: getting value...");
        var knownValue = Cache.Instance.Item.Value;
        Console.WriteLine($"Foo: value = #{RuntimeHelpers.GetHashCode(knownValue)}");
        Console.WriteLine($"Foo: exitted.");
    }

    private static async Task Bar()
    {
        Console.WriteLine($"Bar: entered...");
        await Task.CompletedTask;
        Console.WriteLine($"Bar: exitted.");
    }
}

public sealed class Cache
{
    public static Cache Instance = new Cache();

    public AsyncLocal<object> Item { get; } = new AsyncLocal<object>(OnValueChanged);

    private static void OnValueChanged(AsyncLocalValueChangedArgs<object> args)
    {
        Console.WriteLine($"OnValueChanged! Prev: #{RuntimeHelpers.GetHashCode(args.PreviousValue)} Current: #{RuntimeHelpers.GetHashCode(args.CurrentValue)}");
    }
}

DEMO

The output of that code is:

Async flow entered...
Create: value = #6044116
OnValueChanged! Prev: #0 Current: #6044116
Foo: entered...
Bar: entered...
Bar: exitted.
Foo: getting value...
Foo: value = #6044116
Foo: exitted.
Async flow exitted.
OnValueChanged! Prev: #6044116 Current: #0
Main finished.

The value flow is expected. This answers the question whether the value is assigned to a default value - yes it is. The async flow ends where Task.Run finishes and that's the point where the value is assinged to default.

But things getting interesting if you change await Task.CompletedTask; in Bar to await Task.Delay(1);. The output looks much different:

Async flow entered...
Create: value = #6044116
OnValueChanged! Prev: #0 Current: #6044116
Foo: entered...
Bar: entered...
OnValueChanged! Prev: #6044116 Current: #0
OnValueChanged! Prev: #0 Current: #6044116
Bar: exitted.
Foo: getting value...
Foo: value = #6044116
Foo: exitted.
Async flow exitted.
OnValueChanged! Prev: #6044116 Current: #0
Main finished.


OnValueChanged! Prev: #0 Current: #6044116
OnValueChanged! Prev: #6044116 Current: #0

The weird parts start after Bar has been entered. It looks that await Task.Delay(1) breaks the async flow. But the value is restored corretly. Here I can at least make up an explanation by guessing.

And the real interresting thing happens after main has been finished. The value is restored and cleared one more time... I lack imagination here. I've absolutelly no idea why and how the value is restored after Task.Run has been finished and the async flow should also be finished. This gives me the feeling that the GC cannot clear the object until the program has ended.

Stearne answered 21/4, 2020 at 12:57 Comment(2)
Wow, thanks for such nice explanation and samples, seems I missed the constructor with valuechanged callback, really awesome, big thanks! And about the first part of the second part, I a little bit played with your sample and seems that AsyncLocal implicitly changes the value on every await, if the awaited task is not completed yet. And seems that execution context creates a copy of the current state of every asynlocal for next operation. And seems the values are not restored but just preserved. And seems value changed triggered because of some other await on program end, or who knows...Sulfuric
@СаняТолстиков I'm not that surprised that awaits will come after the main method has been finished. I'm surprised by the fact that the created object is still available even though the mail method has already ended. The async flow that created this instance has been ended at this point - for sure (I hope). This means that the GC cannot free the memory of those objects - and that worries me a little.Stearne
T
0

Just like @Sebastian Schumann mentioned, The GC seems to not happen immediately after the method that triggered the async flow ends. This seems to corrupt my object in a Worker-Service based application. I can get the object stored from the previous repetition even after manually clearing the Context.Value ,given the object has already gone out of scope.

You can check out my Question here, It also contains an MRE.

Troth answered 7/5, 2024 at 10:19 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.