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

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). Числа мегабайт зависят от версии рантайма и платформы: смотрите на соотношения.

21.1. Что держит ждущий метод в памяти

Пока async-метод ждёт, он существует как бокс машины состояний на куче (глава 1). Чтобы бокс собрали, до него не должно быть ни одной цепочки ссылок от корня (статическое поле, стек потока, дескриптор GC). Ждущий метод держат: Task, на который он подписан; тот, кто завершит эту задачу (таймер, TaskCompletionSource, сокет); и всё, что достижимо из его полей, включая «поднятые» локальные переменные и this.

Цепочку от корня видно прямо в дампе. Для 50 000 брошенных Task.Delay(1 ч) (режим hang):

dotnet-dump analyze … -c 'gcroot <адрес бокса>' (Ubuntu 26.04, .NET 10.0.12; сокращено)
HandleTable:
    000070abc5cc13e8 (strong handle)
          -> 70a94fc00028     System.Object[]
          -> 70a95200a7e0     System.Threading.TimerQueue[]    (static variable: …s_tickCountToTimeMap)
          -> 70a95200a880     System.Threading.TimerQueue
          -> 70a951c9f1f0     System.Threading.Tasks.Task+DelayPromise
          -> 70a951c9f298     AsyncTaskMethodBuilder<VoidTaskResult>+AsyncStateMachineBox<Scenarios+<WaitLongAsync>d__9>

Это ровно та цепочка, что нарисована в главе 1 («кто держит бокс, пока мы ждём»): статика с очередью таймеров → TimerQueueTimer → DelayPromise → бокс → ваши поля. Пока таймер не сработает, ни бокс, ни ваши данные не освободятся, даже если все ваши ссылки на Task потеряны.

21.2. Локальные переменные через await

Поле машины состояний появляется только у переменной, значение которой нужно после await (глава 2). Режим hoist держит три приостановленных метода с буфером по 1 МБ и проверяет через WeakReference, жив ли буфер:

final/Scenarios.cs: три метода
    static async Task UsedAfterAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        await wait;
        Console.Write(buffer[0] > 0 ? "" : "?");     // буфер нужен после await → станет полем машины состояний
    }

    static async Task NotUsedAfterAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        await wait;                                    // после await буфер не используется → остаётся локальной в MoveNext
    }

    static async Task NulledBeforeAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        byte first = buffer[0];
        buffer = null;
        await wait;
        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 продлевает жизнь локальных, и результат может отличаться.

21.3. Места утечек

Каждое место ниже воспроизведено числами (режимы лабораторной).

Таймеры без токена

-- delay: 20 000 вызовов Task.Delay(1 ч) с буфером 1 КБ
С токеном:  … → +27.0 МБ; после Cancel +0.3 МБ
Без токена: … → +25.1 МБ; после отказа от ссылок на задачи +24.9 МБ

Task.Delay(…) без токена кладёт запись в очередь таймеров, и она живёт до срабатывания. Когда ожидание «брошено» (клиент ушёл, операция отменена вверху), но токен вниз не передан, час (или десять минут) тащится и сама запись, и бокс, и всё из его полей. С токеном при отмене таймер снимается, и память возвращается.

Реестр ожидающих запросов

final/Scenarios.cs: реестр с токеном
    sealed class Pending
    {
        private readonly Dictionary<int, TaskCompletionSource<string>> _byId = new();
        public int Count => _byId.Count;

        public async Task WaitAsync(int id, byte[] context, CancellationToken ct)
        {
            var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
            _byId[id] = tcs;
            using var reg = ct.Register(() =>
            {
                _byId.Remove(id);                      // снять запись из реестра…
                tcs.TrySetCanceled(ct);                // …и разбудить ждущего отменой
            });
            string reply = await tcs.Task;
            Console.Write(context.Length + reply.Length);
        }
    }
-- tcs
Реестр с токеном:    +28.8 МБ пока ждут, после отмены +1.2 МБ, в реестре 0 записей
Реестр без таймаута: ответы потерялись → +24.9 МБ, в реестре 20000 записей

Классика: «отправил запрос, жду ответ через TaskCompletionSource, ответ не пришёл». Запись в словаре держит TaskCompletionSource, тот держит бокс ждущего. Вариант с токеном (строки 147–151: Register снимает запись из реестра и отменяет TCS) освобождает всё при отмене; даже без токена нужен таймаут на уровне реестра.

Подписки на события

final/Scenarios.cs: издатель и подписчик
    sealed class Bus                                  // долгоживущий издатель (синглтон в реальном приложении)
    {
        private event Func<string, Task>? Message;
        public int Subscribers => Message?.GetInvocationList().Length ?? 0;
        public void Subscribe(Func<string, Task> h) => Message += h;
        public void Unsubscribe(Func<string, Task> h) => Message -= h;
    }

    sealed class Screen : IDisposable
    {
        private readonly Bus _bus;
        private readonly byte[] _state = new byte[1000];
        private readonly Func<string, Task> _handler;

        public Screen(Bus bus)
        {
            _bus = bus;
            _handler = async text => { await Task.Yield(); _state[0] = (byte)text.Length; };   // лямбда держит this
            bus.Subscribe(_handler);
        }

        public void Dispose() => _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 показывает путь:

HandleTable:
    (strong handle) -> System.Object[]
      -> …AsyncStateMachineBox<Scenarios+<WaitLongAsync>d__9>   (static variable: Scenarios._lastCancelled)
      -> Task+ContingentProperties -> TaskExceptionHolder -> ExceptionDispatchInfo
      -> TaskCanceledException -> Task+DelayPromiseWithCancellation
      -> … CallbackNode -> CallbackNode -> … (цепочка из 20 000 узлов регистраций) …
      -> Task+DelayPromiseWithCancellation -> TimerQueueTimer

Исключение отмены хранит свою задачу, у задачи — регистрация в списке токена, регистрации связаны в цепочку CallbackNode, и по ней достижимы все остальные задачи и таймеры. Практический вывод: не храните объекты исключений и задач отмены долго (в статике, в кеше «последних ошибок», в долгоживущих очередях диагностики): храните текст и тип.

Факт: измерение сразу после завершения может соврать

Первые варианты delay и tcs показывали «+7 МБ» после отмены, хотя через секунду кучу видно «+0,3 МБ». gcroot нашёл корень в стеке потока: продолжение метода, дожидавшегося отменённых задач, выполнялось инлайн поверх кадра цикла foreach, и в этом кадре оставалась ссылка на последнюю задачу, а через неё (по цепочке выше) на все остальные. После выхода из кадра всё освобождается. Поэтому в лабораторной между отменой и замером есть await Task.Delay(100) и вынесенный метод DrainCancelledAsync. Если ваш замер памяти «сразу после» не сходится, подождите, пока стек размотается, прежде чем объявлять утечку.

21.4. Поколения: цена долгого ожидания

Сборщик делит кучу на поколения: новые объекты в 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).

21.5. Диагностика

Счётчики и API процесса

dotnet tool run dotnet-counters monitor -p <PID> --counters System.Runtime

В .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.

dotnet-gcdump

dotnet tool run dotnet-gcdump collect -p <PID> -o leak.gcdump
dotnet tool run dotnet-gcdump report leak.gcdump

Быстрый и лёгкий снимок только достижимых объектов: размер, количество, тип. Для 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. На нашем стенде расхождений не было (для hang gcdump насчитал ~65,3 МБ, dumpheap -stat ~65,9 МБ), но на больших кучах .NET 10 предупреждению стоит верить: для окончательных выводов берите полный дамп.

dotnet-dump: dumpheap, gcroot, dumpasync

dotnet tool run dotnet-dump collect -p <PID> -o leak.dmp
dotnet tool run dotnet-dump analyze leak.dmp -c "dumpheap -stat" -c "dumpasync --stats" -c "exit"
dumpheap -stat (hang; хвост, отсортирован по размеру)
70ab47581e00 50,001  3,600,072 System.Threading.Tasks.Task+DelayPromise
70ab47582ae8 50,001  4,800,096 System.Threading.TimerQueueTimer
70ab47593c60 50,000  5,600,000 …AsyncStateMachineBox<Scenarios+<WaitLongAsync>d__9>
70ab4744ca20 50,382 51,244,629 System.Byte[]
dumpasync --stats
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: если пути нет, это ещё не утечка.

Порядок поиска

  1. Счётчики: растёт ли размер gen2 после последней сборки и общий размер? Если растёт только между сборками и падает после них — это нагрузка, не утечка.
  2. dumpheap -stat -live (или gcdump report): какие типы растут в количестве. Типичные подозреваемые в async-коде: боксы машин состояний …d__N, Task/DelayPromise, TimerQueueTimer, CancellationTokenSource+CallbackNode, TaskCompletionSource, свои DTO.
  3. gcroot на несколько адресов из разных мест списка: общий корень и есть виновник (статическое поле, реестр, шина событий, очередь таймеров).
  4. dumpasync: в каком состоянии ждущие методы и на чём ждут. Тысячи одинаковых боксов в одном состоянии = забытое ожидание.
  5. Исправление и тест на WeakReference (ниже).

Тест на утечку

[MethodImpl(MethodImplOptions.NoInlining)]                 // иначе JIT может оставить ссылку в регистре вызывающего
static WeakReference CreateAndCloseScreen(Bus bus)
{
    var screen = new Screen(bus);
    screen.Close();
    return new WeakReference(screen);
}

var weak = CreateAndCloseScreen(bus);
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
Assert.False(weak.IsAlive);                                 // не жив → отписались

Тот же приём лежит в основе режимов hoist и event: создаём объект в отдельном методе, возвращаем только WeakReference, принудительно собираем и смотрим IsAlive.

21.6. Профилактика

  • Токен вниз в каждый 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 на больших кучах может быть неполным (предупреждение инструмента); замеряйте после того, как стек размотался.

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

Запуск из папки главы: dotnet run -c Release --project start (или --project final -- <режим>).

start/Program.cs
// Глава 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)
using System.Collections.Concurrent;

const int N = 10_000;
var bus = new Bus();
var gateway = new Gateway();
var screens = new List<WeakReference>();

double before = Mb();
using var cts = new CancellationTokenSource();

// Утечка 1 и 2: запросы без ответа и «keep-alive» без токена.
var requests = new List<Task>();
for (int i = 0; i < N; i++) requests.Add(gateway.RequestAsync(i, new byte[1000], cts.Token));
await gateway.KeepAliveAsync(100);                    // 100 ожиданий по 10 минут: «забытый» таймер

// Утечка 3: экраны подписываются на шину и «закрываются».
for (int i = 0; i < N; i++)
{
    var screen = new Screen(bus);
    screens.Add(new WeakReference(screen));
    screen.Close();
}

// Конец «нагрузки»: клиенты ушли.
cts.Cancel();                                         // «разрыв соединений»: токен отменён
requests.Clear();                                     // про задачи запросов мы больше не помним
await Task.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 с.");
    await Task.Delay(TimeSpan.FromSeconds(120));
}

static double Mb()
{
    GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
    var info = GC.GetGCMemoryInfo(GCKind.FullBlocking);
    return (info.HeapSizeBytes - info.FragmentedBytes) / 1024.0 / 1024.0;
}

sealed class Gateway
{
    private readonly ConcurrentDictionary<int, TaskCompletionSource<string>> _pending = new();
    public int Pending => _pending.Count;

    public async Task RequestAsync(int id, byte[] context, CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        _pending[id] = tcs;
        string reply = await tcs.Task;               // ответ приходит из другого места; он может не прийти
        _pending.TryRemove(id, out _);
        Console.Write(context.Length + reply.Length);
    }

    public async Task KeepAliveAsync(int count)
    {
        var waits = new List<Task>();
        for (int i = 0; i < count; i++) waits.Add(KeepOneAsync(new byte[10_000]));
        await Task.Delay(10);                        // «работаем дальше», не дожидаясь keep-alive
    }

    private static async Task KeepOneAsync(byte[] state)
    {
        await Task.Delay(TimeSpan.FromMinutes(10));
        Console.Write(state.Length);
    }
}

sealed class Bus
{
    public event Func<string, Task>? Message;
    public void Subscribe(Func<string, Task> handler) => Message += handler;
}

sealed class Screen
{
    private readonly byte[] _state = new byte[1000];

    public Screen(Bus bus) => bus.Subscribe(async text => { await Task.Yield(); _state[0] = (byte)text.Length; });

    public void Close() { /* экран закрыт, пользователь ушёл */ }
}
final/Program.cs
// Глава 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 -- delay
switch (args.FirstOrDefault() ?? "hoist")
{
    case "hoist": await Scenarios.Hoist(); break;
    case "delay": await Scenarios.Delay(); break;
    case "tcs": await Scenarios.Tcs(); break;
    case "event": await Scenarios.Events(); break;
    case "channel": await Scenarios.ChannelGrowth(); break;
    case "gen": await Scenarios.Generations(); break;
    case "hang-cancelled": Scenarios.HangCancelled(args.Length > 1 ? int.Parse(args[1]) : 120); break;
    case "service": await Service.RunAsync(); break;
    case "hang": await Scenarios.Hang(args.Length > 1 ? int.Parse(args[1]) : 120); break;
}
final/Scenarios.cs
using System.Threading.Channels;

static class Scenarios
{
    static Pending? _leakyRegistry;                    // «долгоживущий» реестр: статическое поле

    // Прогон GC и измерение: сколько мегабайт в управляемой куче.
    // Живые данные: после полной блокирующей сборки берём размер кучи без фрагментации.
    // (GC.GetTotalMemory(true) в этом месте завышает на дыры в куче: см. §21.5.)
    static double Mb()
    {
        GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
        var info = GC.GetGCMemoryInfo(GCKind.FullBlocking);
        return (info.HeapSizeBytes - info.FragmentedBytes) / 1024.0 / 1024.0;
    }

    static bool Alive(WeakReference w)
    {
        GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
        return w.IsAlive;
    }

    // ---------- 1. Что держит локальная переменная ----------
    public static async Task Hoist()
    {
        var gate = new TaskCompletionSource();
        var usedAfter = new WeakReference(null);
        var notUsedAfter = new WeakReference(null);
        var nulled = new WeakReference(null);

        var t1 = UsedAfterAwait(gate.Task, usedAfter);
        var t2 = NotUsedAfterAwait(gate.Task, notUsedAfter);
        var t3 = 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();
        await Task.WhenAll(t1, t2, t3);
        Console.WriteLine($"После завершения методов: буфер из первого жив = {Alive(usedAfter)}");
    }

    static async Task UsedAfterAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        await wait;
        Console.Write(buffer[0] > 0 ? "" : "?");     // буфер нужен после await → станет полем машины состояний
    }

    static async Task NotUsedAfterAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        await wait;                                    // после await буфер не используется → остаётся локальной в MoveNext
    }

    static async Task NulledBeforeAwait(Task wait, WeakReference probe)
    {
        var buffer = new byte[1_000_000];
        probe.Target = buffer;
        buffer[0] = 1;
        byte first = buffer[0];
        buffer = null;
        await wait;
        Console.Write(first > 5 ? "?" : "");
    }

    // ---------- 2. Брошенные Task.Delay ----------
    public static async Task Delay()
    {
        const int N = 20_000;

        using var cts = new CancellationTokenSource();
        double before = Mb();
        var tasks = new List<Task>();
        for (int i = 0; i < N; i++) tasks.Add(WaitLongAsync(new byte[1000], cts.Token));
        double held = Mb();
        cts.Cancel();
        await DrainCancelledAsync(tasks);
        await Task.Delay(100);                         // дать стеку, на котором шло завершение задач, размотаться (см. §21.3)
        double afterCancel = 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();
        var leaked = new List<Task>();
        for (int i = 0; i < N; i++) leaked.Add(WaitLongAsync(new byte[1000], CancellationToken.None));
        held = Mb();
        leaked.Clear();                                // выбрасываем свои ссылки на задачи
        double afterDrop = Mb();
        Console.WriteLine($"Без токена: {N} вызовов Delay(1 ч) → +{held - before:F1} МБ; после отказа от ссылок на задачи +{afterDrop - before:F1} МБ");
        Console.WriteLine("  (память держит очередь таймеров, а не вы: ссылок на задачи у вас уже нет)");
    }

    // Дождаться отменённых задач в ОТДЕЛЬНОМ методе: после выхода из него в кадре вызывающего не остаётся ссылки на последнюю задачу.
    static async Task DrainCancelledAsync(List<Task> tasks)
    {
        foreach (var t in tasks) { try { await t; } catch (OperationCanceledException) { } }
        tasks.Clear();
    }

    static async Task WaitLongAsync(byte[] payload, CancellationToken ct)
    {
        await Task.Delay(TimeSpan.FromHours(1), ct);
        Console.Write(payload.Length);
    }

    // ---------- 3. Реестр ожидающих запросов ----------
    public static async Task Tcs()
    {
        const int N = 20_000;

        var fixedReg = new Pending();
        using var cts = new CancellationTokenSource();
        double before = Mb();
        var w2 = new List<Task>();
        for (int i = 0; i < N; i++) w2.Add(fixedReg.WaitAsync(i, new byte[1000], cts.Token));
        double held = Mb() - before;
        cts.Cancel();
        await DrainCancelledAsync(w2);
        await Task.Delay(100);
        Console.WriteLine($"Реестр с токеном:    +{held:F1} МБ пока ждут, после отмены +{Mb() - before:F1} МБ, в реестре {fixedReg.Count} записей");

        _leakyRegistry = new Pending();
        before = Mb();
        var waiting = new List<Task>();
        for (int i = 0; i < N; i++) waiting.Add(_leakyRegistry.WaitAsync(i, new byte[1000], CancellationToken.None));
        waiting.Clear();
        Console.WriteLine($"Реестр без таймаута: ответы потерялись → +{Mb() - before:F1} МБ, в реестре {_leakyRegistry.Count} записей");
    }

    sealed class Pending
    {
        private readonly Dictionary<int, TaskCompletionSource<string>> _byId = new();
        public int Count => _byId.Count;

        public async Task WaitAsync(int id, byte[] context, CancellationToken ct)
        {
            var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
            _byId[id] = tcs;
            using var reg = ct.Register(() =>
            {
                _byId.Remove(id);                      // снять запись из реестра…
                tcs.TrySetCanceled(ct);                // …и разбудить ждущего отменой
            });
            string reply = await tcs.Task;
            Console.Write(context.Length + reply.Length);
        }
    }

    // ---------- 4. Подписчики событий ----------
    public static async Task Events()
    {
        const int N = 10_000;
        var bus = new Bus();
        var probes = new List<WeakReference>();

        for (int i = 0; i < N; i++) { var s = new Screen(bus); probes.Add(new WeakReference(s)); }
        GC.Collect();
        Console.WriteLine($"Закрыто {N} экранов без отписки: живых = {probes.Count(p => p.IsAlive)}, подписчиков у издателя = {bus.Subscribers}");

        var bus2 = new Bus();
        var probes2 = new List<WeakReference>();
        for (int i = 0; i < N; i++) { var s = new Screen(bus2); probes2.Add(new WeakReference(s)); s.Dispose(); }
        GC.Collect();
        Console.WriteLine($"Закрыто {N} экранов с отпиской: живых = {probes2.Count(p => p.IsAlive)}, подписчиков у издателя = {bus2.Subscribers}");
        await Task.CompletedTask;
    }

    sealed class Bus                                  // долгоживущий издатель (синглтон в реальном приложении)
    {
        private event Func<string, Task>? Message;
        public int Subscribers => Message?.GetInvocationList().Length ?? 0;
        public void Subscribe(Func<string, Task> h) => Message += h;
        public void Unsubscribe(Func<string, Task> h) => Message -= h;
    }

    sealed class Screen : IDisposable
    {
        private readonly Bus _bus;
        private readonly byte[] _state = new byte[1000];
        private readonly Func<string, Task> _handler;

        public Screen(Bus bus)
        {
            _bus = bus;
            _handler = async text => { await Task.Yield(); _state[0] = (byte)text.Length; };   // лямбда держит this
            bus.Subscribe(_handler);
        }

        public void Dispose() => _bus.Unsubscribe(_handler);
    }

    // ---------- 5. Очередь без ограничения ----------
    public static async Task ChannelGrowth()
    {
        foreach (bool bounded in new[] { false, true })
        {
            Channel<byte[]> ch = bounded ? Channel.CreateBounded<byte[]>(100) : Channel.CreateUnbounded<byte[]>();
            double before = Mb();
            int produced = 0, consumed = 0;
            using var stop = new CancellationTokenSource();

            var producer = Task.Run(async () =>
            {
                try
                {
                    while (true)
                    {
                        await ch.Writer.WriteAsync(new byte[10_000], stop.Token);
                        produced++;
                    }
                }
                catch (OperationCanceledException) { }
            });
            var consumer = Task.Run(async () =>
            {
                try
                {
                    await foreach (var _ in ch.Reader.ReadAllAsync(stop.Token))
                    {
                        if (stop.IsCancellationRequested) break;
                        consumed++;
                        await Task.Delay(1);               // потребитель медленнее производителя
                    }
                }
                catch (OperationCanceledException) { }
            });

            await Task.Delay(2000);
            int queued = ch.Reader.Count;
            double heap = GC.GetTotalMemory(false) / 1024.0 / 1024.0 - before;
            Console.WriteLine($"{(bounded ? "Bounded(100)" : "Unbounded   ")} за 2 с произведено {produced}, обработано {consumed}, в очереди {queued}, куча ≈ +{heap:F0} МБ");
            stop.Cancel();
            await Task.WhenAll(producer, consumer);
            ch.Writer.TryComplete();
        }
    }

    // ---------- 6. Поколения ----------
    public static async Task Generations()
    {
        var gate = new TaskCompletionSource();
        var probe = new WeakReference(null);
        var waiter = HoldAsync(gate.Task, probe);
        object state = 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();

        const int N = 100_000;
        int g0 = GC.CollectionCount(0), g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2);
        var pending = new List<Task>(N);
        for (int i = 0; i < N; i++) pending.Add(WaitLongAsync(new byte[200], CancellationToken.None));
        for (int i = 0; i < 3; i++) { GC.Collect(0); await Task.Yield(); }
        var info = 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 (int g = 0; g < 3; g++) Console.Write($"gen{g}={info.GenerationInfo[g].SizeAfterBytes / 1024.0 / 1024.0:F1} ");
        Console.WriteLine();
        GC.KeepAlive(pending);
        gate.SetResult();
        await waiter;
    }

    static async Task HoldAsync(Task wait, WeakReference probe)
    {
        var state = new byte[100];
        probe.Target = state;
        await wait;
        Console.Write(state.Length > 1000 ? "?" : "");
    }

    // ---------- 7. Утечка и пауза для инструментов ----------
    static List<Task>? _keepForDump;

    // Тот же сценарий с токеном, но в статическом поле осталась ОДНА отменённая задача (как «последняя ошибка» в логе):
    // она держит цепочку из всех 20 000 регистраций. Задание главы: найти gcroot'ом, что их держит.
    static Task? _lastCancelled;

    public static void HangCancelled(int seconds)
    {
        const int N = 20_000;
        using var cts = new CancellationTokenSource();
        var tasks = new List<Task>();
        for (int i = 0; i < N; i++) tasks.Add(WaitLongAsync(new byte[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));
    }

    public static async Task Hang(int seconds)
    {
        const int N = 50_000;
        _keepForDump = null;
        for (int i = 0; i < N; i++) _ = WaitLongAsync(new byte[1000], CancellationToken.None);
        Console.WriteLine($"PID {Environment.ProcessId}. Брошено {N} вызовов Delay(1 ч); куча ≈ {Mb():F0} МБ. Подключайтесь инструментами; завершится через {seconds} с.");
        await Task.Delay(TimeSpan.FromSeconds(seconds));
    }
}
final/Service.cs
// Глава 21, итог. Тот же сервис, утечки исправлены (режим service: dotnet run -c Release --project final -- service).
// Программа имитирует работу и печатает, сколько памяти и объектов осталось ПОСЛЕ неё.
using System.Collections.Concurrent;

static class Service
{
    public static async Task RunAsync()
    {
        const int N = 10_000;
        var bus = new Bus();
        var gateway = new Gateway();
        var screens = new List<WeakReference>();

        double before = Mb();
        using var cts = new CancellationTokenSource();

        // Утечка 1 и 2: запросы без ответа и «keep-alive» без токена.
        var requests = new List<Task>();
        for (int i = 0; i < N; i++) requests.Add(gateway.RequestAsync(i, new byte[1000], cts.Token));
        await gateway.KeepAliveAsync(100, cts.Token);                   // 100 ожиданий по 10 минут: «забытый» таймер

        // Утечка 3: экраны подписываются на шину и «закрываются».
        for (int i = 0; i < N; i++)
        {
            var screen = new Screen(bus);
            screens.Add(new WeakReference(screen));
            screen.Close();
        }

        // Конец «нагрузки»: клиенты ушли.
        cts.Cancel();                                         // «разрыв соединений»: токен отменён
        requests.Clear();                                     // про задачи запросов мы больше не помним
        await Task.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} МБ");


    }

    static double Mb()
    {
        GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
        var info = GC.GetGCMemoryInfo(GCKind.FullBlocking);
        return (info.HeapSizeBytes - info.FragmentedBytes) / 1024.0 / 1024.0;
    }

}

sealed class Gateway
{
    private readonly ConcurrentDictionary<int, TaskCompletionSource<string>> _pending = new();
    public int Pending => _pending.Count;

    public async Task RequestAsync(int id, byte[] context, CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        _pending[id] = tcs;
        using var reg = ct.Register(() => tcs.TrySetCanceled(ct));   // токен разбудит ждущего отменой…
        try
        {
            string reply = await tcs.Task;           // ответ приходит из другого места; он может не прийти
            Console.Write(context.Length + reply.Length);
        }
        finally
        {
            _pending.TryRemove(id, out _);           // …а запись из реестра уходит при любом исходе
        }
    }

    public async Task KeepAliveAsync(int count, CancellationToken ct)
    {
        var waits = new List<Task>();
        for (int i = 0; i < count; i++) waits.Add(KeepOneAsync(new byte[10_000], ct));
        await Task.Delay(10);                        // «работаем дальше», не дожидаясь keep-alive
    }

    private static async Task KeepOneAsync(byte[] state, CancellationToken ct)
    {
        try
        {
            await Task.Delay(TimeSpan.FromMinutes(10), ct);      // таймер снимается при отмене
            Console.Write(state.Length);
        }
        catch (OperationCanceledException) { }
    }
}

sealed class Bus
{
    public event Func<string, Task>? Message;
    public void Subscribe(Func<string, Task> handler) => Message += handler;
    public void Unsubscribe(Func<string, Task> handler) => Message -= handler;
}

sealed class Screen
{
    private readonly Bus _bus;
    private readonly Func<string, Task> _handler;
    private readonly byte[] _state = new byte[1000];

    public Screen(Bus bus)
    {
        _bus = bus;
        _handler = async text => { await Task.Yield(); _state[0] = (byte)text.Length; };
        bus.Subscribe(_handler);
    }

    public void Close() => _bus.Unsubscribe(_handler);   // отписка при закрытии
}