CLR & Concurrency · Lesson 21

AsyncLocal flows through logical operations

Observe child context changes without treating ambient values as shared global state.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

AsyncLocal stores ambient data associated with logical execution flow rather than a permanent physical thread. Async methods capture and restore execution context around their operation, allowing a child flow to change its ambient value without overwriting the parent's later value in this arrangement. The program uses a controlled incomplete task so the child cannot print before the parent's first observation. It exercises real AsyncLocal and Task behavior on the portable runtime and on the desktop project. It does not require a worker thread, demonstrate global shared-state isolation, or make mutable objects stored inside ambient slots immutable.

Trace the portable program

The parent stores parent and starts Child. The child sets its own ambient string to child and waits at the explicit gate. Control returns to the parent flow, which still reads parent and prints parent-before:parent. Opening the gate allows the child to print child:child; after awaiting that child, the parent prints parent-after:parent. The output order follows the gate and awaits rather than a guessed scheduler delay. A common wrong answer treats AsyncLocal as a normal static variable, or assumes matching thread IDs would imply the parent must inherit the child's assignment.

Execute the actual desktop lab

The desktop project uses the same real gate and async-flow sequence rather than assigning work to a chosen physical thread. The child changes its AsyncLocal slot within its captured operation, and the caller resumes its own context when the child returns an incomplete task. Awaiting completion later does not copy the child's string assignment back into the parent's slot. These observations are about logical flow and immutable string values. A worker-thread identifier would answer a different question, and a test that requires a particular thread switch would be weaker than the deliberate gate ordering used here.

Pitfalls and boundary limits

Execution-context flow is not a deep copy of every referenced object. If an AsyncLocal value is a mutable list or request object, parent and child flows may hold references to the same instance even when later slot assignments are isolated. Mutating that shared object can therefore be visible through both slots. Ambient values also carry lifetime costs: placing a large graph in a slot can keep it reachable while operations or captured context remain alive. Avoid using ambient storage as an unexplained global dependency or treating flow-local assignment as a substitute for explicit ownership of mutable state.

Practice one: isolate behavior

First change only the child's assigned string to worker and predict the parent observations still read parent while the middle line reads worker. Hint: the gate controls output ordering; it does not make the slots one ordinary global variable. Then let the child read the inherited value before making its own assignment and verify the distinction between inherited ambient data and later child changes. Keep each observation at a deliberate point in the flow instead of using a sleep to guess when the child happened to run.

Practice two: design and verify

For another exercise, store a small mutable object in AsyncLocal, let the child mutate that object's property, and compare it with replacing the entire slot value. Predict visibility separately for the shared object mutation and the flow-local assignment. Hint: draw references as well as execution flows. If the intended correlation data should be immutable, design a small immutable value and restore scope explicitly where needed. Test nested operations and cleanup rather than assuming that an ambient API automatically prevents data from living longer than the logical request that introduced it.

Review the evidence

Review ambient context by identifying where a value is established, inherited, replaced, restored, and retained. Distinguish slot assignment from mutation of a referenced object, and keep logical flow separate from physical thread identity. The three output lines establish the controlled string example, while the desktop run confirms the same real API behavior. Neither proves deep isolation or makes arbitrary mutable ambient state safe. Prefer explicit arguments where they clarify a dependency, and reserve ambient values for deliberately scoped concerns whose propagation and lifetime rules the application can explain and test. Context propagation does not supply automatic authorization or isolation for arbitrary application data.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

AsyncLocal flows through logical operations — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var local = new AsyncLocal<string?>();
local.Value = "parent";
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task child = Child();
Console.WriteLine($"parent-before:{local.Value}");
gate.SetResult(true);
await child;
Console.WriteLine($"parent-after:{local.Value}");
async Task Child() { local.Value = "child"; await gate.Task; Console.WriteLine($"child:{local.Value}"); }
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: parent-before:parent child:child parent-after:parent Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var local = new AsyncLocal<string?>();
local.Value = "parent";
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task child = Child();
Console.WriteLine($"parent-before:{local.Value}");
gate.SetResult(true);
await child;
Console.WriteLine($"parent-after:{local.Value}");
async Task Child() { local.Value = "child"; await gate.Task; Console.WriteLine($"child:{local.Value}"); }
Open C# practice

C# practice output

parent-before:parent
child:child
parent-after:parent
Try this
AsyncLocal values follow captured execution context, not a permanent OS thread. A mutable object stored as the value can still be shared by reference.

Further reading

Interview questions for this lesson