Перейти к содержанию

7. ExecutionContext и AsyncLocal

О главе

Цель: отличать ExecutionContext («какие данные видит код») от SynchronizationContext («где выполнить код»). Понять по коду рантайма, как AsyncLocal переживает await и смену потока, почему запись в дочернем async-методе не видна вызывающему, сколько это стоит и как контекст становится причиной утечек.

Лабораторная: start/ — AsyncLocal против ThreadLocal, запись в дочерних методах: предсказать вывод и выполнить TODO про фоновую задачу. final/ — шесть опытов: перенос через await, запись в дочерних методах, неизменяемые снимки, уведомления о смене значения, SuppressFlow и утечка, стоимость записи. Код — в конце главы.

Визуализация: Контекст через await — Пошагово: запись создаёт новый контекст, бокс его захватывает, builder восстанавливает контекст вызывающего.

Статус: ✅ проверено на Ubuntu 26.04 (2 ядра), рантайм 10.0.12. Листинги CoreLib — декомпиляция System.Private.CoreLib 10.0.12 (.\tools\disasm.ps1 -Assembly corelib -Type …). Перепроверено на Windows 11 (8 ядер): вывод совпадает, отличаются только номера потоков.

7.1. Два контекста

В главе 5 продолжение после await искало себе место: SynchronizationContext. У потока есть и второй контекст — данные, которые код «видит вокруг себя». Сравним:

SynchronizationContext ExecutionContext
Отвечает на вопрос где выполнить продолжение какие данные видит код
Что внутри Post/Send в UI-поток, в запрос старого ASP.NET… значения всех AsyncLocal<T>
Где хранится поле потока Thread._synchronizationContext поле потока Thread._executionContext
Через await захватывается при подписке, если не ConfigureAwait(false) захватывается всегда
Через Task.Run, ThreadPool.QueueUserWorkItem, таймеры не переносится переносится, если не SuppressFlow / Unsafe…

ConfigureAwait(false) отключает первый и не трогает второй. Через ExecutionContext работают HttpContext в IHttpContextAccessor, Activity.Current (трассировка), области логирования ILogger.BeginScope, CultureInfo.CurrentCulture: всё это AsyncLocal внутри.

В .NET (Core) ExecutionContext устроен просто:

CoreLib 10.0.12: ExecutionContext — поля и Capture
public sealed class ExecutionContext : IDisposable, ISerializable
{
    internal static readonly ExecutionContext Default = new ExecutionContext();

    private readonly IAsyncLocalValueMap m_localValues;
    private readonly IAsyncLocal[] m_localChangeNotifications;
    private readonly bool m_isFlowSuppressed;
    private readonly bool m_isDefault;

    public static ExecutionContext? Capture()
    {
        ExecutionContext executionContext = Thread.CurrentThread._executionContext;
        if (executionContext == null)
        {
            executionContext = Default;
        }
        else if (executionContext.m_isFlowSuppressed)
        {
            executionContext = null;
        }
        return executionContext;
    }
    ...
}
  • Строки 5–8: все поля readonly. Объект ExecutionContext неизменяем: «изменить контекст» — значит создать новый объект и записать его в поле потока.
  • Строка 5: m_localValues — словарь «AsyncLocal → значение».
  • Строка 6: AsyncLocal, которые просили уведомлять об изменениях (§7.4).
  • Строки 10–22: Capture() — просто прочитать ссылку из поля потока. Ни копирования, ни аллокаций: копировать неизменяемый объект незачем. null на потоке означает «пустой контекст» (Default), а при подавленном переносе (SuppressFlow, §7.5) Capture возвращает null — «нечего переносить».

7.2. AsyncLocal<T> против ThreadLocal<T>

final/Program.cs
Console.WriteLine("== 1. AsyncLocal и ThreadLocal через await ==");
var asyncLocal = new AsyncLocal<string>();
var threadLocal = new ThreadLocal<string>();
asyncLocal.Value = "значение";
threadLocal.Value = "значение";
Console.WriteLine($"  до await   : поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");
await Task.Delay(100);
Console.WriteLine($"  после await: поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");
1
2
3
== 1. AsyncLocal и ThreadLocal через await ==
  до await   : поток 1, AsyncLocal=значение, ThreadLocal=значение
  после await: поток 5, AsyncLocal=значение, ThreadLocal=null

После await (строка 15) код продолжился на другом потоке (5 вместо 1). ThreadLocal привязан к физическому потоку, на потоке 5 значения нет. AsyncLocal привязан к логическому потоку выполнения, его значение пришло вместе с контекстом.

Вывод: для данных «текущей операции» (идентификатор корреляции, пользователь, арендатор, транзакция) в async-коде годится только AsyncLocal. ThreadLocal и [ThreadStatic] после первого же await показывают данные чужой операции, которая раньше выполнялась на этом потоке, или ничего.

Предскажите

В start/Program.cs у строк стоят комментарии PREDICT. Впишите, что напечатает каждая, и только потом запускайте:

cd chapters
dotnet run -c Release --project 07-execution-context\start

7.3. Запись создаёт новый контекст

Как устроена запись

CoreLib 10.0.12: AsyncLocal<T>.Value и ExecutionContext.SetLocalValue (сокращено)
// AsyncLocal<T>
public T Value
{
    get => (T)ExecutionContext.GetLocalValue(this);   // упрощено: для значимого T null → default
    set => ExecutionContext.SetLocalValue(this, value, _valueChangedHandler != null);
}

// ExecutionContext
internal static void SetLocalValue(IAsyncLocal local, object newValue, bool needChangeNotifications)
{
    ExecutionContext executionContext = Thread.CurrentThread._executionContext;
    object value = null;
    bool flag = false;
    if (executionContext != null)
    {
        flag = executionContext.m_localValues.TryGetValue(local, out value);
    }
    if (value == newValue)
    {
        return;
    }
    ...
    asyncLocalValueMap = executionContext.m_localValues.Set(local, newValue, !needChangeNotifications);
    ...
    Thread.CurrentThread._executionContext = ((!flag2 && AsyncLocalValueMap.IsEmpty(asyncLocalValueMap)) ? null : new ExecutionContext(asyncLocalValueMap, array, flag2));
    if (needChangeNotifications)
    {
        local.OnValueChanged(value, newValue, contextChanged: false);
    }
}
  • Строка 9: значение хранится как object. Для AsyncLocal<int> каждая запись — упаковка (§7.6).
  • Строки 18–21: запись того же объекта ничего не делает. Сравнение по ссылке: для упакованных значимых типов «то же число» — это уже другой объект.
  • Строка 23: Set не меняет старый словарь, а возвращает новый (копирование при записи).
  • Строка 25: новый ExecutionContext с новым словарём записывается в поле потока. Старый объект не меняется: кто успел его захватить (бокс приостановленного метода, задача в очереди пула), тот так и видит старые значения.

Подтверждение — опыт 3:

final/Program.cs
Console.WriteLine("== 3. ExecutionContext — неизменяемый снимок ==");
id.Value = 1;
ExecutionContext? first = ExecutionContext.Capture();
ExecutionContext? second = ExecutionContext.Capture();
id.Value = 2;
ExecutionContext? third = ExecutionContext.Capture();
Console.WriteLine($"  два Capture без записи — один объект: {ReferenceEquals(first, second)}");
Console.WriteLine($"  Capture после записи — тот же объект: {ReferenceEquals(first, third)}");
ExecutionContext.Run(first!, _ => Console.WriteLine($"  внутри Run(first): id={id.Value}"), null);
Console.WriteLine($"  после Run         : id={id.Value}");
1
2
3
4
5
== 3. ExecutionContext — неизменяемый снимок ==
  два Capture без записи — один объект: True
  Capture после записи — тот же объект: False
  внутри Run(first): id=1
  после Run         : id=2
  • Строка 2 вывода (строки 34–35 кода): два Capture подряд — один и тот же объект.
  • Строка 3 (строки 36–37): после записи в поле потока лежит другой объект.
  • Строки 4–5 (строки 40–41): ExecutionContext.Run(first, …) временно ставит на поток старый снимок (там id = 1), а по выходе возвращает текущий (id = 2). Так рантайм и переносит контекст: «поставить захваченный снимок на поток → выполнить → вернуть как было».

Запись в дочернем методе

final/Program.cs
Console.WriteLine("== 2. Запись в дочернем методе ==");
var id = new AsyncLocal<int>();
id.Value = 1;
await ChildAsync();
Console.WriteLine($"  после ChildAsync      : {id.Value}");
id.Value = 1;
_ = ChildNoAwaitAsync();
Console.WriteLine($"  после ChildNoAwaitAsync: {id.Value}");
id.Value = 1;
ChildSync();
Console.WriteLine($"  после ChildSync       : {id.Value}");
final/Program.cs
async Task ChildAsync()
{
    Console.WriteLine($"  в ChildAsync до записи: {id.Value}");
    id.Value = 2;
    await Task.Delay(10);
    Console.WriteLine($"  в ChildAsync после await: {id.Value}");
}

async Task ChildNoAwaitAsync()
{
    id.Value = 3;                                // async-метод без await: выполняется целиком синхронно
}

void ChildSync()
{
    id.Value = 4;                                // обычный метод
}
1
2
3
4
5
6
== 2. Запись в дочернем методе ==
  в ChildAsync до записи: 1
  в ChildAsync после await: 2
  после ChildAsync      : 1
  после ChildNoAwaitAsync: 1
  после ChildSync       : 4
  • Строки 2–3 вывода: ChildAsync унаследовал 1 и после своей записи (строка 81) видит 2, в том числе после await.
  • Строка 4: вызывающий после await ChildAsync() видит прежнее 1. Запись в дочернем async-методе вверх не «всплывает».
  • Строка 5: то же для async-метода без единого await (строки 86–89). Он выполняется целиком синхронно, но запись всё равно пропала.
  • Строка 6: а запись из обычного метода (строки 91–94) вызывающий видит.

Почему так, видно по коду, который выполняет первый шаг любого async-метода (глава 2, Start builder'а):

CoreLib 10.0.12: AsyncMethodBuilderCore.Start
public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    if (stateMachine == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.stateMachine);
    }
    Thread currentThread = Thread.CurrentThread;
    ExecutionContext executionContext = currentThread._executionContext;
    SynchronizationContext synchronizationContext = currentThread._synchronizationContext;
    try
    {
        stateMachine.MoveNext();
    }
    finally
    {
        if (synchronizationContext != currentThread._synchronizationContext)
        {
            currentThread._synchronizationContext = synchronizationContext;
        }
        ExecutionContext executionContext2 = currentThread._executionContext;
        if (executionContext != executionContext2)
        {
            ExecutionContext.RestoreChangedContextToThread(currentThread, executionContext, executionContext2);
        }
    }
}
  • Строки 8–9: до первого MoveNext запомнить оба контекста потока.
  • Строка 12: синхронная часть метода, до первой приостановки или до конца.
  • Строки 16–24: что бы метод ни сделал с контекстами, вернуть вызывающему прежние. Запись в AsyncLocal создала на потоке новый ExecutionContext, а finally поставил обратно старый.

У обычного метода нет Start, некому восстанавливать, и новый контекст остаётся на потоке. Поэтому запись в AsyncLocal ведёт себя как «локальная переменная, видимая во всех вызываемых методах», но граница — async-метод, а не любой вызов.

Факт: AsyncLocal, записанный в обычном методе, «протекает» к вызывающему

В первой версии курса было правило «изменения вниз не видны вверх». Точнее так: не видны вверх через границу async-метода. Вспомогательный синхронный метод вроде void SetUser(…) => s_user.Value = … меняет контекст вызывающего, и это можно использовать. А тот же код в async Task SetUserAsync(…) «ничего не сделает» для вызывающего, даже без единого await.

Если данные нужно вернуть вверх из async-метода, возвращайте их результатом или кладите в изменяемый объект-держатель, ссылка на который лежит в AsyncLocal (так устроен HttpContextAccessor: в AsyncLocal лежит держатель, а не сам HttpContext).

7.4. Как контекст переходит через await

Два места в рантайме, оба уже встречались в главе 1.

При приостановке builder упаковывает машину в бокс и захватывает контекст:

CoreLib 10.0.12: AsyncTaskMethodBuilder<TResult>.GetStateMachineBox (сокращено)
private static IAsyncStateMachineBox GetStateMachineBox<TStateMachine>(ref TStateMachine stateMachine, [NotNull] ref Task<TResult> taskField) where TStateMachine : IAsyncStateMachine
{
    ExecutionContext executionContext = ExecutionContext.Capture();
    if (taskField is AsyncStateMachineBox<TStateMachine> asyncStateMachineBox)
    {
        if (asyncStateMachineBox.Context != executionContext)
        {
            asyncStateMachineBox.Context = executionContext;
        }
        return asyncStateMachineBox;
    }
    ...
    AsyncStateMachineBox<TStateMachine> asyncStateMachineBox3 = ... new AsyncStateMachineBox<TStateMachine>();
    asyncStateMachineBox3.StateMachine = stateMachine;
    asyncStateMachineBox3.Context = executionContext;
    ...
    return asyncStateMachineBox3;
}
  • Строка 3: захват — одно чтение поля потока (§7.1). Делается при каждой приостановке: после записи в AsyncLocal между двумя await бокс должен унести новый снимок (строки 6–9).
  • Строка 15: снимок лежит в боксе (поле Context, унаследованное от Task поле m_stateObject, глава 4).

При возобновлении бокс выполняет MoveNext внутри захваченного контекста:

CoreLib 10.0.12: AsyncStateMachineBox.MoveNext и ExecutionContext.RunFromThreadPoolDispatchLoop
private void MoveNext(Thread threadPoolThread)
{
    ...
    ExecutionContext context = Context;
    if (context == null)
    {
        StateMachine.MoveNext();
    }
    else if (threadPoolThread == null)
    {
        ExecutionContext.RunInternal(context, s_callback, this);
    }
    else
    {
        ExecutionContext.RunFromThreadPoolDispatchLoop(threadPoolThread, context, s_callback, this);
    }
    ...
}

internal static void RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, object state)
{
    if (executionContext != null && !executionContext.m_isDefault)
    {
        RestoreChangedContextToThread(threadPoolThread, executionContext, null);
    }
    ...
    callback(state);
    ...
    ExecutionContext executionContext2 = threadPoolThread._executionContext;
    threadPoolThread._synchronizationContext = null;
    if (executionContext2 != null)
    {
        RestoreChangedContextToThread(threadPoolThread, null, executionContext2);
    }
}
  • Строки 9–16: бокс вызван инлайн (из SetResult, глава 5) — RunInternal: поставить снимок, выполнить, вернуть прежний контекст (как Start). Бокс взят из очереди пула — RunFromThreadPoolDispatchLoop.
  • Строки 22–25: поставить на поток пула захваченный снимок.
  • Строки 29–34: после работы очистить поток: и ExecutionContext, и SynchronizationContext. Поток пула возвращается в пул «чистым», следующий рабочий элемент не увидит чужих AsyncLocal.

Сама подмена — RestoreChangedContextToThread: записать объект в поле потока и, если кто-то из старого или нового контекста просил уведомлений, вызвать их.

Опыт: уведомления показывают каждую подмену

AsyncLocal можно создать с обработчиком, который вызывается при каждой смене видимого на этом потоке значения. ThreadContextChanged = false — явная запись в Value, true — рантайм подменил контекст на потоке.

final/Program.cs
Console.WriteLine("== 4. Уведомления о смене значения ==");
bool tracing = true;                             // печатать уведомления только в этом опыте
var traced = new AsyncLocal<string?>(e =>
{
    if (tracing)
        Console.WriteLine($"    {e.PreviousValue ?? "null"} -> {e.CurrentValue ?? "null"}, ThreadContextChanged={e.ThreadContextChanged}, поток {Environment.CurrentManagedThreadId}");
});
Console.WriteLine("  запись req-42:");
traced.Value = "req-42";
Console.WriteLine("  await Task.Delay(50):");
await Task.Delay(50);
Console.WriteLine("  вызов TracedChildAsync:");
await TracedChildAsync();
Console.WriteLine("  запись null:");
traced.Value = null;
tracing = false;
final/Program.cs
async Task TracedChildAsync()
{
    traced.Value = "child";
    await Task.Delay(50);
    Console.WriteLine($"    TracedChildAsync после await: {traced.Value}");
}
== 4. Уведомления о смене значения ==
  запись req-42:
    null -> req-42, ThreadContextChanged=False, поток 5
  await Task.Delay(50):
    req-42 -> null, ThreadContextChanged=True, поток 5
    null -> req-42, ThreadContextChanged=True, поток 5
  вызов TracedChildAsync:
    req-42 -> child, ThreadContextChanged=False, поток 5
    child -> req-42, ThreadContextChanged=True, поток 5
    req-42 -> null, ThreadContextChanged=True, поток 5
    null -> child, ThreadContextChanged=True, поток 5
    TracedChildAsync после await: child
    child -> req-42, ThreadContextChanged=True, поток 5
  запись null:
    req-42 -> null, ThreadContextChanged=False, поток 5

Код верхнего уровня к этому моменту уже работает на потоке пула 5 (после await в опытах 1–3). Построчно:

  • Строка 3 вывода (строка 52 кода): явная запись.
  • Строка 5 (строка 54): await Task.Delay(50) приостановил метод, MoveNext вернул управление в цикл пула, и RunFromThreadPoolDispatchLoop очистил поток (строки 29–34 листинга выше): req-42 → null.
  • Строка 6: таймер сработал, бокс взят из очереди на тот же поток 5, и его снимок поставлен на поток (строки 22–25): null → req-42.
  • Строка 8 (строка 98): запись в TracedChildAsync.
  • Строка 9: TracedChildAsync приостановился на await, и Start вернул вызывающему его контекст (строки 16–24 листинга Start): child → req-42. Это та самая граница async-метода из §7.3.
  • Строка 10: вызывающий тоже приостановился на await TracedChildAsync(), поток очищен.
  • Строки 11–12: таймер TracedChildAsync — его снимок с child на потоке.
  • Строка 13: TracedChildAsync завершился, продолжение вызывающего выполнено инлайн (глава 5) через RunInternal со снимком вызывающего: child → req-42.
  • Строка 15 (строка 58): явная запись null.

Номер потока от прогона к прогону бывает 5 или 6, последовательность уведомлений одна и та же.

Обработчик уведомлений должен быть быстрым и не бросать исключений

Он вызывается при каждой подмене контекста на потоке, то есть на каждом await и каждом рабочем элементе пула. Исключение в нём рантайм не обрабатывает, а аварийно завершает процесс (Environment.FailFast в ExecutionContext.OnValuesChanged). В лабораторной флаг tracing (строки 45, 59) выключает печать после опыта: иначе уведомления продолжали бы приходить при раскрутке стека в следующих опытах.

Интерактивная схема

Пошагово: запись создаёт новый контекст, бокс его захватывает, builder восстанавливает контекст вызывающего. Открыть на весь экран.

7.5. Когда контекст переносить не надо

SuppressFlow

ExecutionContext.SuppressFlow() ставит на поток копию контекста с флагом m_isFlowSuppressed. Пока он стоит, Capture() возвращает null (строки 17–20 листинга §7.1): новые задачи, рабочие элементы пула, таймеры и боксы контекст не уносят. Возвращает AsyncFlowControl, его Dispose() / Undo() восстанавливает перенос.

final/Program.cs
Console.WriteLine();
Console.WriteLine("== 5. SuppressFlow ==");
Task suppressed;
using (ExecutionContext.SuppressFlow())
{
    suppressed = Task.Run(() => Console.WriteLine($"  Task.Run при SuppressFlow : id={id.Value}"));
}
await suppressed;
await Task.Run(() => Console.WriteLine($"  Task.Run без SuppressFlow: id={id.Value}"));
await SuppressAcrossAwaitAsync();
Console.WriteLine($"  после SuppressAcrossAwaitAsync: id={id.Value}");
await Leak.RunAsync();
1
2
3
4
5
6
7
== 5. SuppressFlow ==
  Task.Run при SuppressFlow : id=0
  Task.Run без SuppressFlow: id=2
  после await внутри SuppressFlow: id=0
  выход из using: InvalidOperationException: AsyncFlowControl objects can be used to restore flow only on a Context that had its flow suppressed.
  после SuppressAcrossAwaitAsync: id=2
  данные запроса живы после GC: без SuppressFlow=True, с SuppressFlow=False
  • Строки 2–3 вывода (строки 64–69 кода): задача, созданная при SuppressFlow, видит пустой контекст (id = 0), без него — значение вызывающего.

Рантайм и сам переносит контекст не везде. Методы с Unsafe в имени (ThreadPool.UnsafeQueueUserWorkItem, UnsafeOnCompleted у awaiter'ов, CancellationToken.UnsafeRegister) не захватывают ExecutionContext. Для await это не потеря: builder сам захватывает контекст в боксе (§7.4), поэтому зовёт UnsafeOnCompleted. Таймер Task.Delay создаётся с flowExecutionContext: false (глава 1): ему контекст не нужен, продолжение всё равно несёт свой.

Факт: await внутри using (ExecutionContext.SuppressFlow()) ломает и метод, и Dispose

final/Program.cs
async Task SuppressAcrossAwaitAsync()
{
    try
    {
        using (ExecutionContext.SuppressFlow())
        {
            await Task.Delay(10);                    // ошибка: await внутри SuppressFlow
            Console.WriteLine($"  после await внутри SuppressFlow: id={id.Value}");
        }
    }
    catch (InvalidOperationException e)
    {
        Console.WriteLine($"  выход из using: {e.GetType().Name}: {e.Message}");
    }
}
  • Строка 4 вывода: после await (строка 109) метод потерял свой собственный контекст: id = 0. При приостановке бокс захватил Capture() = null (перенос подавлен), и продолжение выполнилось с пустым контекстом.
  • Строка 5: выход из using бросил InvalidOperationException. AsyncFlowControl.Undo требует, чтобы его вызвали на том же потоке и при подавленном переносе. После await не выполняется ни то, ни другое. В трёх прогонах final поток оказался тем же, и сработала вторая проверка (сообщение выше). В черновом опыте продолжение пришло на другой поток, и сообщение было первым: «AsyncFlowControl object must be used on the thread where it was created».
  • Строка 6: у вызывающего контекст цел: его вернул Start (§7.3).

Правило: внутри SuppressFlow — только синхронный код, который запускает работу (Task.Run, new Thread, Timer), и сразу Dispose.

Утечка через фоновую задачу

Задача, запущенная «из запроса», уносит контекст запроса со всеми его AsyncLocal. Если задача живёт дольше запроса (фоновый цикл, кеш с периодическим обновлением, «запустил и забыл»), она удерживает в памяти всё, что лежало в контексте.

final/Leak.cs
    private static WeakReference HandleRequest(bool suppressFlow)
    {
        WeakReference data = null!;
        var request = new Thread(() =>
        {
            byte[] payload = new byte[10_000_000];
            data = new WeakReference(payload);
            s_requestData.Value = payload;
            if (suppressFlow)
            {
                using (ExecutionContext.SuppressFlow())
                    _ = BackgroundLoopAsync();
            }
            else
            {
                _ = BackgroundLoopAsync();
            }
            s_requestData.Value = null;          // запрос «закончился» и всё за собой убрал
        });
        request.Start();
        request.Join();
        return data;
    }

    private static async Task BackgroundLoopAsync()
    {
        while (true)
            await Task.Delay(50);                // данные запроса не нужны, но контекст захвачен
    }
  • Строки 24–26: «запрос» кладёт 10 МБ в AsyncLocal.
  • Строки 27–35: запуск вечного фонового цикла, с SuppressFlow или без.
  • Строка 36: запрос «за собой убрал»: записал null. Но это создало новый контекст на потоке запроса, а фоновый цикл уже захватил старый снимок с 10 МБ.

Строка 7 вывода: после сборки мусора данные запроса живы, если цикл запущен без SuppressFlow, и собраны, если с ним. Снимок держит бокс BackgroundLoopAsync, бокс держит таймер Task.Delay, таймер — очередь таймеров рантайма. Это известная причина утечек в ASP.NET Core: фоновая работа, запущенная из обработчика запроса, держит HttpContext и всё, что в нём, через IHttpContextAccessor. Решения: запускать фоновую работу через IHostedService / очередь (у них свой, чистый контекст), либо SuppressFlow при запуске.

Задание (TODO)

В start/Program.cs фоновая задача в конце печатает id вызывающего. Добейтесь, чтобы она печатала id=0, не меняя саму лямбду.

7.6. Сколько это стоит

final/Cost.cs
    private static void Measure()
    {
        foreach (int count in new[] { 1, 2, 3, 4, 5, 8, 16, 17 })
        {
            var locals = new AsyncLocal<object?>[count];
            var a = new object();
            var b = new object();
            for (int i = 0; i < count; i++)
            {
                locals[i] = new AsyncLocal<object?>();
                locals[i].Value = a;
            }
            double set = PerCall(i => locals[0].Value = i % 2 == 0 ? b : a);
            double capture = PerCall(_ => ExecutionContext.Capture());
            Console.WriteLine($"  AsyncLocal в контексте: {count,2}  запись: {set,4} байт  Capture: {capture} байт");
            foreach (var local in locals)
                local.Value = null;
        }
        var number = new AsyncLocal<int>();
        number.Value = -1;
        Console.WriteLine($"  AsyncLocal<int>, 1 в контексте: запись: {PerCall(i => number.Value = i)} байт");
    }

Замер в чистом контексте (строки 4–8 Cost.cs: Task.Run при SuppressFlow), count разных AsyncLocal в контексте, перезаписывается один из них:

== 6. Стоимость ==
  AsyncLocal в контексте:  1  запись:   72 байт  Capture: 0 байт
  AsyncLocal в контексте:  2  запись:   88 байт  Capture: 0 байт
  AsyncLocal в контексте:  3  запись:  104 байт  Capture: 0 байт
  AsyncLocal в контексте:  4  запись:  120 байт  Capture: 0 байт
  AsyncLocal в контексте:  5  запись:  168 байт  Capture: 0 байт
  AsyncLocal в контексте:  8  запись:  216 байт  Capture: 0 байт
  AsyncLocal в контексте: 16  запись:  344 байт  Capture: 0 байт
  AsyncLocal в контексте: 17  запись:  648 байт  Capture: 0 байт
  AsyncLocal<int>, 1 в контексте: запись: 96 байт

Числа одинаковы во всех прогонах и складываются из устройства объектов:

В контексте Словарь m_localValues Запись = новый ExecutionContext (40 Б) +
1–4 значения OneElement… … FourElementAsyncLocalValueMap: поля прямо в объекте словарь 32 / 48 / 64 / 80 Б
5–16 MultiElementAsyncLocalValueMap: массив пар объект 24 Б + копия массива: 24 Б + 16 Б на пару (5 пар — 128 Б, 16 пар — 304 Б)
17 и больше ManyElementAsyncLocalValueMap — наследник Dictionary копия словаря целиком
  • Capture() бесплатен (столбец справа): это чтение ссылки. Поэтому await ничего не платит за перенос контекста, сколько бы AsyncLocal там ни было.
  • Запись — всегда новый ExecutionContext и копия словаря. При 1–16 значениях это десятки-сотни байт, начиная с 17 — копирование Dictionary.
  • AsyncLocal<int> — ещё 24 байта на упаковку int (96 = 72 + 24).

Отсюда практические правила: не пишите в AsyncLocal в горячем цикле; держите в контексте немного значений; если нужно много полей «текущей операции», положите в один AsyncLocal объект-держатель.

7.7. Итоги

  • SynchronizationContext — где выполнить продолжение, ExecutionContext — какие данные видит код. Оба — поля потока. ConfigureAwait(false) влияет только на первый.
  • ExecutionContext неизменяем. Capture() — чтение ссылки, бесплатно. Запись в AsyncLocal создаёт новый контекст и новый словарь значений (72+ байт, для значимых типов плюс упаковка).
  • Через await контекст переходит в боксе: захват при каждой приостановке, установка при возобновлении, очистка потока пула после работы.
  • AsyncMethodBuilderCore.Start возвращает вызывающему его контексты, поэтому запись в AsyncLocal внутри async-метода (даже без await) не видна вызывающему. Внутри обычного метода — видна.
  • SuppressFlow — чтобы фоновая работа не уносила контекст (и не удерживала данные запроса). Внутри него не должно быть await.
  • Для данных «текущей операции» в async-коде — AsyncLocal, не ThreadLocal/[ThreadStatic].

Код лабораторной

Запуск из папки главы: dotnet run -c Release --project start или --project final.

start/Program.cs
// Глава 7, заготовка. AsyncLocal против ThreadLocal; запись в AsyncLocal из дочерних методов.
// PREDICT: что напечатает каждая строка с комментарием PREDICT?
// TODO (§7.5): фоновая задача в конце не должна видеть id «запроса». Добейтесь, чтобы она печатала id=0.

var asyncLocal = new AsyncLocal<string>();
var threadLocal = new ThreadLocal<string>();
asyncLocal.Value = "значение";
threadLocal.Value = "значение";
Console.WriteLine($"до await   : поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");
await Task.Delay(100);
Console.WriteLine($"после await: поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");   // PREDICT:

var id = new AsyncLocal<int>();
id.Value = 1;
await ChildAsync();
Console.WriteLine($"после ChildAsync: {id.Value}");          // PREDICT:
id.Value = 1;
ChildSync();
Console.WriteLine($"после ChildSync : {id.Value}");          // PREDICT:

Task background = Task.Run(() => Console.WriteLine($"фоновая задача: id={id.Value}"));   // PREDICT:   TODO
await background;

async Task ChildAsync()
{
    Console.WriteLine($"в ChildAsync до записи : {id.Value}");   // PREDICT:
    id.Value = 2;
    await Task.Delay(10);
    Console.WriteLine($"в ChildAsync после await: {id.Value}");  // PREDICT:
}

void ChildSync()
{
    id.Value = 3;
}
final/Program.cs
// Глава 7, итог. ExecutionContext и AsyncLocal:
//   1) AsyncLocal течёт через await, ThreadLocal — нет (§7.2);
//   2) запись в AsyncLocal из async-метода не видна вызывающему, из обычного — видна (§7.3);
//   3) ExecutionContext — неизменяемый снимок: Capture без записи не создаёт объектов (§7.3);
//   4) уведомления AsyncLocal: когда рантайм подменяет контекст на потоке (§7.4);
//   5) SuppressFlow и утечка данных запроса через фоновую задачу (§7.5);
//   6) сколько стоит запись в AsyncLocal (§7.6).

Console.WriteLine("== 1. AsyncLocal и ThreadLocal через await ==");
var asyncLocal = new AsyncLocal<string>();
var threadLocal = new ThreadLocal<string>();
asyncLocal.Value = "значение";
threadLocal.Value = "значение";
Console.WriteLine($"  до await   : поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");
await Task.Delay(100);
Console.WriteLine($"  после await: поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");

Console.WriteLine();
Console.WriteLine("== 2. Запись в дочернем методе ==");
var id = new AsyncLocal<int>();
id.Value = 1;
await ChildAsync();
Console.WriteLine($"  после ChildAsync      : {id.Value}");
id.Value = 1;
_ = ChildNoAwaitAsync();
Console.WriteLine($"  после ChildNoAwaitAsync: {id.Value}");
id.Value = 1;
ChildSync();
Console.WriteLine($"  после ChildSync       : {id.Value}");

Console.WriteLine();
Console.WriteLine("== 3. ExecutionContext — неизменяемый снимок ==");
id.Value = 1;
ExecutionContext? first = ExecutionContext.Capture();
ExecutionContext? second = ExecutionContext.Capture();
id.Value = 2;
ExecutionContext? third = ExecutionContext.Capture();
Console.WriteLine($"  два Capture без записи — один объект: {ReferenceEquals(first, second)}");
Console.WriteLine($"  Capture после записи — тот же объект: {ReferenceEquals(first, third)}");
ExecutionContext.Run(first!, _ => Console.WriteLine($"  внутри Run(first): id={id.Value}"), null);
Console.WriteLine($"  после Run         : id={id.Value}");

Console.WriteLine();
Console.WriteLine("== 4. Уведомления о смене значения ==");
bool tracing = true;                             // печатать уведомления только в этом опыте
var traced = new AsyncLocal<string?>(e =>
{
    if (tracing)
        Console.WriteLine($"    {e.PreviousValue ?? "null"} -> {e.CurrentValue ?? "null"}, ThreadContextChanged={e.ThreadContextChanged}, поток {Environment.CurrentManagedThreadId}");
});
Console.WriteLine("  запись req-42:");
traced.Value = "req-42";
Console.WriteLine("  await Task.Delay(50):");
await Task.Delay(50);
Console.WriteLine("  вызов TracedChildAsync:");
await TracedChildAsync();
Console.WriteLine("  запись null:");
traced.Value = null;
tracing = false;

Console.WriteLine();
Console.WriteLine("== 5. SuppressFlow ==");
Task suppressed;
using (ExecutionContext.SuppressFlow())
{
    suppressed = Task.Run(() => Console.WriteLine($"  Task.Run при SuppressFlow : id={id.Value}"));
}
await suppressed;
await Task.Run(() => Console.WriteLine($"  Task.Run без SuppressFlow: id={id.Value}"));
await SuppressAcrossAwaitAsync();
Console.WriteLine($"  после SuppressAcrossAwaitAsync: id={id.Value}");
await Leak.RunAsync();

Console.WriteLine();
Console.WriteLine("== 6. Стоимость ==");
await Cost.RunAsync();

async Task ChildAsync()
{
    Console.WriteLine($"  в ChildAsync до записи: {id.Value}");
    id.Value = 2;
    await Task.Delay(10);
    Console.WriteLine($"  в ChildAsync после await: {id.Value}");
}

async Task ChildNoAwaitAsync()
{
    id.Value = 3;                                // async-метод без await: выполняется целиком синхронно
}

void ChildSync()
{
    id.Value = 4;                                // обычный метод
}

async Task TracedChildAsync()
{
    traced.Value = "child";
    await Task.Delay(50);
    Console.WriteLine($"    TracedChildAsync после await: {traced.Value}");
}

async Task SuppressAcrossAwaitAsync()
{
    try
    {
        using (ExecutionContext.SuppressFlow())
        {
            await Task.Delay(10);                    // ошибка: await внутри SuppressFlow
            Console.WriteLine($"  после await внутри SuppressFlow: id={id.Value}");
        }
    }
    catch (InvalidOperationException e)
    {
        Console.WriteLine($"  выход из using: {e.GetType().Name}: {e.Message}");
    }
}
final/Leak.cs
// §7.5. Фоновая задача, запущенная из «запроса», уносит с собой ExecutionContext запроса
// и удерживает всё, что лежит в его AsyncLocal.
static class Leak
{
    private static readonly AsyncLocal<byte[]?> s_requestData = new();

    public static async Task RunAsync()
    {
        WeakReference plain = HandleRequest(suppressFlow: false);
        WeakReference suppressed = HandleRequest(suppressFlow: true);
        await Task.Delay(200);                   // фоновые циклы успели несколько раз проснуться
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        Console.WriteLine($"  данные запроса живы после GC: без SuppressFlow={plain.IsAlive}, с SuppressFlow={suppressed.IsAlive}");
    }

    // «Обработка запроса»: 10 МБ данных в AsyncLocal и запуск вечного фонового цикла.
    private static WeakReference HandleRequest(bool suppressFlow)
    {
        WeakReference data = null!;
        var request = new Thread(() =>
        {
            byte[] payload = new byte[10_000_000];
            data = new WeakReference(payload);
            s_requestData.Value = payload;
            if (suppressFlow)
            {
                using (ExecutionContext.SuppressFlow())
                    _ = BackgroundLoopAsync();
            }
            else
            {
                _ = BackgroundLoopAsync();
            }
            s_requestData.Value = null;          // запрос «закончился» и всё за собой убрал
        });
        request.Start();
        request.Join();
        return data;
    }

    private static async Task BackgroundLoopAsync()
    {
        while (true)
            await Task.Delay(50);                // данные запроса не нужны, но контекст захвачен
    }
}
final/Cost.cs
// §7.6. Сколько байт выделяет одна запись в AsyncLocal и один Capture.
static class Cost
{
    public static Task RunAsync()
    {
        using (ExecutionContext.SuppressFlow())      // замер — в чистом контексте, без AsyncLocal основной программы
            return Task.Run(Measure);
    }

    private static void Measure()
    {
        foreach (int count in new[] { 1, 2, 3, 4, 5, 8, 16, 17 })
        {
            var locals = new AsyncLocal<object?>[count];
            var a = new object();
            var b = new object();
            for (int i = 0; i < count; i++)
            {
                locals[i] = new AsyncLocal<object?>();
                locals[i].Value = a;
            }
            double set = PerCall(i => locals[0].Value = i % 2 == 0 ? b : a);
            double capture = PerCall(_ => ExecutionContext.Capture());
            Console.WriteLine($"  AsyncLocal в контексте: {count,2}  запись: {set,4} байт  Capture: {capture} байт");
            foreach (var local in locals)
                local.Value = null;
        }
        var number = new AsyncLocal<int>();
        number.Value = -1;
        Console.WriteLine($"  AsyncLocal<int>, 1 в контексте: запись: {PerCall(i => number.Value = i)} байт");
    }

    private static double PerCall(Action<int> action)
    {
        const int N = 10_000;
        action(0);
        long before = GC.GetAllocatedBytesForCurrentThread();
        for (int i = 1; i <= N; i++)
            action(i);
        return (GC.GetAllocatedBytesForCurrentThread() - before) / (double)N;
    }
}