This article explains how AsyncLocal values are stored in .NET and how to retrieve them from a memory dump. Note that the code provided is written for .NET 5, but should be able to work for .NET Framework with minor modifications (the name of some internal fields have changed across versions).

Preparing the memory dump

First thing first, we need to prepare the memory dump that will serve as example for the whole article. For that, I used the following code:

using System;
using System.Diagnostics;
using System.Threading;

namespace ClientApp
{
    internal class Program
    {
        private const int Total = 17;

        private readonly AsyncLocal<int>[] _asyncLocals;

        public Program()
        {
            _asyncLocals = new AsyncLocal<int>[Total];

            for (int i = 0; i < _asyncLocals.Length; i++)
            {
                _asyncLocals[i] = new AsyncLocal<int>();
            }
        }

        static void Main(string[] args)
        {
            var program = new Program();

            var countdown = new CountdownEvent(Total);

            for (int i = 0; i < Total; i++)
            {
                var thread = new Thread(state =>
                {
                    var index = (int)state;

                    Thread.CurrentThread.Name = index.ToString();

                    for (int j = 0; j <= index; j++)
                    {
                        program._asyncLocals[j].Value = j;
                    }

                    countdown.Signal();

                    Thread.Sleep(Timeout.Infinite);
                });

                thread.Start(i);
            }

            countdown.Wait();

            Console.WriteLine("Ready to capture dump. Process id {0}", Process.GetCurrentProcess().Id);
            Console.ReadLine();
        }
    }
}

The program creates an array of AsyncLocal<int> of 17 elements. 17 is not a random number, its significance will become clear in a moment. Then the program creates a few threads, and have them store a different number of asynclocal values, such as thread 1 will store 1 asynclocal value, thread 2 will store 2, and so on until thread 17. When all the values are initialized, the program displays a prompt and pauses to give time to capture a memory dump. This can be done using the procdump tool:

$ procdump -ma <pid>

Inspecting the memory dump with WinDbg

Now that we have our memory dump, let’s start by inspecting it with WinDbg. We’ll start by retrieving the array of AsyncLocal<int>. Recent versions of WinDbg automatically load the SOS extension when dealing with .NET Core memory dumps, so that’s one less thing to do. We can use the dumpheap command to locate our instance of Program, and from there access the array.

For that, we first use dumpheap -stat -type ClientApp.Programto get the address of the MT, and then we feed it to dumpheap -mt to get the address of the instance:

0:000> !dumpheap -stat -type ClientApp.Program
Statistics:
              MT    Count    TotalSize Class Name
00007ffb8e8e4968        1           24 ClientApp.Program
00007ffb8e9141d0        1           40 ClientApp.Program+<>c__DisplayClass3_0
Total 2 objects

0:000> !dumpheap -mt 00007ffb8e8e4968 
         Address               MT     Size
000001c53436bb08 00007ffb8e8e4968       24     

Statistics:
              MT    Count    TotalSize Class Name
00007ffb8e8e4968        1           24 ClientApp.Program
Total 1 objects

Then we use dumpobj to dump the instance and from there we get the address of the value stored in the _asyncLocals field:

0:000> !dumpobj 000001c53436bb08
Name:        ClientApp.Program
MethodTable: 00007ffb8e8e4968
EEClass:     00007ffb8e8fa988
Size:        24(0x18) bytes
File:        C:\Users\kevin.gosse\source\repos\ClrmdAsyncLocal\ClientApp\bin\Release\net5.0\ClientApp.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb8e91b5c0  4000004        8 ...ivate.CoreLib]][]  0 instance 000001c53436bb78 _asyncLocals

0:000> !dumpobj 000001c53436bb78
Name:        System.Threading.AsyncLocal`1[[System.Int32, System.Private.CoreLib]][]
MethodTable: 00007ffb8e91b5c0
EEClass:     00007ffb8e8067c8
Size:        160(0xa0) bytes
Array:       Rank 1, Number of elements 17, Type CLASS (Print Array)
Fields:
None

Now we can dump the contents of the array using dumparray and inspect the individual items:

0:000> !dumparray 000001c53436bb78
Name:        System.Threading.AsyncLocal`1[[System.Int32, System.Private.CoreLib]][]
MethodTable: 00007ffb8e91b5c0
EEClass:     00007ffb8e8067c8
Size:        160(0xa0) bytes
Array:       Rank 1, Number of elements 17, Type CLASS
Element Methodtable: 00007ffb8e91b4d0
[0] 000001c53436bc18
[1] 000001c53436bc30
[2] 000001c53436bc48
[3] 000001c53436bc60
[4] 000001c53436bc78
[5] 000001c53436bc90
[6] 000001c53436bca8
[7] 000001c53436bcc0
[8] 000001c53436bcd8
[9] 000001c53436bcf0
[10] 000001c53436bd08
[11] 000001c53436bd20
[12] 000001c53436bd38
[13] 000001c53436bd50
[14] 000001c53436bd68
[15] 000001c53436bd80
[16] 000001c53436bd98

0:000> !dumpobj 000001c53436bc18
Name:        System.Threading.AsyncLocal`1[[System.Int32, System.Private.CoreLib]]
MethodTable: 00007ffb8e91b4d0
EEClass:     00007ffb8e934ce8
Size:        24(0x18) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.10\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb8e940048  4000a28        8 ...Private.CoreLib]]  0 instance 0000000000000000 m_valueChangedHandler

Immediately we can see what the issue is going to be: in our instance of AsyncLocal there is… nothing. Literally nothing at all is stored inside, unless you provided a valueChanged handler (in which case, you will only find the address of your handler).

So where are those values stored? On the Thread object itself. The instance of AsyncLocal is just used as a key to retrieve the value from the execution context. If we take one thread at random, we can find all the asynclocal values from that thread stored in the m_LocalValues field of the execution context:

0:000> !DumpObj /d 000001c53436be40
Name:        System.Threading.Thread
MethodTable: 00007ffb8e8ca8c0
EEClass:     00007ffb8e8b67d0
Size:        72(0x48) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.10\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb8e91c1e8  40009e6        8 ....ExecutionContext  0 instance 000001c53436dea0 _executionContext
0000000000000000  40009e7       10 ...ronizationContext  0 instance 0000000000000000 _synchronizationContext
00007ffb8e8c7a90  40009e8       18        System.String  0 instance 000001c53436d4f0 _name
00007ffb8e8c1a30  40009e9       20      System.Delegate  0 instance 0000000000000000 _delegate
00007ffb8e800c68  40009ea       28        System.Object  0 instance 0000000000000000 _threadStartArg
00007ffb8e80ee60  40009eb       30        System.IntPtr  1 instance 000001C534165830 _DONT_USE_InternalThread
00007ffb8e80b258  40009ec       38         System.Int32  1 instance                2 _priority
00007ffb8e80b258  40009ed       3c         System.Int32  1 instance                4 _managedThreadId
00007ffb8e80b258  40009ef      914         System.Int32  1   static                7 s_optimalMaxSpinWaitsPerSpinIteration
00007ffb8e807238  40009f0      918       System.Boolean  1   static                0 s_isProcessorNumberReallyFast
0000000000000000  40009f1      760                       0   static 0000000000000000 s_asyncLocalPrincipal
00007ffb8e8ca8c0  40009f2       18 ....Threading.Thread  0 TLstatic  t_currentThread
    >> Thread:Value 359c:000001c53436bf88 1940:000001c53436be40 7214:000001c53436c050 7034:000001c53436c128 6e38:000001c53436c200 7220:000001c53436c2d8 5684:000001c53436c3b0 4364:000001c53436c488 63ec:000001c53436c560 1970:000001c53436c638 6bcc:000001c53436c710 6890:000001c53436c7e8 69d0:000001c53436c8c0 1dc8:000001c53436c998 1db4:000001c53436ca70 6aa8:000001c53436cb48 6b94:000001c53436cc20 628:000001c53436ccf8 <<

0:000> !DumpObj /d 000001c53436dea0
Name:        System.Threading.ExecutionContext
MethodTable: 00007ffb8e91c1e8
EEClass:     00007ffb8e935090
Size:        40(0x28) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.10\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb8e91e7c8  4000a69        8 ...syncLocalValueMap  0 instance 000001c53436de80 m_localValues
00007ffb8e91eb78  4000a6a       10 ...ing.IAsyncLocal[]  0 instance 0000000000000000 m_localChangeNotifications
00007ffb8e807238  4000a6b       18       System.Boolean  1 instance                0 m_isFlowSuppressed
00007ffb8e807238  4000a6c       19       System.Boolean  1 instance                0 m_isDefault
00007ffb8e91c1e8  4000a67      7f8 ....ExecutionContext  0   static 000001c53436bfd0 Default
00007ffb8e91c1e8  4000a68      800 ....ExecutionContext  0   static 000001c53436c010 DefaultFlowSuppressed

0:000> !DumpObj /d 000001c53436de80
Name:        System.Threading.AsyncLocalValueMap+OneElementAsyncLocalValueMap
MethodTable: 00007ffb8e9408b8
EEClass:     00007ffb8e935de8
Size:        32(0x20) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.10\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb8e91b248  4000a2d        8 ...ading.IAsyncLocal  0 instance 000001c53436bc18 _key1
00007ffb8e800c68  4000a2e       10        System.Object  0 instance 000001c53436de68 _value1

The _key1 field contains the address of the AsyncLocal instance, and the _value1 field contains the value. So to check all the values stores in an AsyncLocal instance, we need to inspect every single thread. Not only that, but the way the values are stored is going to change depending on how many different AsyncLocal values are associated to a given thread. Managing that in WinDbg is going to be very tricky, so it’s time to switch to ClrMD.

Inspecting the memory dump with ClrMD

Our ClrMD program will open the memory dump, find the AsyncLocal array, and extract all the values for every thread. To make things easier, we’re going to use the DynaMD library.

First thing first, we open the memory dump, and we locate the first (and only) instance of AsyncLocal<int>[] thanks to the GetProxies extension method. Then we iterate on every instance and give them to a helper method that will extract a list of thread ids and their associated values:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Microsoft.Diagnostics.Runtime;

namespace ClrmdAsyncLocal
{
    internal class Program
    {
        static void Main(string[] args)
        {
            const string path = @"C:\Users\kevin.gosse\Desktop\blog\asynclocal\ClientApp.exe_210916_114422.dmp";

            var target = DataTarget.LoadDump(path);
            var runtime = target.ClrVersions.First().CreateRuntime();
            var heap = runtime.Heap;

            var array = heap.GetProxies<AsyncLocal<int>[]>().First();

            foreach (var item in array)
            {
                Console.WriteLine($"Values stored in AsyncLocal {item}:");

                foreach (var (threadId, value) in ExtractAsyncLocalValues(heap, (ulong)item))
                {
                    Console.WriteLine($" - {threadId}: {(int)value}");
                }
            }

            Console.ReadLine();
        }

        private static IEnumerable<(int threadId, dynamic value)> ExtractAsyncLocalValues(ClrHeap heap, ulong address)
        {
            // TODO
        }
    }
}

The helper method will iterate on every thread, inspect the AsyncLocal values stored in the execution context, and filter them to only return the one associated to the given instance:

private static IEnumerable<(int threadId, dynamic value)> ExtractAsyncLocalValues(ClrHeap heap, ulong address)
{
    foreach (var thread in heap.GetProxies<Thread>())
    {
        int threadId = thread._managedThreadId;
        var localValues = thread._executionContext?.m_localValues;

        foreach ((dynamic key, dynamic value) pair in ReadAsyncLocalStorage(localValues))
        {
            if ((ulong)pair.key == address)
            {
                yield return (threadId, pair.value);
                break;
            }
        }
    }
}

private static IEnumerable<(dynamic key, dynamic value)> ReadAsyncLocalStorage(dynamic storage)
{
    // TODO        
}

Now we just need to extract the values from the AsyncLocal storage… Which is where things get fancy. It turns out there’s not one but many different storage implementations, depending on how many values the current thread is storing.

From 1 to 3 values

If a given thread stores one to three different AsyncLocal values, it will use an instance of OneElementAsyncLocalValueMap, TwoElementAsyncLocalValueMap, or… ThreeElementAsyncLocalValueMap. Those types just have a list of fields to store the keys and the values, _key1 / _key2 / _key3 and _value1 / _value2 / _value3 respectively. Extracting the values is just a matter of reading those fields:

private static IEnumerable<(dynamic key, dynamic value)> ReadOneElementAsyncLocalValueMap(dynamic storage)
{
    yield return (storage._key1, storage._value1);
}

private static IEnumerable<(dynamic key, dynamic value)> ReadTwoElementAsyncLocalValueMap(dynamic storage)
{
    yield return (storage._key1, storage._value1);
    yield return (storage._key2, storage._value2);
}

private static IEnumerable<(dynamic key, dynamic value)> ReadThreeElementAsyncLocalValueMap(dynamic storage)
{
    yield return (storage._key1, storage._value1);
    yield return (storage._key2, storage._value2);
    yield return (storage._key3, storage._value3);
}

From 4 to 16 values

If a given thread stores four to sixteen values, it will thankfully stop declaring a field for every value and instead use an array-backed storage: MultiElementAsyncLocalValueMap. All the keys and values are stored in the _keyValues field as an array of KeyValuePair<IAsyncLocal, object>. The array is allocated with exactly the right size, so we just need to iterate on it to return the contents. It’s very straightforward with DynaMD:

private static IEnumerable<(dynamic key, dynamic value)> ReadMultiElementAsyncLocalValueMap(dynamic storage)
{
    foreach (var kvp in storage._keyValues)
    {
        yield return (kvp.key, kvp.value);
    }
}

More than 16 values

To read from an AsyncLocal storage, the runtime has to find the value associated to a given key. Iterating on an array becomes less and less efficient as the number of values increases, so the runtime fallbacks on a ManyElementAsyncLocalValueMap when that number gets above 16. This type directly inherits from Dictionary<IAsyncLocal, object>, so to extract the values we need to iterate on the _entries field:

private static IEnumerable<(dynamic key, dynamic value)> ReadManyElementAsyncLocalValueMap(dynamic storage)
{
    foreach (var entry in storage._entries)
    {
        if (entry.key != null)
        {
            yield return (entry.key, entry.value);
        }
    }
}

Putting everything together

At this point, you may be wondering how everything fits together. Every time you add or remove a value from an AsyncLocal instance, the runtime will allocate a new storage based on the new number of elements, and copy everything from the old to the new storage. This makes adding/removing values very inefficient, so use AsyncLocal with care.

In any case, we now have helper methods for every possible AsyncLocal storage, so we can just call the appropriate one in our ReadAsyncLocalStorage method:

private static IEnumerable<(dynamic key, dynamic value)> ReadAsyncLocalStorage(dynamic storage)
{
    if (storage == null)
    {
        return Enumerable.Empty<(dynamic key, dynamic value)>();
    }

    ClrType type = storage.GetClrType();

    return type.Name switch
    {
        "System.Threading.AsyncLocalValueMap+OneElementAsyncLocalValueMap" => ReadOneElementAsyncLocalValueMap(storage),
        "System.Threading.AsyncLocalValueMap+TwoElementAsyncLocalValueMap" => ReadTwoElementAsyncLocalValueMap(storage),
        "System.Threading.AsyncLocalValueMap+ThreeElementAsyncLocalValueMap" => ReadThreeElementAsyncLocalValueMap(storage),
        "System.Threading.AsyncLocalValueMap+MultiElementAsyncLocalValueMap" => ReadMultiElementAsyncLocalValueMap(storage),
        "System.Threading.AsyncLocalValueMap+ManyElementAsyncLocalValueMap" => ReadManyElementAsyncLocalValueMap(storage),
        _ => throw new InvalidOperationException($"Unexpected asynclocal storage type: {type.Name}")
    };
}

And that’s it! Now we can run the program and inspect the values for every AsyncLocal instance.

The full code is available here.