Недавно у меня появилась задача по снижению аллокации в очень горячем месте кода. Там происходит тривиальное: запускаются Task'и в которых заранее известным набором handler'ов обрабатываются объекты. Вооружившись профайлером, я с удивлением обнаружил, что много памяти (и много времени GC) затрачивается на удаление объектов-замыканий.   Замыкания (closure) это очень крутая штука, которая помогает писать более лаконичный код на C#. Под капотом, замыкание это более-менее обычный класс, который "захватывает" ссылки на переменные, которые участвуют в замыкании. Думаю вы видели, как многие IDE честно подсказывают, что в месте использования замыкания возникает захват переменных: Что же происходит "под капотом"? В этом же классе создаётся класс, который представляет из себя то самое замыкание. Класс специально называется хитрым образом (в моём случае он называется DisplayClass4_0) и помечается атрибутом CompilerGenerated. В специальном классе создаётся набор полей по количеству переменных, захватываемых замыканием. Также, создаётся метод, ссылка на который передаётся в метод Task.Run. В декомпилированном коде (я использую dotPeek) это выглядит примерно вот так: Почему происходит именно так? Потому что "замыкание" это не механизм платформы .NET, а языка C#. Если угодно, это синтаксический сахар, который делает язык красивым и выразительным. Однако, на более низком уровне, любой синтаксический сахар требует низкоуровневой реализации - и это она и есть. Подробнее о замыканиях написал Сергей Тепляков и никому не известный Стефан Тауб. У их написано много, объясняется сравнительно легко, в том числе затрагиваются особенности работы с замыканиями. Можно заметить, что при каждом замыкании "под капотом" создаётся инстанс класса замыкания, в его поля помещаются захватываемые значения, а в нужный нам метод передаётся ссылка на метод closure-класса, где и происходит выполнение логики, указанной в замыкании. Напомню, что инстанс класса размещается в куче, откуда его потом удалит GC. Кажется, что это совсем не страшно, так как речь в подавляющем большинстве случаев идёт о помещении инстанса в Gen0, откуда он будет быстро удалён. Более того, сам класс замыкания предельно лёгкий и не занимает много места. Однако, если место использования замыкания горячее (часто вызывается), то GC может не успеть удалить инстансы closure-класса. При самых печальных сценариях, это может привести к "выживанию" классов вплоть до Gen2, с последующим stop the world для проведения вдумчивой очистки кучи. Более того, не надо забывать, что не все имплементации платформы работают одинаково. Например, игровой движок Unity имеет особенный GC с одним поколением. Это требует от разработчиков очень внимательно относиться к тому, кто и что аллоцирует и в каких количествах. Чтобы снизить нагрузку на GC, в некоторых сценариях можно попытаться написать собственную имплементацию замыкания. Кажется, что это просто, так как мы знаем как работает closure. Если мы знаем количество вызовов этого класса, то мы можем запросто преаллоцировать все замыкания. При использовании мы просто записываем значение аргумента в поле класса (точно также, как сделали бы за нас "под капотом "), а в качестве метода используем заранее созданную ссылку на метод этого же класса.   При использовании собственного класса-замыкания при декомпиляции кода мы можем наблюдать более понятную картину без "магии под капотом". Бонусом, мы избавились от создания new Action при передачи ссылки на метод замыкания в нужный нам метод.   Минус подобного использования - инстанс класса замыкания нужно очищать от значения, которое в него было передано ранее. Сделать это необходимо, поскольку это место становится местом потенциальной утечки памяти, так как замыкание будет хранить ссылку на "захваченное" значение вечно. Ещё один минус - многопоточность. При использовании собственной имплементации замыкания нужно следить за тем, чтобы переданные в замыкание значения были атомарны для каждого из потоков. Как это сделать красиво и без особых сложностей - совершенно другой вопрос. Какие же конкретные числа мы можем получить при замене стандартного механизма замыкания на собственный велосипед? Были произведены замеры с использованием известного фреймворка для микробенчмаркинга BenchmarkDotNet. Код бенчмарка находится тут. Method Runtime Mean Ratio Gen 0 Gen 1 Gen 2 Allocated AutoClosure .NET 6.0 6.131 ms 1.00 203.1250 - - 1,711 KB ParallelFor .NET 6.0 1.850 ms 0.30 265.6250 - - 2,158 KB ParallelForeach .NET 6.0 1.916 ms 0.31 271.4844 - - 2,221 KB SelfClosure .NET 6.0 5.883 ms 0.96 93.7500 - - 774 KB         AutoClosure .NET Core 3.1 6.727 ms 1.00 203.1250 - - 1,713 KB ParallelFor .NET Core 3.1 1.925 ms 0.29 257.8125 - - 2,108 KB ParallelForeach .NET Core 3.1 2.015 ms 0.30 265.6250 - - 2,182 KB SelfClosure .NET Core 3.1 6.880 ms 1.02 93.7500 - - 773 KB         AutoClosure .NET Framework 4.6.1 8.776 ms 1.00 296.8750 46.8750 15.6250 1,890 KB ParallelFor .NET Framework 4.6.1 1.726 ms 0.20 208.9844 9.7656 1.9531 1,290 KB ParallelForeach .NET Framework 4.6.1 1.861 ms 0.21 216.7969 9.7656 1.9531 1,345 KB SelfClosure .NET Framework 4.6.1 8.380 ms 0.95 140.6250 15.6250 - 954 KB Приятно, что скорость осталась примерно прежней. Это говорит о том, что сделано более менее правильно. Столбец "Allocated" бодро рапортует нам о том, что аллокация меньше почти в два раза. Но, собственно, почему же она есть? Если вы посмотрите код бенчмарка, то вы заметите, что я пытаюсь минимизировать аллокацию при запуске Task'ов. Это достаточно распространенный случай использования замыкания. Цифры, которые можно увидеть в столбце Allocated включают в себя затраты платформы на создание Task'ов. В бенчмарке, также, можно найти результаты для Parallel.For и Parallel.ForEach. Их использование значительно повышает скорость работы и, к сожалению, существенно увеличивают аллокацию. Дьявол кроется в деталях: Parallel.ForEach принимает в качестве аргумента IEnumerable<T>, который возвращает IEnumerator<T>. Это объект, который будет расположен в куче, а значит будет нагружать GC. Ну а Parallel.For принимает делегат, где снова создаётся объект-замыкания, что также влияет на аллокацию. Спасибо комментатору @Deosis. И снова дьявол кроется в деталях. При увеличении количества заданий (изначально их было 10) начинает стремительно выигрывать имплементация на Parallel.For и Parallel.ForEach. Во-первых, она просто быстрая, а во-вторых, создание enumerator'a и аллокация замыкания - это фиксированная плата, никак не зависящая от количества. Бенчмарк это явно показывает. Спасибо @soalexmn. В его комментарии количество заданий увеличено до 400 и цифры там совсем драматичные. Method Runtime Tasks Mean Ratio Gen 0 Gen 1 Gen 2 Allocated AutoClosure .NET 6.0 10 6.252 ms 1.00 203.1250 - - 1,709 KB ParallelFor .NET 6.0 10 1.891 ms 0.30 263.6719 - - 2,154 KB ParallelForeach .NET 6.0 10 1.909 ms 0.31 269.5313 - - 2,220 KB SelfClosure .NET 6.0 10 5.747 ms 0.88 93.7500 - - 773 KB          AutoClosure .NET Core 3.1 10 6.706 ms 1.00 203.1250 - - 1,710 KB ParallelFor .NET Core 3.1 10 1.900 ms 0.28 259.7656 - - 2,115 KB ParallelForeach .NET Core 3.1 10 2.016 ms 0.30 265.6250 - - 2,190 KB SelfClosure .NET Core 3.1 10 6.740 ms 1.01 93.7500 - - 774 KB          AutoClosure .NET Framework 4.6.1 10 8.514 ms 1.00 296.8750 46.8750 15.6250 1,896 KB ParallelFor .NET Framework 4.6.1 10 1.667 ms 0.20 208.9844 13.6719 1.9531 1,292 KB ParallelForeach .NET Framework 4.6.1 10 1.768 ms 0.21 216.7969 9.7656 1.9531 1,344 KB SelfClosure .NET Framework 4.6.1 10 8.455 ms 0.99 140.6250 15.6250 - 950 KB          AutoClosure .NET 6.0 100 65.939 ms 1.00 2000.0000 - - 16,368 KB ParallelFor .NET 6.0 100 3.455 ms 0.05 347.6563 - - 2,831 KB ParallelForeach .NET 6.0 100 4.040 ms 0.06 367.1875 - - 2,975 KB SelfClosure .NET 6.0 100 60.615 ms 0.92 750.0000 - - 6,982 KB          AutoClosure .NET Core 3.1 100 68.076 ms 1.00 2000.0000 - - 16,370 KB ParallelFor .NET Core 3.1 100 3.467 ms 0.05 335.9375 - - 2,726 KB ParallelForeach .NET Core 3.1 100 3.796 ms 0.06 351.5625 - - 2,845 KB SelfClosure .NET Core 3.1 100 69.871 ms 1.03 750.0000 - - 6,980 KB          AutoClosure .NET Framework 4.6.1 100 78.586 ms 1.00 2857.1429 571.4286 142.8571 18,094 KB ParallelFor .NET Framework 4.6.1 100 3.343 ms 0.04 289.0625 3.9063 - 1,788 KB ParallelForeach .NET Framework 4.6.1 100 4.095 ms 0.05 304.6875 7.8125 - 1,912 KB SelfClosure .NET Framework 4.6.1 100 78.101 ms 0.99 1285.7143 142.8571 - 8,685 KB P.S.: Начал писать в телегу и, внезапно, Дзен. Заглядывайте, если интересно.  Senior .NET Developer