In this article, we’ll explain the AsyncLocal class and how to persist values across an async flow with the AsyncLocal class in C#. We’ll start by describing the challenge that AsyncLocal solves and continue with examples of how it does that. We’ll scratch the surface of the AsyncLocal inner workings and finish with the cases when we can use it.
Let’s dive in!
The AsyncLocal Class
During the development of asynchronous .NET applications, we often come across the need to pass values through the entire asynchronous flow. Good examples are data of authenticated users or correlation identifiers for logging that help us identify all logs related to one request.
The obvious solution would be adding additional parameters to our methods and passing these values as an argument. Such an approach can lead to numerous parameters in methods that we don’t necessarily need in each of them and are present only to pass this data to the methods that this method calls. Besides this tramp data, this also leads to unnecessary coupling of the classes.
AsyncLocal as a Way to Modify the ExecutionContext Class
A better solution would be storing such data in variables or classes accessible from all other classes in the async flow. In other words, to persist data in the particular async flow.
We could store these data in thread-local storage, but task-based asynchronous programming doesn’t guarantee that each task executes on the same thread.
To solve this problem, .NET provides a built-in global context where we can store data specific to single requests. And that is the ExecutionContext
class.
Task
and Thread
, read our article Tasks vs Threads in C#.The ExecutionContext
class attaches to a task and loads into a thread before the execution of a task. The ExecutionContext
class serves as a value bag for a single request. We can’t manipulate the ExecutionContext
directly. Instead, we get and set data on it using AsyncLocal
fields. It is recommended to declare that field as static
.
The AsyncLocal Class Implementation
The AsyncLocal
class is a generic class, that implements two constructors: parameterless AsyncLocal<T>()
, and AsyncLocal<T>(Action>AsyncLocalValueChangedArgs<T>>)
. The second constructor accepts an Action
argument. This action triggers when the instance value changes.
The AsyncLocal
class has only one property, Value
, which enables us to get or set the data. It doesn’t implement any additional methods besides inherited methods from objects.
Using the AsyncLocal Class to Persist Values in Async Flow in C#
Let’s look at how we can implement AsyncLocal
using two examples.
AsyncLocal Class Value in Async Flow
The AsyncLocalExample
class is a simple static class that implements the DoWork()
public method. It uses the AsyncFlow<int>
class to store the integer value in different async methods that we call in an async flow:
public static class AsyncLocalExample { public static readonly AsyncLocal<int> AsyncLocalInt = new(); public static async Task DoWork() { AsyncLocalInt.Value = 1; Console.WriteLine($"AsyncLocal value in DoMainWork method: {AsyncLocalInt.Value}"); await DoSubTaskLevel1(); Console.WriteLine($"AsyncLocal value in DoMainWork method after executing " + $"DoSubTaskLevel1 method: {AsyncLocalInt.Value}"); } private static async Task DoSubTaskLevel1() { Console.WriteLine($"AsyncLocal value when entering DoSubTaskLevel1 method: {AsyncLocalInt.Value}"); AsyncLocalInt.Value++; Console.WriteLine($"AsyncLocal value after changing in DoSubTaskLevel1 method: {AsyncLocalInt.Value}"); await DoSubTaskLevel2(); Console.WriteLine($"AsyncLocal value in DoSubTaskLevel1 method after executing " + $"DoSubTaskLevel2 method: {AsyncLocalInt.Value}"); } private static async Task DoSubTaskLevel2() { Console.WriteLine($"AsyncLocal value when entering DoSubTaskLevel2 method: {AsyncLocalInt.Value}"); AsyncLocalInt.Value++; Console.WriteLine($"AsyncLocal value after changing in DoSubTaskLevel2 method: {AsyncLocalInt.Value}"); await Task.Delay(100); } }
In the DoWork()
method, we set the value of AsyncLocalInt
to 1. Within this method, we call the child task DoSubTaskLevel1()
. The called method increments the value of the AsyncLocalInt
and calls yet another child task, DoSubTaskLevel2()
, which increments the AsyncLocalInt
value again.
In every method execution, we log the values of the AsyncLocalInt
class:
AsyncLocalExample execution: AsyncLocal value in DoMainWork method: 1 AsyncLocal value when entering DoSubTaskLevel1 method: 1 AsyncLocal value after changing in DoSubTaskLevel1 method: 2 AsyncLocal value when entering DoSubTaskLevel2 method: 2 AsyncLocal value after changing in DoSubTaskLevel2 method: 3 AsyncLocal value in DoSubTaskLevel1 method after executing DoSubTaskLevel2 method: 2 AsyncLocal value in DoMainWork method after executing DoSubTaskLevel1 method: 1
We can see that the AsyncLocalInt
value is copied to ExecutionContext
of the task we execute. This copy is shallow and uses the copy-on-write technique as ExecutionContext
is immutable. In other words, the new copy of the ExecutionContext
is created for every task executed in async flow.
Every task in flow changes the value of AsyncLocalInt
. But when tasks are executed, the changed value of AsyncLocalInt
is not visible for the parent task and stays as set in this method. We can observe that in the last two lines of the output.
AsyncLocal Class With Value Change Notification Action
The AsyncLocalNotifyExample
class follows the execution logic of our AsyncLocalExample
class. The difference is that we create an AsyncLocal
class with string as a type argument, and we pass the Action
that executes when the value of the AsyncLocal
class changes:
public static class AsyncLocalNotifyExample { public static readonly AsyncLocal<string> AsyncLocalString = new(AsyncLocalValueChangedAction); static Action<AsyncLocalValueChangedArgs<string>> AsyncLocalValueChangedAction => asyncLocalValueChangedArgs => Console.WriteLine($"Current: {asyncLocalValueChangedArgs.CurrentValue}, " + $"Previous: {asyncLocalValueChangedArgs.PreviousValue}, " + $"Thread: {Environment.CurrentManagedThreadId}, " + $"ThreadContextChanged: {asyncLocalValueChangedArgs.ThreadContextChanged}"); public static async Task DoWork() { AsyncLocalString.Value = "Enter DoWork method"; await DoSubTaskLevel1(); AsyncLocalString.Value = "Exit DoWork method"; } private static async Task DoSubTaskLevel1() { AsyncLocalString.Value = "Enter DoSubTaskLevel1 method"; await DoSubTaskLevel2(); AsyncLocalString.Value = "Exit DoSubTaskLevel1 method"; } private static async Task DoSubTaskLevel2() { AsyncLocalString.Value = "Enter DoSubTaskLevel2 method"; await Task.Delay(100); AsyncLocalString.Value = "Exit DoSubTaskLevel2 method"; } }
The action AsyncLocalValueChangedAction
gets triggered whenever the AsyncLocalString
value changes. The type parameter of the action is a struct AsyncLocalValueChangedArgs
that holds the previous and current values of the AsyncLocalString
, as well as the information on whether the thread context changed:
AsyncLocalNotifyExample execution: Current: Enter DoWork method, Previous: , Thread: 5, ThreadContextChanged: False Current: Enter DoSubTaskLevel1 method, Previous: Enter DoWork method, Thread: 5, ThreadContextChanged: False Current: Enter DoSubTaskLevel2 method, Previous: Enter DoSubTaskLevel1 method, Thread: 5, ThreadContextChanged: False Current: Enter DoSubTaskLevel1 method, Previous: Enter DoSubTaskLevel2 method, Thread: 5, ThreadContextChanged: True Current: Enter DoWork method, Previous: Enter DoSubTaskLevel1 method, Thread: 5, ThreadContextChanged: True Current: , Previous: Enter DoWork method, Thread: 5, ThreadContextChanged: True Current: Enter DoSubTaskLevel2 method, Previous: , Thread: 5, ThreadContextChanged: True Current: Exit DoSubTaskLevel2 method, Previous: Enter DoSubTaskLevel2 method, Thread: 5, ThreadContextChanged: False Current: Enter DoSubTaskLevel1 method, Previous: Exit DoSubTaskLevel2 method, Thread: 5, ThreadContextChanged: True Current: Exit DoSubTaskLevel1 method, Previous: Enter DoSubTaskLevel1 method, Thread: 5, ThreadContextChanged: False Current: Enter DoWork method, Previous: Exit DoSubTaskLevel1 method, Thread: 5, ThreadContextChanged: True Current: Exit DoWork method, Previous: Enter DoWork method, Thread: 5, ThreadContextChanged: False Current: , Previous: Exit DoWork method, Thread: 5, ThreadContextChanged: True Current: Exit DoWork method, Previous: , Thread: 5, ThreadContextChanged: True Current: Exit DoSubTaskLevel1 method, Previous: Exit DoWork method, Thread: 5, ThreadContextChanged: True Current: Exit DoSubTaskLevel2 method, Previous: Exit DoSubTaskLevel1 method, Thread: 5, ThreadContextChanged: True Current: , Previous: Exit DoSubTaskLevel2 method, Thread: 5, ThreadContextChanged: True
We can see that the action triggers whenever we set the value of the AsyncLocalString
. Additionally, we can observe the control flow to the calling function when awaiting some tasks and appropriate values of the AsyncLocal
class. Besides that, as we move along the async flow, we get a notification about ExecutionContext
changes with the boolean value of the ThreadContextChanges
.
When to Use AsyncLocal
The AsyncLocal
generic class doesn’t limit type parameters to simple data types. We can construct it with any argument, such as dictionaries, lists, stacks, etc.
The most obvious usage is for storing request-specific data, such as user identity-related data, session data, and transaction identifiers. It is convenient for storing the data for logging, such as correlation ID, which is used in structured logging to trace the flow of the single request through executing methods in different classes.
The AsyncLocal
class is not appropriate for storing data across unrelated asynchronous operations. The values don’t change when the execution thread changes, which differs from the thread context. Finally, we don’t use the AsyncLocal
class in the global context of the application.
Conclusion
To persist values across the async flow, the AsyncLocal class represents a convenient implementation. It enables writing cleaner code and avoiding using some less elegant solutions to pass the data down the async flow to the methods that may need this data.
However, we should be aware of the inner workings and behavior of the AsyncLocal class, such as persisting value only on the task level where we set the value and down the async flow to its child tasks to avoid unwanted consequences.