SoFunction
Updated on 2025-03-07

.NET Core uses AsyncLocal to implement shared variables in detail

Introduction

If we need the entire program to share a variable, we only need to place the variable on a static variable of a static class (not meeting our needs, the entire program is a fixed value on a static variable). In our web application, each web request server allocates it an independent thread, and how to implement information such as users, tenants, etc. isolate them in these independent threads. This is what we are going to talk about today's thread local storage. .NET provides us with two classes ThreadLocal and AsyncLocal for thread-local storage. We can clearly see the difference between the two by looking at the following examples:

[TestClass]
public class TastLocal {
    private static ThreadLocal<string> threadLocal = new ThreadLocal<string>();
    private static AsyncLocal<string> asyncLocal = new AsyncLocal<string>();
    [TestMethod]
    public void Test() {
         = "threadLocal";
         = "asyncLocal";
        var threadId = ;
        (() => {
            var threadId = ;
            ($"StartNew:threadId:{ threadId}; threadLocal:{}");
            ($"StartNew:threadId:{ threadId}; asyncLocal:{}");
        });
        CurrThread();
    }
    public void CurrThread() {
        var threadId = ;
        ($"CurrThread:threadId:{threadId};threadLocal:{}");
        ($"CurrThread:threadId:{threadId};asyncLocal:{}");
    }
}

Output result:

CurrThread:threadId:4;threadLocal:threadLocal
StartNew:threadId:11; threadLocal:
CurrThread:threadId:4;asyncLocal:asyncLocal
StartNew:threadId:11; asyncLocal:asyncLocal

From the above results, we can see that both ThreadLocal and AsyncLocal can implement thread-based local storage. However, when thread switches, only AsyncLocal can still retain the original value. In web development, we will have many asynchronous scenarios, in which thread switching may occur. So we use AsyncLocal to implement shared variables under web applications.

AsyncLocal Interpretation

Official Documentation

Source code address

Source code view:

public sealed class AsyncLocal&lt;T&gt; : IAsyncLocal
{
    private readonly Action&lt;AsyncLocalValueChangedArgs&lt;T&gt;&gt;? m_valueChangedHandler;
    //
    // No parameter constructor    //
    public AsyncLocal()
    {
    }
    //
    // Construct an AsyncLocal<T> with a delegate that is called when the current value changes    // On any thread    //
    public AsyncLocal(Action&lt;AsyncLocalValueChangedArgs&lt;T&gt;&gt;? valueChangedHandler)
    {
        m_valueChangedHandler = valueChangedHandler;
    }
    [MaybeNull]
    public T Value
    {
        get
        {
            object? obj = (this);
            return (obj == null) ? default : (T)obj;
        }
        set =&gt; (this, value, m_valueChangedHandler != null);
    }
    void (object? previousValueObj, object? currentValueObj, bool contextChanged)
    {
        (m_valueChangedHandler != null);
        T previousValue = previousValueObj == null ? default! : (T)previousValueObj;
        T currentValue = currentValueObj == null ? default! : (T)currentValueObj;
        m_valueChangedHandler(new AsyncLocalValueChangedArgs&lt;T&gt;(previousValue, currentValue, contextChanged));
    }
}
//
// Interface, allowing non-generic code in ExecutionContext to call generic AsyncLocal<T> type//
internal interface IAsyncLocal
{
    void OnValueChanged(object? previousValue, object? currentValue, bool contextChanged);
}
public readonly struct AsyncLocalValueChangedArgs&lt;T&gt;
{
    public T? PreviousValue { get; }
    public T? CurrentValue { get; }
    //
    // If the value changed because we changed to a different ExecutionContext, this is true.  If it changed
    // because someone set the Value property, this is false.
    //
    public bool ThreadContextChanged { get; }
    internal AsyncLocalValueChangedArgs(T? previousValue, T? currentValue, bool contextChanged)
    {
        PreviousValue = previousValue!;
        CurrentValue = currentValue!;
        ThreadContextChanged = contextChanged;
    }
}
//
// Interface used to store an IAsyncLocal =&gt; object mapping in ExecutionContext.
// Implementations are specialized based on the number of elements in the immutable
// map in order to minimize memory consumption and look-up times.
//
internal interface IAsyncLocalValueMap
{
    bool TryGetValue(IAsyncLocal key, out object? value);
    IAsyncLocalValueMap Set(IAsyncLocal key, object? value, bool treatNullValueAsNonexistent);
}

We know that in .NET, each thread is associated with an execution context. We can access it through the attribute or get it through ().

From the above we can see that the Value access of AsyncLocal is operated through and . We can continue to take out part of the code from the ExecutionContext to view (Source code address), In order to have a deeper understanding of AsyncLocal, we can check the source code and see the internal implementation principles.

internal static readonly ExecutionContext Default = new ExecutionContext();
private static volatile ExecutionContext? s_defaultFlowSuppressed;
private readonly IAsyncLocalValueMap? m_localValues;
private readonly IAsyncLocal[]? m_localChangeNotifications;
private readonly bool m_isFlowSuppressed;
private readonly bool m_isDefault;
private ExecutionContext()
{
    m_isDefault = true;
}
private ExecutionContext(
    IAsyncLocalValueMap localValues,
    IAsyncLocal[]? localChangeNotifications,
    bool isFlowSuppressed)
{
    m_localValues = localValues;
    m_localChangeNotifications = localChangeNotifications;
    m_isFlowSuppressed = isFlowSuppressed;
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
    throw new PlatformNotSupportedException();
}
public static ExecutionContext? Capture()
{
    ExecutionContext? executionContext = ._executionContext;
    if (executionContext == null)
    {
        executionContext = Default;
    }
    else if (executionContext.m_isFlowSuppressed)
    {
        executionContext = null;
    }
    return executionContext;
}
internal static object? GetLocalValue(IAsyncLocal local)
{
ExecutionContext? current = ._executionContext;
if (current == null)
{
    return null;
}
(!);
(current.m_localValues != null, "Only the default context should have null, and we shouldn't be here on the default context");
current.m_localValues.TryGetValue(local, out object? value);
return value;
}
internal static void SetLocalValue(IAsyncLocal local, object? newValue, bool needChangeNotifications)
{
ExecutionContext? current = ._executionContext;
object? previousValue = null;
bool hadPreviousValue = false;
if (current != null)
{
    (!);
    (current.m_localValues != null, "Only the default context should have null, and we shouldn't be here on the default context");
    hadPreviousValue = current.m_localValues.TryGetValue(local, out previousValue);
}
if (previousValue == newValue)
{
    return;
}
// Regarding 'treatNullValueAsNonexistent: !needChangeNotifications' below:
// - When change notifications are not necessary for this IAsyncLocal, there is no observable difference between
//   storing a null value and removing the IAsyncLocal from 'm_localValues'
// - When change notifications are necessary for this IAsyncLocal, the IAsyncLocal's absence in 'm_localValues'
//   indicates that this is the first value change for the IAsyncLocal and it needs to be registered for change
//   notifications. So in this case, a null value must be stored in 'm_localValues' to indicate that the IAsyncLocal
//   is already registered for change notifications.
IAsyncLocal[]? newChangeNotifications = null;
IAsyncLocalValueMap newValues;
bool isFlowSuppressed = false;
if (current != null)
{
    (!);
    (current.m_localValues != null, "Only the default context should have null, and we shouldn't be here on the default context");
    isFlowSuppressed = current.m_isFlowSuppressed;
    newValues = current.m_localValues.Set(local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
    newChangeNotifications = current.m_localChangeNotifications;
}
else
{
    // First AsyncLocal
    newValues = (local, newValue, treatNullValueAsNonexistent: !needChangeNotifications);
}
//
// Either copy the change notification array, or create a new one, depending on whether we need to add a new item.
//
if (needChangeNotifications)
{
    if (hadPreviousValue)
    {
        (newChangeNotifications != null);
        ((newChangeNotifications, local) >= 0);
    }
    else if (newChangeNotifications == null)
    {
        newChangeNotifications = new IAsyncLocal[1] { local };
    }
    else
    {
        int newNotificationIndex = ;
        (ref newChangeNotifications, newNotificationIndex + 1);
        newChangeNotifications[newNotificationIndex] = local;
    }
}
._executionContext =
    (!isFlowSuppressed && (newValues)) ?
    null : // No values, return to Default context
    new ExecutionContext(newValues, newChangeNotifications, isFlowSuppressed);
if (needChangeNotifications)
{
    (previousValue, newValue, contextChanged: false);
}
}

As can be seen from the above, and both are operated by operating the m_localValues ​​field.

The type of m_localValues ​​is IAsyncLocalValueMap , and the implementation of IAsyncLocalValueMap and . Those who are interested can further view how IAsyncLocalValueMap is created and how to find it.

As you can see, the most important thing is the flow of ExecutionContext. When the thread changes, the ExecutionContext will be captured by default in the previous thread and flow to the next thread, and the data it saves will flow accordingly. In all places where thread switching occurs, the basic class library (BCL) encapsulates the capture of the execution context for us (as in the beginning, you can see that the data of AsyncLocal will not be lost with thread switching). This is why AsyncLocal can achieve thread switching and can still obtain data normally without losing it.

Summarize

AsyncLocal itself does not save data, and the data is saved in the ExecutionContext instance.

The ExecutionContext instance will flow to the next thread with thread switching (the flow can also be prohibited and restored), ensuring that data can be accessed normally during thread switching.

1.Usage example in .NET Core first create a context object

using System;
using ;
using ;
using ;
using ;
namespace 
{
    /// &lt;summary&gt;
    /// Request context Tenant ID    /// &lt;/summary&gt;
    public class RequestContext
    {
        /// &lt;summary&gt;
        /// Get the request context        /// &lt;/summary&gt;
        public static RequestContext Current =&gt; _asyncLocal.Value;
        private readonly static AsyncLocal&lt;RequestContext&gt; _asyncLocal = new AsyncLocal&lt;RequestContext&gt;();
        /// &lt;summary&gt;
        /// Set the request context to the thread global area        /// &lt;/summary&gt;
        /// &lt;param name="userContext"&gt;&lt;/param&gt;
        public static IDisposable SetContext(RequestContext userContext)
        {
            _asyncLocal.Value = userContext;
            return new RequestContextDisposable();
        }
        /// &lt;summary&gt;
        /// Clear context        /// &lt;/summary&gt;
        public static void ClearContext()
        {
            _asyncLocal.Value = null;
        }
        /// &lt;summary&gt;
        /// Tenant ID        /// &lt;/summary&gt;
        public string TenantId { get; set; }
    }
}
namespace 
{
    /// &lt;summary&gt;
    /// Used to release objects    /// &lt;/summary&gt;
    internal class RequestContextDisposable : IDisposable
    {
        internal RequestContextDisposable() { }
        public void Dispose()
        {
            ();
        }
    }
}

 

2. Create a request context middleware

using ;
using ;
using ;
using System;
using ;
using ;
using ;
namespace 
{
    /// &lt;summary&gt;
    /// Request context    /// &lt;/summary&gt;
    public class RequestContextMiddleware : IMiddleware
    {
        protected readonly IServiceProvider ServiceProvider;
        private readonly ILogger&lt;RequestContextMiddleware&gt; Logger;
        public RequestContextMiddleware(IServiceProvider serviceProvider, ILogger&lt;RequestContextMiddleware&gt; logger)
        {
            ServiceProvider = serviceProvider;
            Logger = logger;
        }
        public virtual async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            var requestContext = new RequestContext();
            using ((requestContext))
            {
                 = $"TenantID:{("yyyyMMddHHmmsss")}";
                await next(context);
            }
        }
    }
}

3. Register middleware

public void ConfigureServices(IServiceCollection services)
{
	&lt;RequestContextMiddleware&gt;();
	();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (())
    {
        ();
    }
    else
    {
        ("/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see /aspnetcore-hsts.
        ();
    }
    ();
    ();
    ();
    ();
    //Add context    &lt;RequestContextMiddleware&gt;();
    (endpoints =&gt;
    {
        ();
    });
}

Assign once, use everywhere

namespace 
{
    public class IndexModel : PageModel
    {
        private readonly ILogger&lt;IndexModel&gt; _logger;
        public IndexModel(ILogger&lt;IndexModel&gt; logger)
        {
            _logger = logger;
            _logger.LogInformation($"Test to get global variables1:{}");
        }
        public void OnGet()
        {
            _logger.LogInformation($"Test to get global variables2:{}");
        }
    }
}

This is the article about the detailed explanation of the code of .NET Core using AsyncLocal to implement shared variables. For more related .NET Core shared variable content, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!