.NET Tutorial
Senior6 мин чтенияВопрос 24 из 25

ExecutionContext, AsyncLocal и SynchronizationContext: как контекст «течёт» через await?

Два разных «контекста»

ExecutionContext — ЧТО несёт выполнение

AsyncLocal значения

Activity.Current (TraceId)

Logging scopes, HttpContext

SynchronizationContext — ГДЕ выполнить продолжение

WPF / WinForms: UI-поток

ASP.NET Core: отсутствует
→ пул потоков

ExecutionContext — ЧТО несёт выполнение

AsyncLocal значения

Activity.Current (TraceId)

Logging scopes, HttpContext

SynchronizationContext — ГДЕ выполнить продолжение

WPF / WinForms: UI-поток

ASP.NET Core: отсутствует
→ пул потоков

ExecutionContext и SynchronizationContext решают разные задачи
ExecutionContext SynchronizationContext
Назначение Перенос «окружающих» данных Выбор потока/планировщика для продолжения
Поток через await Всегда (если не подавлен) Захватывается, если не ConfigureAwait(false)
Влияние ConfigureAwait(false) Никакого — контекст всё равно течёт Продолжение не возвращается в контекст
В ASP.NET Core Есть Нет

Как течёт ExecutionContext

Поток пула 12Поток пула 7CorrelationId.Value= "abc"ExecutionContextзахвачен вместе спродолжениемExecutionContextвосстановлен[ThreadStatic]-полездесь было быпустымawaitdb.SaveChangesAsync(-)I/O завершился,продолжение запланированоCorrelationId.Value =="abc"
Поток пула 12Поток пула 7CorrelationId.Value= "abc"ExecutionContextзахвачен вместе спродолжениемExecutionContextвосстановлен[ThreadStatic]-полездесь было быпустымawaitdb.SaveChangesAsync(-)I/O завершился,продолжение запланированоCorrelationId.Value =="abc"
AsyncLocal переживает смену потока
C#
public static class Correlation
{
    private static readonly AsyncLocal<string?> _id = new();
    public static string? Id { get => _id.Value; set => _id.Value = value; }
}

[ThreadStatic] private static string? _threadId;

async Task HandleAsync()
{
    Correlation.Id = "abc";
    _threadId = "abc";

    await Task.Delay(10);                 // продолжение — возможно, на другом потоке

    Console.WriteLine(Correlation.Id);    // abc
    Console.WriteLine(_threadId);         // null (или чужое значение!) — привязано к потоку
}

Семантика copy-on-write

C#
static readonly AsyncLocal<int> Value = new();

async Task Parent()
{
    Value.Value = 1;
    await Child();
    Console.WriteLine(Value.Value);       // 1 — изменение в Child не видно родителю
}

async Task Child()
{
    Console.WriteLine(Value.Value);       // 1 — унаследовано
    Value.Value = 2;                      // создаётся НОВЫЙ ExecutionContext для этой ветки
    await Task.Yield();
    Console.WriteLine(Value.Value);       // 2
}

по возвращении

Parent: EC1 {Value=1}

Child наследует EC1

Child меняет Value=2 →
новый EC2 {Value=2}
только для своей ветки

Parent снова видит EC1 {Value=1}

по возвращении

Parent: EC1 {Value=1}

Child наследует EC1

Child меняет Value=2 →
новый EC2 {Value=2}
только для своей ветки

Parent снова видит EC1 {Value=1}

Изменение AsyncLocal создаёт новый контекст для ветки

ExecutionContext неизменяем: запись в AsyncLocal создаёт новый объект контекста. Поэтому дочерние операции не могут «испортить» контекст родителя — и поэтому нельзя вернуть значение наверх через AsyncLocal. Если нужно передать данные вверх, храните в AsyncLocal изменяемый объект-держатель и меняйте его поля (так устроен HttpContextAccessor).

Где это используется в .NET

Механизм Как использует ExecutionContext
Activity.Current AsyncLocal<Activity> — TraceId/SpanId для распределённой трассировки
IHttpContextAccessor AsyncLocal с держателем HttpContext
ILogger.BeginScope Скоупы логирования хранятся в AsyncLocal
CultureInfo.CurrentCulture Течёт через await (с .NET 4.6)
Transaction.Current (TransactionScope) При TransactionScopeAsyncFlowOption.Enabled
EF Core, Npgsql Не используют для соединений — контекст передаётся явно через DI

Подводные камни

  • Производительность: каждая запись в AsyncLocal — выделение нового контекста; много AsyncLocal с частыми изменениями в горячем пути заметны в профиле. IAsyncLocalValueChangedHandler-уведомления (AsyncLocal с обработчиком) ещё дороже.
  • Таймеры и кэши: System.Threading.Timer, созданный внутри запроса, захватывает контекст запроса и держит его объекты в памяти — используйте SuppressFlow при создании долгоживущих таймеров.
  • ThreadLocal/[ThreadStatic] уместны для кэшей на поток (буферы, Random), но не для данных, связанных с логической операцией.

Явная передача против неявного контекста

Неявный контекст удобен для сквозных аспектов (трассировка, логирование, корреляция), но скрывает зависимости: метод неявно требует, чтобы кто-то заранее установил значение. Для бизнес-данных (текущий пользователь, тенант) предпочтительнее Scoped-сервисы через DI или явные параметры — их видно в сигнатуре и легко подменить в тестах.

Вопросы на засыпку

Почему ConfigureAwait(false) не отключает AsyncLocal?

ConfigureAwait(false) влияет только на SynchronizationContext/TaskScheduler — где выполнится продолжение. ExecutionContext захватывается и восстанавливается независимо от этого; чтобы его не передавать, нужен ExecutionContext.SuppressFlow() или Unsafe*-API.

Почему значение AsyncLocal, установленное в вызванном async-методе, не видно после await в вызывающем?

Async-метод при входе работает в контексте вызывающего, но при записи в AsyncLocal создаёт новый контекст для своей ветки; по завершении вызывающий продолжает со своим исходным контекстом (восстанавливаемым машиной состояний). Это защищает вызывающего от побочных эффектов.

Где в ASP.NET Core нет SynchronizationContext и что это даёт?

Во всём конвейере обработки запросов. Продолжения выполняются на любом потоке пула, нет «возврата» в контекст — меньше накладных расходов и нет классических deadlock-ов от .Result в UI/старом ASP.NET. Но блокирующие ожидания всё равно опасны — они вызывают голодание пула потоков.

Как реализовать «текущий тенант» — через AsyncLocal или DI?

Предпочтительно Scoped-сервис ITenantContext, заполняемый middleware: зависимость явная, тестируемая и ограничена временем жизни запроса. AsyncLocal оправдан, если значение нужно в коде вне DI (статические помощники, сторонние библиотеки) или в обработчиках сообщений, где скоуп создаётся вручную.

Итог

ExecutionContext переносит «окружающие» данные (AsyncLocal, Activity, скоупы логирования, культуру) через await и границы потоков, тогда как SynchronizationContext определяет, где выполнится продолжение, и в ASP.NET Core отсутствует. AsyncLocal работает по принципу copy-on-write, [ThreadStatic] в асинхронном коде ненадёжен. Остерегайтесь утечки контекста запроса в фоновые задачи и таймеры, а бизнес-контекст передавайте явно через DI.