21. Память и сборка мусора в async-коде: утечки и их поиск¶
О главе
Цель: понимать, что держит в памяти ждущий async-метод, где в async-коде возникают утечки, как поколения GC влияют на долгие ожидания и как находить утечки по дампу.
Лабораторная:start/ — сервис с тремя утечками (реестр запросов, забытый таймер, подписки на события): найдите и исправьте. final/ — исправленный сервис (-- service) и сценарии hoist, delay, tcs, event, channel, gen, а также hang и hang-cancelled (процесс «зависает», чтобы подключиться dotnet-dump/dotnet-gcdump). Запуск: dotnet run -c Release --project final -- <режим>.
Статус: ✅ проверено на стенде Ubuntu 26.04 (2 ядра, .NET 10.0.12) и перепроверено на Windows 11 (8 ядер): числа по памяти совпадают, различия — во вкладках; инструменты dotnet-* версии 10.0.750501 лежат в манифесте (dotnet tool restore). Числа мегабайт зависят от версии рантайма и платформы: смотрите на соотношения.
Пока async-метод ждёт, он существует как бокс машины состояний на куче (глава 1). Чтобы бокс собрали, до него не должно быть ни одной цепочки ссылок от корня (статическое поле, стек потока, дескриптор GC). Ждущий метод держат: Task, на который он подписан; тот, кто завершит эту задачу (таймер, TaskCompletionSource, сокет); и всё, что достижимо из его полей, включая «поднятые» локальные переменные и this.
Цепочку от корня видно прямо в дампе. Для 50 000 брошенных Task.Delay(1 ч) (режим hang):
Это ровно та цепочка, что нарисована в главе 1 («кто держит бокс, пока мы ждём»): статика с очередью таймеров → TimerQueueTimer → DelayPromise → бокс → ваши поля. Пока таймер не сработает, ни бокс, ни ваши данные не освободятся, даже если все ваши ссылки на Task потеряны.
Поле машины состояний появляется только у переменной, значение которой нужно послеawait (глава 2). Режим hoist держит три приостановленных метода с буфером по 1 МБ и проверяет через WeakReference, жив ли буфер:
staticasyncTaskUsedAfterAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;awaitwait;Console.Write(buffer[0]>0?"":"?");// буфер нужен после await → станет полем машины состояний}staticasyncTaskNotUsedAfterAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;awaitwait;// после await буфер не используется → остаётся локальной в MoveNext}staticasyncTaskNulledBeforeAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;bytefirst=buffer[0];buffer=null;awaitwait;Console.Write(first>5?"?":"");}
dotnet run -c Release --project final -- hoist (Release)
буфер нужен после await : жив = True
буфер после await не нужен: жив = False
буфер обнулён до await : жив = False
После завершения методов: буфер из первого жив = False
Факт: держит только то, что нужно после await
Буфер из UsedAfterAwait стал полем машины состояний и жив, пока метод ждёт (строки 47 и 51). В NotUsedAfterAwait после await он не нужен: остался локальной переменной MoveNext и собран, хотя метод всё ещё приостановлен. Обнуление (buffer = null до await, строка 68) тоже освобождает. Совет «не держите большие объекты в локальных переменных через await» (глава 12) означает: после await не обращайтесь к ним, или null'ьте. Опыт снят в Release; в Debug JIT продлевает жизнь локальных, и результат может отличаться.
С токеном: … → +27.0 МБ; после Cancel +0.3 МБ
Без токена: … → +25.1 МБ; после отказа от ссылок на задачи +24.9 МБ
Task.Delay(…) без токена кладёт запись в очередь таймеров, и она живёт до срабатывания. Когда ожидание «брошено» (клиент ушёл, операция отменена вверху), но токен вниз не передан, час (или десять минут) тащится и сама запись, и бокс, и всё из его полей. С токеном при отмене таймер снимается, и память возвращается.
sealedclassPending{privatereadonlyDictionary<int,TaskCompletionSource<string>>_byId=new();publicintCount=>_byId.Count;publicasyncTaskWaitAsync(intid,byte[]context,CancellationTokenct){vartcs=newTaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);_byId[id]=tcs;usingvarreg=ct.Register(()=>{_byId.Remove(id);// снять запись из реестра…tcs.TrySetCanceled(ct);// …и разбудить ждущего отменой});stringreply=awaittcs.Task;Console.Write(context.Length+reply.Length);}}
-- tcs
Реестр с токеном: +28.8 МБ пока ждут, после отмены +1.2 МБ, в реестре 0 записей
Реестр без таймаута: ответы потерялись → +24.9 МБ, в реестре 20000 записей
Классика: «отправил запрос, жду ответ через TaskCompletionSource, ответ не пришёл». Запись в словаре держит TaskCompletionSource, тот держит бокс ждущего. Вариант с токеном (строки 147–151: Register снимает запись из реестра и отменяет TCS) освобождает всё при отмене; даже без токена нужен таймаут на уровне реестра.
sealedclassBus// долгоживущий издатель (синглтон в реальном приложении){privateeventFunc<string,Task>?Message;publicintSubscribers=>Message?.GetInvocationList().Length??0;publicvoidSubscribe(Func<string,Task>h)=>Message+=h;publicvoidUnsubscribe(Func<string,Task>h)=>Message-=h;}sealedclassScreen:IDisposable{privatereadonlyBus_bus;privatereadonlybyte[]_state=newbyte[1000];privatereadonlyFunc<string,Task>_handler;publicScreen(Busbus){_bus=bus;_handler=asynctext=>{awaitTask.Yield();_state[0]=(byte)text.Length;};// лямбда держит thisbus.Subscribe(_handler);}publicvoidDispose()=>_bus.Unsubscribe(_handler);}
-- event
Закрыто 10000 экранов без отписки: живых = 10000, подписчиков у издателя = 10000
Закрыто 10000 экранов с отпиской: живых = 0, подписчиков у издателя = 0
Обработчик события — делегат, который держит this подписчика (строка 193: лямбда использует _state, то есть this). Издатель, который живёт дольше подписчиков (шина, синглтон, статическое событие), держит их всех. Асинхронный обработчик ничем не отличается от синхронного: отписка обязательна.
-- channel (2 секунды при потребителе медленнее производителя; три запуска для Unbounded)
Unbounded за 2 с произведено 81743, обработано 934, в очереди 80809, куча ≈ +775 МБ
Unbounded … в очереди 94324, куча ≈ +905 МБ
Unbounded … в очереди 92904, куча ≈ +890 МБ
Bounded(100) за 2 с произведено 1772, обработано 1673, в очереди 100, куча ≈ +6 МБ
-- channel (Windows 11, 8 ядер; три запуска для Unbounded)
Unbounded за 2 с произведено 281153, обработано 136, в очереди 280843, куча ≈ +2693 МБ
Unbounded за 2 с произведено 385835, обработано 135, в очереди 385544, куча ≈ +3694 МБ
Unbounded за 2 с произведено 412621, обработано 137, в очереди 412315, куча ≈ +3950 МБ
Bounded(100) за 2 с произведено 231, обработано 131, в очереди 100, куча ≈ +2 МБ
Channel.CreateUnbounded или фоновая очередь без ёмкости при медленном потребителе растут без предела: меньше чем за две секунды на Linux набралось ~800–900 МБ, на Windows — 2,7–4 ГБ. Принцип одинаков, а цифры определяют две вещи. Во-первых, потребитель в лабораторной ждёт Task.Delay(1) на каждом сообщении: на Linux это ≈ 2 мс (обработано ≈ 930 за 2 с), на Windows ≈ 15 мс из-за шага таймера (обработано ≈ 135; глава 10), то есть потребитель на Windows ещё медленнее. Во-вторых, производитель на стенде Windows быстрее (больше ядер и мощнее процессор): 281–413 тыс. сообщений против 82–94 тыс. Ограниченный канал на обеих ОС держит очередь на 100 и кучу на уровне нескольких МБ: пропускная способность производителя им и регулируется. Не запускайте этот режим на машине с малой памятью. Ограниченный канал (глава 11) замедляет производителя и удерживает память на уровне нескольких МБ.
Факт: остальные опыты главы на Windows дали те же числа
Windows 11, 8 ядер, .NET 10.0.12: hoist (жив / не жив / не жив / не жив) — как на Linux; delay (+26,9 МБ, после Cancel +0,3; без токена +25,1 → +24,9); tcs (+28,8 МБ → +1,2, в реестре 0 записей; без таймаута +24,9 МБ, 20 000 записей); event (10 000 живых против 0) — совпадают с Linux до десятых МБ. Потому что это размеры объектов .NET и поведение сборщика, а не ОС. Режим hang (50 000 вызовов Delay(1 ч)): gcdump — 50 000 byte[] по 1 КБ, 65,5 МБ кучи; dumpheap -stat — по 50 001 DelayPromise (3 600 072 Б), TimerQueueTimer (4 800 096 Б) и 50 000 боксов WaitLongAsync (5 600 000 Б), dumpasync --stats — 50 000 боксов: размеры объектов совпадают с Linux байт в байт. Адреса и номера таблиц методов, понятно, другие.
CancellationTokenSource.CreateLinkedTokenSource без Dispose
9
100 000 обработчиков остались на родителе, ~14 МБ
AsyncLocal с большим объектом уходит в долгую фоновую задачу
7
контекст течёт в Task.Run; для чистого запуска SuppressFlow
Lazy<Task<T>> кеширует результат или ошибку навсегда
10
свой AsyncLazy с повтором
Fire-and-forget без ограничения
16, 17
потерянные исключения и неограниченное число задач
Факт: одна отменённая задача держит в памяти десятки тысяч других
Режим hang-cancelled: 20 000 вызовов Task.Delay(1 ч, token), все отменены, список задач очищен, но одна отменённая задача лежит в статическом поле (например, «последняя ошибка» для лога). Куча остаётся ~7 МБ вместо 0,3. gcroot показывает путь:
Исключение отмены хранит свою задачу, у задачи — регистрация в списке токена, регистрации связаны в цепочку CallbackNode, и по ней достижимы все остальные задачи и таймеры. Практический вывод: не храните объекты исключений и задач отмены долго (в статике, в кеше «последних ошибок», в долгоживущих очередях диагностики): храните текст и тип.
Факт: измерение сразу после завершения может соврать
Первые варианты delay и tcs показывали «+7 МБ» после отмены, хотя через секунду кучу видно «+0,3 МБ». gcroot нашёл корень в стеке потока: продолжение метода, дожидавшегося отменённых задач, выполнялось инлайн поверх кадра цикла foreach, и в этом кадре оставалась ссылка на последнюю задачу, а через неё (по цепочке выше) на все остальные. После выхода из кадра всё освобождается. Поэтому в лабораторной между отменой и замером есть await Task.Delay(100) и вынесенный метод DrainCancelledAsync. Если ваш замер памяти «сразу после» не сходится, подождите, пока стек размотается, прежде чем объявлять утечку.
Сборщик делит кучу на поколения: новые объекты в gen0, пережившие сборку переходят выше. Короткоживущий мусор дёшев, а gen2 собирается только полной сборкой. async-методы, которые долго ждут, переживают несколько сборок gen0 и gen1 и оказываются в gen2.
-- gen (.NET 10.0.12; два запуска дали одно и то же)
Поколение состояния ждущего метода после сборок: 0 → 1 → 2
100000 ждущих методов: сборок gen0/gen1/gen2 за создание: 10/7/1
Размеры поколений после последней сборки, МБ: gen0=0.0 gen1=0.3 gen2=48.0
-- gen (.NET 10.0.12; три запуска дали одно и то же)
Поколение состояния ждущего метода после сборок: 0 → 1 → 2
100000 ждущих методов: сборок gen0/gen1/gen2 за создание: 12/7/3
Размеры поколений после последней сборки, МБ: gen0=0.0 gen1=0.0 gen2=48.4
Путь состояния 0 → 1 → 2 и итоговый размер gen2 (≈ 48 МБ, ≈ 480 байт на ждущий метод) на обеих ОС одинаковы; число сборок за создание немного отличается (10/7/1 и 12/7/3): когда запускается сборка, решает сборщик по скорости выделения и числу ядер.
Факт: 100 000 ждущих методов — это 48 МБ в gen2
Состояние одного ждущего метода (бокс, DelayPromise, TimerQueueTimer, ваш массив 200 Б) вместе занимает около 480 байт. Они пережили сборки gen0 и gen1 и осели в gen2: освободить их может только сборка gen2. Для сервера это значит: много долгих ожиданий (минуты, часы: таймеры, long polling, ожидание сообщения) = раздувшаяся gen2 и более редкие, но тяжёлые полные сборки. Короткие ожидания (десятки миллисекунд) обычно умирают в gen0, не доходя до gen2.
Отсюда признак утечки на счётчиках: размер gen2 после последней сборки растёт от сборки к сборке (§21.5).
В .NET 10 метрики GC называются dotnet.gc.last_collection.heap.size (по поколениям gen0, gen1, gen2, loh, poh), dotnet.gc.last_collection.heap.fragmentation.size, dotnet.gc.collections, dotnet.gc.heap.total_allocated, dotnet.gc.pause.time. Для режима hang в gen2 после последней сборки было 58,5 МБ.
Из кода: GC.GetGCMemoryInfo(GCKind.FullBlocking) даёт HeapSizeBytes, FragmentedBytes, размеры по поколениям.
Факт: GC.GetTotalMemory(true) — не «живые данные»
Метод возвращает размер кучи, включающий свободные дыры внутри неё. В лабораторной вместо него HeapSizeBytes - FragmentedBytes после полной блокирующей сборки с уплотнением (функция Mb()). Для проверок утечек надёжнее счётчик живых объектов через WeakReference.
Быстрый и лёгкий снимок только достижимых объектов: размер, количество, тип. Для hang показывает 50 000 byte[], ~65 МБ кучи. Предупреждение при сборе: the target runtime does not support non-lossy (Block) buffering, which requires .NET 11+; the gcdump may be incomplete on large heaps. На нашем стенде расхождений не было (для hanggcdump насчитал ~65,3 МБ, dumpheap -stat ~65,9 МБ), но на больших кучах .NET 10 предупреждению стоит верить: для окончательных выводов берите полный дамп.
MT Count Type
000070ab47593c60 50,000 Scenarios+<WaitLongAsync>d__9
Читается так: 50 000 одинаковых боксов WaitLongAsync рядом с 50 000 таймерами и 50 000 массивами — это не «нагрузка», а накопление. Дальше dumpheap -mt <MT> даёт адреса объектов, gcroot <адрес> — путь от корня (§21.1).
Факт: dumpheap без -live показывает и мусор
В дампе процесса после отмены dumpheap -stat показывал 20 000 TimerQueueTimer, 20 000 CallbackNode, 20 000 DelayPromiseWithCancellation, около 7,7 МБ. Те же объекты с ключом -live — 367 объектов, 594 КБ: недостижимые, но ещё не вычищенные сборщиком. Для вопроса «что утекло» используйте -live (считается дольше) и сверяйте с gcroot: если пути нет, это ещё не утечка.
Счётчики: растёт ли размер gen2 после последней сборки и общий размер? Если растёт только между сборками и падает после них — это нагрузка, не утечка.
dumpheap -stat -live (или gcdump report): какие типы растут в количестве. Типичные подозреваемые в async-коде: боксы машин состояний …d__N, Task/DelayPromise, TimerQueueTimer, CancellationTokenSource+CallbackNode, TaskCompletionSource, свои DTO.
gcroot на несколько адресов из разных мест списка: общий корень и есть виновник (статическое поле, реестр, шина событий, очередь таймеров).
dumpasync: в каком состоянии ждущие методы и на чём ждут. Тысячи одинаковых боксов в одном состоянии = забытое ожидание.
[MethodImpl(MethodImplOptions.NoInlining)]// иначе JIT может оставить ссылку в регистре вызывающегоstaticWeakReferenceCreateAndCloseScreen(Busbus){varscreen=newScreen(bus);screen.Close();returnnewWeakReference(screen);}varweak=CreateAndCloseScreen(bus);GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();Assert.False(weak.IsAlive);// не жив → отписались
Тот же приём лежит в основе режимов hoist и event: создаём объект в отдельном методе, возвращаем только WeakReference, принудительно собираем и смотрим IsAlive.
Токен вниз в каждый Task.Delay, WaitAsync, запрос, ожидание реестра. Отмена снимает таймеры и регистрации.
Таймаут и очистка у любых структур, где запись ждёт внешнего события (ответа, сообщения, сигнала).
Отписка от событий долгоживущих издателей, в том числе у асинхронных обработчиков; для кода «подписался и забыл» — слабые события или явный жизненный цикл.
Ограниченные очереди (глава 11) и ограничение числа одновременных задач (глава 10).
using/Dispose у связанных CancellationTokenSource и регистраций.
Не хранить в статике исключения и задачи надолго; логировать данные, а не объекты.
Не держать большие данные в локальных переменных, нужных после долгого await: передавать нужный кусок, обнулять, выносить в отдельный метод.
Долгие ожидания (минуты) знать как нагрузку на gen2.
Ждущий метод живёт в куче, пока его держит таймер, TaskCompletionSource, событие или реестр: ссылки на Task у вас может не быть, а память занята.
После await держится только то, что используется после него (и this).
Классические утечки в async: таймеры без токена, реестры ожидающих без таймаута, подписки на события, неограниченные очереди, долгое хранение исключений отмены.
Долгие ожидания оседают в gen2 (100 000 ждущих методов = 48 МБ в нашем опыте).
Диагностика: счётчики dotnet.gc.*, dumpheap -stat -live, gcroot, dumpasync; gcdump быстрый, но на .NET 10 на больших кучах может быть неполным (предупреждение инструмента); замеряйте после того, как стек размотался.
// Глава 21. Сервис с тремя утечками. Запуск: dotnet run -c Release --project start// Программа имитирует работу и печатает, сколько памяти и объектов осталось ПОСЛЕ неё.// PREDICT: сколько экранов и ожидающих запросов останется живыми? Сколько мегабайт?// TODO 1: найдите все три места утечки. Для этого запустите с паузой (аргумент hang) и посмотрите dotnet-dump: dumpheap -stat, gcroot.// TODO 2: исправьте так, чтобы после «нагрузки» живых экранов и записей реестра было 0, а куча ≈ 1 МБ.// TODO 3: как бы вы поймали эту утечку тестом? (подсказка: WeakReference + GC.Collect)usingSystem.Collections.Concurrent;constintN=10_000;varbus=newBus();vargateway=newGateway();varscreens=newList<WeakReference>();doublebefore=Mb();usingvarcts=newCancellationTokenSource();// Утечка 1 и 2: запросы без ответа и «keep-alive» без токена.varrequests=newList<Task>();for(inti=0;i<N;i++)requests.Add(gateway.RequestAsync(i,newbyte[1000],cts.Token));awaitgateway.KeepAliveAsync(100);// 100 ожиданий по 10 минут: «забытый» таймер// Утечка 3: экраны подписываются на шину и «закрываются».for(inti=0;i<N;i++){varscreen=newScreen(bus);screens.Add(newWeakReference(screen));screen.Close();}// Конец «нагрузки»: клиенты ушли.cts.Cancel();// «разрыв соединений»: токен отменёнrequests.Clear();// про задачи запросов мы больше не помнимawaitTask.Delay(200);// даём стеку завершения размотатьсяGC.Collect();GC.WaitForPendingFinalizers();GC.Collect();Console.WriteLine($"Живых экранов: {screens.Count(s => s.IsAlive)} из {N}");Console.WriteLine($"Записей в реестре шлюза: {gateway.Pending}");Console.WriteLine($"Куча: +{Mb() - before:F1} МБ");if(args.FirstOrDefault()=="hang"){Console.WriteLine($"PID {Environment.ProcessId}: подключайтесь dotnet-dump / dotnet-gcdump. Завершится через 120 с.");awaitTask.Delay(TimeSpan.FromSeconds(120));}staticdoubleMb(){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);varinfo=GC.GetGCMemoryInfo(GCKind.FullBlocking);return(info.HeapSizeBytes-info.FragmentedBytes)/1024.0/1024.0;}sealedclassGateway{privatereadonlyConcurrentDictionary<int,TaskCompletionSource<string>>_pending=new();publicintPending=>_pending.Count;publicasyncTaskRequestAsync(intid,byte[]context,CancellationTokenct){vartcs=newTaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);_pending[id]=tcs;stringreply=awaittcs.Task;// ответ приходит из другого места; он может не прийти_pending.TryRemove(id,out_);Console.Write(context.Length+reply.Length);}publicasyncTaskKeepAliveAsync(intcount){varwaits=newList<Task>();for(inti=0;i<count;i++)waits.Add(KeepOneAsync(newbyte[10_000]));awaitTask.Delay(10);// «работаем дальше», не дожидаясь keep-alive}privatestaticasyncTaskKeepOneAsync(byte[]state){awaitTask.Delay(TimeSpan.FromMinutes(10));Console.Write(state.Length);}}sealedclassBus{publiceventFunc<string,Task>?Message;publicvoidSubscribe(Func<string,Task>handler)=>Message+=handler;}sealedclassScreen{privatereadonlybyte[]_state=newbyte[1000];publicScreen(Busbus)=>bus.Subscribe(asynctext=>{awaitTask.Yield();_state[0]=(byte)text.Length;});publicvoidClose(){/* экран закрыт, пользователь ушёл */}}
// Глава 21. Память и сборка мусора в async-коде. Режимы (после -- ):// hoist — что держит локальная переменная async-метода, пока он приостановлен;// delay — «брошенные» Task.Delay и отмена токеном;// tcs — запросы, ждущие ответ, который не придёт (реестр ожидающих);// event — async-обработчики событий долгоживущего издателя;// channel — неограниченная и ограниченная очередь при медленном потребителе;// gen — поколения: во что превращается долго ждущий async-метод;// service — сервис из start/ с исправленными утечками;// hang — утечка из режима delay и пауза, чтобы подключиться dotnet-gcdump / dotnet-dump;// hang-cancelled — «исправленный» delay, но одна отменённая задача осталась в статическом поле: найдите gcroot'ом, что держит память.// Запуск: dotnet run -c Release --project final -- delayswitch(args.FirstOrDefault()??"hoist"){case"hoist":awaitScenarios.Hoist();break;case"delay":awaitScenarios.Delay();break;case"tcs":awaitScenarios.Tcs();break;case"event":awaitScenarios.Events();break;case"channel":awaitScenarios.ChannelGrowth();break;case"gen":awaitScenarios.Generations();break;case"hang-cancelled":Scenarios.HangCancelled(args.Length>1?int.Parse(args[1]):120);break;case"service":awaitService.RunAsync();break;case"hang":awaitScenarios.Hang(args.Length>1?int.Parse(args[1]):120);break;}
usingSystem.Threading.Channels;staticclassScenarios{staticPending?_leakyRegistry;// «долгоживущий» реестр: статическое поле// Прогон GC и измерение: сколько мегабайт в управляемой куче.// Живые данные: после полной блокирующей сборки берём размер кучи без фрагментации.// (GC.GetTotalMemory(true) в этом месте завышает на дыры в куче: см. §21.5.)staticdoubleMb(){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);varinfo=GC.GetGCMemoryInfo(GCKind.FullBlocking);return(info.HeapSizeBytes-info.FragmentedBytes)/1024.0/1024.0;}staticboolAlive(WeakReferencew){GC.Collect();GC.WaitForPendingFinalizers();GC.Collect();returnw.IsAlive;}// ---------- 1. Что держит локальная переменная ----------publicstaticasyncTaskHoist(){vargate=newTaskCompletionSource();varusedAfter=newWeakReference(null);varnotUsedAfter=newWeakReference(null);varnulled=newWeakReference(null);vart1=UsedAfterAwait(gate.Task,usedAfter);vart2=NotUsedAfterAwait(gate.Task,notUsedAfter);vart3=NulledBeforeAwait(gate.Task,nulled);Console.WriteLine("Три метода приостановлены на await, буфер по 1 МБ у каждого.");Console.WriteLine($" буфер нужен после await : жив = {Alive(usedAfter)}");Console.WriteLine($" буфер после await не нужен: жив = {Alive(notUsedAfter)}");Console.WriteLine($" буфер обнулён до await : жив = {Alive(nulled)}");gate.SetResult();awaitTask.WhenAll(t1,t2,t3);Console.WriteLine($"После завершения методов: буфер из первого жив = {Alive(usedAfter)}");}staticasyncTaskUsedAfterAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;awaitwait;Console.Write(buffer[0]>0?"":"?");// буфер нужен после await → станет полем машины состояний}staticasyncTaskNotUsedAfterAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;awaitwait;// после await буфер не используется → остаётся локальной в MoveNext}staticasyncTaskNulledBeforeAwait(Taskwait,WeakReferenceprobe){varbuffer=newbyte[1_000_000];probe.Target=buffer;buffer[0]=1;bytefirst=buffer[0];buffer=null;awaitwait;Console.Write(first>5?"?":"");}// ---------- 2. Брошенные Task.Delay ----------publicstaticasyncTaskDelay(){constintN=20_000;usingvarcts=newCancellationTokenSource();doublebefore=Mb();vartasks=newList<Task>();for(inti=0;i<N;i++)tasks.Add(WaitLongAsync(newbyte[1000],cts.Token));doubleheld=Mb();cts.Cancel();awaitDrainCancelledAsync(tasks);awaitTask.Delay(100);// дать стеку, на котором шло завершение задач, размотаться (см. §21.3)doubleafterCancel=Mb();Console.WriteLine($"С токеном: {N} вызовов Delay(1 ч) с буфером 1 КБ → +{held - before:F1} МБ; после Cancel +{afterCancel - before:F1} МБ");cts.Dispose();Console.WriteLine($" после cts.Dispose() +{Mb() - before:F1} МБ");before=Mb();varleaked=newList<Task>();for(inti=0;i<N;i++)leaked.Add(WaitLongAsync(newbyte[1000],CancellationToken.None));held=Mb();leaked.Clear();// выбрасываем свои ссылки на задачиdoubleafterDrop=Mb();Console.WriteLine($"Без токена: {N} вызовов Delay(1 ч) → +{held - before:F1} МБ; после отказа от ссылок на задачи +{afterDrop - before:F1} МБ");Console.WriteLine(" (память держит очередь таймеров, а не вы: ссылок на задачи у вас уже нет)");}// Дождаться отменённых задач в ОТДЕЛЬНОМ методе: после выхода из него в кадре вызывающего не остаётся ссылки на последнюю задачу.staticasyncTaskDrainCancelledAsync(List<Task>tasks){foreach(vartintasks){try{awaitt;}catch(OperationCanceledException){}}tasks.Clear();}staticasyncTaskWaitLongAsync(byte[]payload,CancellationTokenct){awaitTask.Delay(TimeSpan.FromHours(1),ct);Console.Write(payload.Length);}// ---------- 3. Реестр ожидающих запросов ----------publicstaticasyncTaskTcs(){constintN=20_000;varfixedReg=newPending();usingvarcts=newCancellationTokenSource();doublebefore=Mb();varw2=newList<Task>();for(inti=0;i<N;i++)w2.Add(fixedReg.WaitAsync(i,newbyte[1000],cts.Token));doubleheld=Mb()-before;cts.Cancel();awaitDrainCancelledAsync(w2);awaitTask.Delay(100);Console.WriteLine($"Реестр с токеном: +{held:F1} МБ пока ждут, после отмены +{Mb() - before:F1} МБ, в реестре {fixedReg.Count} записей");_leakyRegistry=newPending();before=Mb();varwaiting=newList<Task>();for(inti=0;i<N;i++)waiting.Add(_leakyRegistry.WaitAsync(i,newbyte[1000],CancellationToken.None));waiting.Clear();Console.WriteLine($"Реестр без таймаута: ответы потерялись → +{Mb() - before:F1} МБ, в реестре {_leakyRegistry.Count} записей");}sealedclassPending{privatereadonlyDictionary<int,TaskCompletionSource<string>>_byId=new();publicintCount=>_byId.Count;publicasyncTaskWaitAsync(intid,byte[]context,CancellationTokenct){vartcs=newTaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);_byId[id]=tcs;usingvarreg=ct.Register(()=>{_byId.Remove(id);// снять запись из реестра…tcs.TrySetCanceled(ct);// …и разбудить ждущего отменой});stringreply=awaittcs.Task;Console.Write(context.Length+reply.Length);}}// ---------- 4. Подписчики событий ----------publicstaticasyncTaskEvents(){constintN=10_000;varbus=newBus();varprobes=newList<WeakReference>();for(inti=0;i<N;i++){vars=newScreen(bus);probes.Add(newWeakReference(s));}GC.Collect();Console.WriteLine($"Закрыто {N} экранов без отписки: живых = {probes.Count(p => p.IsAlive)}, подписчиков у издателя = {bus.Subscribers}");varbus2=newBus();varprobes2=newList<WeakReference>();for(inti=0;i<N;i++){vars=newScreen(bus2);probes2.Add(newWeakReference(s));s.Dispose();}GC.Collect();Console.WriteLine($"Закрыто {N} экранов с отпиской: живых = {probes2.Count(p => p.IsAlive)}, подписчиков у издателя = {bus2.Subscribers}");awaitTask.CompletedTask;}sealedclassBus// долгоживущий издатель (синглтон в реальном приложении){privateeventFunc<string,Task>?Message;publicintSubscribers=>Message?.GetInvocationList().Length??0;publicvoidSubscribe(Func<string,Task>h)=>Message+=h;publicvoidUnsubscribe(Func<string,Task>h)=>Message-=h;}sealedclassScreen:IDisposable{privatereadonlyBus_bus;privatereadonlybyte[]_state=newbyte[1000];privatereadonlyFunc<string,Task>_handler;publicScreen(Busbus){_bus=bus;_handler=asynctext=>{awaitTask.Yield();_state[0]=(byte)text.Length;};// лямбда держит thisbus.Subscribe(_handler);}publicvoidDispose()=>_bus.Unsubscribe(_handler);}// ---------- 5. Очередь без ограничения ----------publicstaticasyncTaskChannelGrowth(){foreach(boolboundedinnew[]{false,true}){Channel<byte[]>ch=bounded?Channel.CreateBounded<byte[]>(100):Channel.CreateUnbounded<byte[]>();doublebefore=Mb();intproduced=0,consumed=0;usingvarstop=newCancellationTokenSource();varproducer=Task.Run(async()=>{try{while(true){awaitch.Writer.WriteAsync(newbyte[10_000],stop.Token);produced++;}}catch(OperationCanceledException){}});varconsumer=Task.Run(async()=>{try{awaitforeach(var_inch.Reader.ReadAllAsync(stop.Token)){if(stop.IsCancellationRequested)break;consumed++;awaitTask.Delay(1);// потребитель медленнее производителя}}catch(OperationCanceledException){}});awaitTask.Delay(2000);intqueued=ch.Reader.Count;doubleheap=GC.GetTotalMemory(false)/1024.0/1024.0-before;Console.WriteLine($"{(bounded ? "Bounded(100)" : "Unbounded")} за 2 с произведено {produced}, обработано {consumed}, в очереди {queued}, куча ≈ +{heap:F0} МБ");stop.Cancel();awaitTask.WhenAll(producer,consumer);ch.Writer.TryComplete();}}// ---------- 6. Поколения ----------publicstaticasyncTaskGenerations(){vargate=newTaskCompletionSource();varprobe=newWeakReference(null);varwaiter=HoldAsync(gate.Task,probe);objectstate=probe.Target!;Console.Write("Поколение состояния ждущего метода после сборок: ");Console.Write($"{GC.GetGeneration(state)} ");GC.Collect(0);Console.Write($"→ {GC.GetGeneration(state)} ");GC.Collect(1);Console.Write($"→ {GC.GetGeneration(state)}");Console.WriteLine();constintN=100_000;intg0=GC.CollectionCount(0),g1=GC.CollectionCount(1),g2=GC.CollectionCount(2);varpending=newList<Task>(N);for(inti=0;i<N;i++)pending.Add(WaitLongAsync(newbyte[200],CancellationToken.None));for(inti=0;i<3;i++){GC.Collect(0);awaitTask.Yield();}varinfo=GC.GetGCMemoryInfo(GCKind.Any);Console.WriteLine($"{N} ждущих методов: сборок gen0/gen1/gen2 за создание: {GC.CollectionCount(0) - g0}/{GC.CollectionCount(1) - g1}/{GC.CollectionCount(2) - g2}");Console.Write("Размеры поколений после последней сборки, МБ: ");for(intg=0;g<3;g++)Console.Write($"gen{g}={info.GenerationInfo[g].SizeAfterBytes / 1024.0 / 1024.0:F1} ");Console.WriteLine();GC.KeepAlive(pending);gate.SetResult();awaitwaiter;}staticasyncTaskHoldAsync(Taskwait,WeakReferenceprobe){varstate=newbyte[100];probe.Target=state;awaitwait;Console.Write(state.Length>1000?"?":"");}// ---------- 7. Утечка и пауза для инструментов ----------staticList<Task>?_keepForDump;// Тот же сценарий с токеном, но в статическом поле осталась ОДНА отменённая задача (как «последняя ошибка» в логе):// она держит цепочку из всех 20 000 регистраций. Задание главы: найти gcroot'ом, что их держит.staticTask?_lastCancelled;publicstaticvoidHangCancelled(intseconds){constintN=20_000;usingvarcts=newCancellationTokenSource();vartasks=newList<Task>();for(inti=0;i<N;i++)tasks.Add(WaitLongAsync(newbyte[1000],cts.Token));cts.Cancel();_lastCancelled=tasks[^1];try{_lastCancelled.Wait();}catch(AggregateException){}tasks.Clear();Console.WriteLine($"PID {Environment.ProcessId}. Все отменены, список очищен; куча ≈ {Mb():F1} МБ. Завершится через {seconds} с.");Thread.Sleep(TimeSpan.FromSeconds(seconds));}publicstaticasyncTaskHang(intseconds){constintN=50_000;_keepForDump=null;for(inti=0;i<N;i++)_=WaitLongAsync(newbyte[1000],CancellationToken.None);Console.WriteLine($"PID {Environment.ProcessId}. Брошено {N} вызовов Delay(1 ч); куча ≈ {Mb():F0} МБ. Подключайтесь инструментами; завершится через {seconds} с.");awaitTask.Delay(TimeSpan.FromSeconds(seconds));}}
// Глава 21, итог. Тот же сервис, утечки исправлены (режим service: dotnet run -c Release --project final -- service).// Программа имитирует работу и печатает, сколько памяти и объектов осталось ПОСЛЕ неё.usingSystem.Collections.Concurrent;staticclassService{publicstaticasyncTaskRunAsync(){constintN=10_000;varbus=newBus();vargateway=newGateway();varscreens=newList<WeakReference>();doublebefore=Mb();usingvarcts=newCancellationTokenSource();// Утечка 1 и 2: запросы без ответа и «keep-alive» без токена.varrequests=newList<Task>();for(inti=0;i<N;i++)requests.Add(gateway.RequestAsync(i,newbyte[1000],cts.Token));awaitgateway.KeepAliveAsync(100,cts.Token);// 100 ожиданий по 10 минут: «забытый» таймер// Утечка 3: экраны подписываются на шину и «закрываются».for(inti=0;i<N;i++){varscreen=newScreen(bus);screens.Add(newWeakReference(screen));screen.Close();}// Конец «нагрузки»: клиенты ушли.cts.Cancel();// «разрыв соединений»: токен отменёнrequests.Clear();// про задачи запросов мы больше не помнимawaitTask.Delay(200);// даём стеку завершения размотатьсяGC.Collect();GC.WaitForPendingFinalizers();GC.Collect();Console.WriteLine($"Живых экранов: {screens.Count(s => s.IsAlive)} из {N}");Console.WriteLine($"Записей в реестре шлюза: {gateway.Pending}");Console.WriteLine($"Куча: +{Mb() - before:F1} МБ");}staticdoubleMb(){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);varinfo=GC.GetGCMemoryInfo(GCKind.FullBlocking);return(info.HeapSizeBytes-info.FragmentedBytes)/1024.0/1024.0;}}sealedclassGateway{privatereadonlyConcurrentDictionary<int,TaskCompletionSource<string>>_pending=new();publicintPending=>_pending.Count;publicasyncTaskRequestAsync(intid,byte[]context,CancellationTokenct){vartcs=newTaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);_pending[id]=tcs;usingvarreg=ct.Register(()=>tcs.TrySetCanceled(ct));// токен разбудит ждущего отменой…try{stringreply=awaittcs.Task;// ответ приходит из другого места; он может не прийтиConsole.Write(context.Length+reply.Length);}finally{_pending.TryRemove(id,out_);// …а запись из реестра уходит при любом исходе}}publicasyncTaskKeepAliveAsync(intcount,CancellationTokenct){varwaits=newList<Task>();for(inti=0;i<count;i++)waits.Add(KeepOneAsync(newbyte[10_000],ct));awaitTask.Delay(10);// «работаем дальше», не дожидаясь keep-alive}privatestaticasyncTaskKeepOneAsync(byte[]state,CancellationTokenct){try{awaitTask.Delay(TimeSpan.FromMinutes(10),ct);// таймер снимается при отменеConsole.Write(state.Length);}catch(OperationCanceledException){}}}sealedclassBus{publiceventFunc<string,Task>?Message;publicvoidSubscribe(Func<string,Task>handler)=>Message+=handler;publicvoidUnsubscribe(Func<string,Task>handler)=>Message-=handler;}sealedclassScreen{privatereadonlyBus_bus;privatereadonlyFunc<string,Task>_handler;privatereadonlybyte[]_state=newbyte[1000];publicScreen(Busbus){_bus=bus;_handler=asynctext=>{awaitTask.Yield();_state[0]=(byte)text.Length;};bus.Subscribe(_handler);}publicvoidClose()=>_bus.Unsubscribe(_handler);// отписка при закрытии}