Цель: отличать 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 ядер): вывод совпадает, отличаются только номера потоков.
В главе 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
Строка 6:AsyncLocal, которые просили уведомлять об изменениях (§7.4).
Строки 10–22:Capture() — просто прочитать ссылку из поля потока. Ни копирования, ни аллокаций: копировать неизменяемый объект незачем. null на потоке означает «пустой контекст» (Default), а при подавленном переносе (SuppressFlow, §7.5) Capture возвращает null — «нечего переносить».
== 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 chaptersdotnetrun-cRelease--project07-execution-context\start
// AsyncLocal<T>publicTValue{get=>(T)ExecutionContext.GetLocalValue(this);// упрощено: для значимого T null → defaultset=>ExecutionContext.SetLocalValue(this,value,_valueChangedHandler!=null);}// ExecutionContextinternalstaticvoidSetLocalValue(IAsyncLocallocal,objectnewValue,boolneedChangeNotifications){ExecutionContextexecutionContext=Thread.CurrentThread._executionContext;objectvalue=null;boolflag=false;if(executionContext!=null){flag=executionContext.m_localValues.TryGetValue(local,outvalue);}if(value==newValue){return;}...asyncLocalValueMap=executionContext.m_localValues.Set(local,newValue,!needChangeNotifications);...Thread.CurrentThread._executionContext=((!flag2&&AsyncLocalValueMap.IsEmpty(asyncLocalValueMap))?null:newExecutionContext(asyncLocalValueMap,array,flag2));if(needChangeNotifications){local.OnValueChanged(value,newValue,contextChanged:false);}}
Строка 9: значение хранится как object. Для AsyncLocal<int> каждая запись — упаковка (§7.6).
Строки 18–21: запись того же объекта ничего не делает. Сравнение по ссылке: для упакованных значимых типов «то же число» — это уже другой объект.
Строка 23:Set не меняет старый словарь, а возвращает новый (копирование при записи).
Строка 25:новыйExecutionContext с новым словарём записывается в поле потока. Старый объект не меняется: кто успел его захватить (бокс приостановленного метода, задача в очереди пула), тот так и видит старые значения.
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}");
== 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). Так рантайм и переносит контекст: «поставить захваченный снимок на поток → выполнить → вернуть как было».
Console.WriteLine("== 2. Запись в дочернем методе ==");varid=newAsyncLocal<int>();id.Value=1;awaitChildAsync();Console.WriteLine($" после ChildAsync : {id.Value}");id.Value=1;_=ChildNoAwaitAsync();Console.WriteLine($" после ChildNoAwaitAsync: {id.Value}");id.Value=1;ChildSync();Console.WriteLine($" после ChildSync : {id.Value}");
asyncTaskChildAsync(){Console.WriteLine($" в ChildAsync до записи: {id.Value}");id.Value=2;awaitTask.Delay(10);Console.WriteLine($" в ChildAsync после await: {id.Value}");}asyncTaskChildNoAwaitAsync(){id.Value=3;// async-метод без await: выполняется целиком синхронно}voidChildSync(){id.Value=4;// обычный метод}
== 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'а):
Строки 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).
Строка 3: захват — одно чтение поля потока (§7.1). Делается при каждой приостановке: после записи в AsyncLocal между двумя await бокс должен унести новый снимок (строки 6–9).
Строка 15: снимок лежит в боксе (поле Context, унаследованное от Task поле m_stateObject, глава 4).
При возобновлении бокс выполняет MoveNext внутри захваченного контекста:
CoreLib 10.0.12: AsyncStateMachineBox.MoveNext и ExecutionContext.RunFromThreadPoolDispatchLoop
Строки 9–16: бокс вызван инлайн (из SetResult, глава 5) — RunInternal: поставить снимок, выполнить, вернуть прежний контекст (как Start). Бокс взят из очереди пула — RunFromThreadPoolDispatchLoop.
Строки 22–25: поставить на поток пула захваченный снимок.
Строки 29–34: после работы очистить поток: и ExecutionContext, и SynchronizationContext. Поток пула возвращается в пул «чистым», следующий рабочий элемент не увидит чужих AsyncLocal.
Сама подмена — RestoreChangedContextToThread: записать объект в поле потока и, если кто-то из старого или нового контекста просил уведомлений, вызвать их.
AsyncLocal можно создать с обработчиком, который вызывается при каждой смене видимого на этом потоке значения. ThreadContextChanged = false — явная запись в Value, true — рантайм подменил контекст на потоке.
Console.WriteLine("== 4. Уведомления о смене значения ==");booltracing=true;// печатать уведомления только в этом опытеvartraced=newAsyncLocal<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):");awaitTask.Delay(50);Console.WriteLine(" вызов TracedChildAsync:");awaitTracedChildAsync();Console.WriteLine(" запись null:");traced.Value=null;tracing=false;
Код верхнего уровня к этому моменту уже работает на потоке пула 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) выключает печать после опыта: иначе уведомления продолжали бы приходить при раскрутке стека в следующих опытах.
ExecutionContext.SuppressFlow() ставит на поток копию контекста с флагом m_isFlowSuppressed. Пока он стоит, Capture() возвращает null (строки 17–20 листинга §7.1): новые задачи, рабочие элементы пула, таймеры и боксы контекст не уносят. Возвращает AsyncFlowControl, его Dispose() / Undo() восстанавливает перенос.
== 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
asyncTaskSuppressAcrossAwaitAsync(){try{using(ExecutionContext.SuppressFlow()){awaitTask.Delay(10);// ошибка: await внутри SuppressFlowConsole.WriteLine($" после await внутри SuppressFlow: id={id.Value}");}}catch(InvalidOperationExceptione){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. Если задача живёт дольше запроса (фоновый цикл, кеш с периодическим обновлением, «запустил и забыл»), она удерживает в памяти всё, что лежало в контексте.
privatestaticWeakReferenceHandleRequest(boolsuppressFlow){WeakReferencedata=null!;varrequest=newThread(()=>{byte[]payload=newbyte[10_000_000];data=newWeakReference(payload);s_requestData.Value=payload;if(suppressFlow){using(ExecutionContext.SuppressFlow())_=BackgroundLoopAsync();}else{_=BackgroundLoopAsync();}s_requestData.Value=null;// запрос «закончился» и всё за собой убрал});request.Start();request.Join();returndata;}privatestaticasyncTaskBackgroundLoopAsync(){while(true)awaitTask.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, не меняя саму лямбду.
Capture() бесплатен (столбец справа): это чтение ссылки. Поэтому await ничего не платит за перенос контекста, сколько бы AsyncLocal там ни было.
Запись — всегда новый ExecutionContext и копия словаря. При 1–16 значениях это десятки-сотни байт, начиная с 17 — копирование Dictionary.
AsyncLocal<int> — ещё 24 байта на упаковку int (96 = 72 + 24).
Отсюда практические правила: не пишите в AsyncLocal в горячем цикле; держите в контексте немного значений; если нужно много полей «текущей операции», положите в один AsyncLocal объект-держатель.
SynchronizationContext — где выполнить продолжение, ExecutionContext — какие данные видит код. Оба — поля потока. ConfigureAwait(false) влияет только на первый.
ExecutionContextнеизменяем. Capture() — чтение ссылки, бесплатно. Запись в AsyncLocal создаёт новый контекст и новый словарь значений (72+ байт, для значимых типов плюс упаковка).
Через await контекст переходит в боксе: захват при каждой приостановке, установка при возобновлении, очистка потока пула после работы.
AsyncMethodBuilderCore.Start возвращает вызывающему его контексты, поэтому запись в AsyncLocal внутри async-метода (даже без await) не видна вызывающему. Внутри обычного метода — видна.
SuppressFlow — чтобы фоновая работа не уносила контекст (и не удерживала данные запроса). Внутри него не должно быть await.
Для данных «текущей операции» в async-коде — AsyncLocal, не ThreadLocal/[ThreadStatic].
// Глава 7, заготовка. AsyncLocal против ThreadLocal; запись в AsyncLocal из дочерних методов.// PREDICT: что напечатает каждая строка с комментарием PREDICT?// TODO (§7.5): фоновая задача в конце не должна видеть id «запроса». Добейтесь, чтобы она печатала id=0.varasyncLocal=newAsyncLocal<string>();varthreadLocal=newThreadLocal<string>();asyncLocal.Value="значение";threadLocal.Value="значение";Console.WriteLine($"до await : поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");awaitTask.Delay(100);Console.WriteLine($"после await: поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");// PREDICT:varid=newAsyncLocal<int>();id.Value=1;awaitChildAsync();Console.WriteLine($"после ChildAsync: {id.Value}");// PREDICT:id.Value=1;ChildSync();Console.WriteLine($"после ChildSync : {id.Value}");// PREDICT:Taskbackground=Task.Run(()=>Console.WriteLine($"фоновая задача: id={id.Value}"));// PREDICT: TODOawaitbackground;asyncTaskChildAsync(){Console.WriteLine($"в ChildAsync до записи : {id.Value}");// PREDICT:id.Value=2;awaitTask.Delay(10);Console.WriteLine($"в ChildAsync после await: {id.Value}");// PREDICT:}voidChildSync(){id.Value=3;}
// Глава 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 ==");varasyncLocal=newAsyncLocal<string>();varthreadLocal=newThreadLocal<string>();asyncLocal.Value="значение";threadLocal.Value="значение";Console.WriteLine($" до await : поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");awaitTask.Delay(100);Console.WriteLine($" после await: поток {Environment.CurrentManagedThreadId}, AsyncLocal={asyncLocal.Value}, ThreadLocal={threadLocal.Value ?? "null"}");Console.WriteLine();Console.WriteLine("== 2. Запись в дочернем методе ==");varid=newAsyncLocal<int>();id.Value=1;awaitChildAsync();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. Уведомления о смене значения ==");booltracing=true;// печатать уведомления только в этом опытеvartraced=newAsyncLocal<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):");awaitTask.Delay(50);Console.WriteLine(" вызов TracedChildAsync:");awaitTracedChildAsync();Console.WriteLine(" запись null:");traced.Value=null;tracing=false;Console.WriteLine();Console.WriteLine("== 5. SuppressFlow ==");Tasksuppressed;using(ExecutionContext.SuppressFlow()){suppressed=Task.Run(()=>Console.WriteLine($" Task.Run при SuppressFlow : id={id.Value}"));}awaitsuppressed;awaitTask.Run(()=>Console.WriteLine($" Task.Run без SuppressFlow: id={id.Value}"));awaitSuppressAcrossAwaitAsync();Console.WriteLine($" после SuppressAcrossAwaitAsync: id={id.Value}");awaitLeak.RunAsync();Console.WriteLine();Console.WriteLine("== 6. Стоимость ==");awaitCost.RunAsync();asyncTaskChildAsync(){Console.WriteLine($" в ChildAsync до записи: {id.Value}");id.Value=2;awaitTask.Delay(10);Console.WriteLine($" в ChildAsync после await: {id.Value}");}asyncTaskChildNoAwaitAsync(){id.Value=3;// async-метод без await: выполняется целиком синхронно}voidChildSync(){id.Value=4;// обычный метод}asyncTaskTracedChildAsync(){traced.Value="child";awaitTask.Delay(50);Console.WriteLine($" TracedChildAsync после await: {traced.Value}");}asyncTaskSuppressAcrossAwaitAsync(){try{using(ExecutionContext.SuppressFlow()){awaitTask.Delay(10);// ошибка: await внутри SuppressFlowConsole.WriteLine($" после await внутри SuppressFlow: id={id.Value}");}}catch(InvalidOperationExceptione){Console.WriteLine($" выход из using: {e.GetType().Name}: {e.Message}");}}
// §7.5. Фоновая задача, запущенная из «запроса», уносит с собой ExecutionContext запроса// и удерживает всё, что лежит в его AsyncLocal.staticclassLeak{privatestaticreadonlyAsyncLocal<byte[]?>s_requestData=new();publicstaticasyncTaskRunAsync(){WeakReferenceplain=HandleRequest(suppressFlow:false);WeakReferencesuppressed=HandleRequest(suppressFlow:true);awaitTask.Delay(200);// фоновые циклы успели несколько раз проснутьсяGC.Collect();GC.WaitForPendingFinalizers();GC.Collect();Console.WriteLine($" данные запроса живы после GC: без SuppressFlow={plain.IsAlive}, с SuppressFlow={suppressed.IsAlive}");}// «Обработка запроса»: 10 МБ данных в AsyncLocal и запуск вечного фонового цикла.privatestaticWeakReferenceHandleRequest(boolsuppressFlow){WeakReferencedata=null!;varrequest=newThread(()=>{byte[]payload=newbyte[10_000_000];data=newWeakReference(payload);s_requestData.Value=payload;if(suppressFlow){using(ExecutionContext.SuppressFlow())_=BackgroundLoopAsync();}else{_=BackgroundLoopAsync();}s_requestData.Value=null;// запрос «закончился» и всё за собой убрал});request.Start();request.Join();returndata;}privatestaticasyncTaskBackgroundLoopAsync(){while(true)awaitTask.Delay(50);// данные запроса не нужны, но контекст захвачен}}
// §7.6. Сколько байт выделяет одна запись в AsyncLocal и один Capture.staticclassCost{publicstaticTaskRunAsync(){using(ExecutionContext.SuppressFlow())// замер — в чистом контексте, без AsyncLocal основной программыreturnTask.Run(Measure);}privatestaticvoidMeasure(){foreach(intcountinnew[]{1,2,3,4,5,8,16,17}){varlocals=newAsyncLocal<object?>[count];vara=newobject();varb=newobject();for(inti=0;i<count;i++){locals[i]=newAsyncLocal<object?>();locals[i].Value=a;}doubleset=PerCall(i=>locals[0].Value=i%2==0?b:a);doublecapture=PerCall(_=>ExecutionContext.Capture());Console.WriteLine($" AsyncLocal в контексте: {count,2} запись: {set,4} байт Capture: {capture} байт");foreach(varlocalinlocals)local.Value=null;}varnumber=newAsyncLocal<int>();number.Value=-1;Console.WriteLine($" AsyncLocal<int>, 1 в контексте: запись: {PerCall(i => number.Value = i)} байт");}privatestaticdoublePerCall(Action<int>action){constintN=10_000;action(0);longbefore=GC.GetAllocatedBytesForCurrentThread();for(inti=1;i<=N;i++)action(i);return(GC.GetAllocatedBytesForCurrentThread()-before)/(double)N;}}