
Недавно у меня появилась задача по снижению аллокации в очень горячем месте кода. Там происходит тривиальное: запускаются Task’и в которых заранее известным набором handler’ов обрабатываются объекты. Вооружившись профайлером, я с удивлением обнаружил, что много памяти (и много времени GC) затрачивается на удаление объектов-замыканий.
Что такое замыкание в C#?
Замыкания (closure) это очень крутая штука, которая помогает писать более лаконичный код на C#. Под капотом, замыкание это более-менее обычный класс, который «захватывает» ссылки на переменные, которые участвуют в замыкании.
Думаю вы видели, как многие IDE честно подсказывают, что в месте использования замыкания возникает захват переменных:

Что же происходит «под капотом»? В этом же классе создаётся класс, который представляет из себя то самое замыкание. Класс специально называется хитрым образом (в моём случае он называется DisplayClass4_0) и помечается атрибутом CompilerGenerated.
В специальном классе создаётся набор полей по количеству переменных, захватываемых замыканием. Также, создаётся метод, ссылка на который передаётся в метод Task.Run.
В декомпилированном коде (я использую dotPeek) это выглядит примерно вот так:

Почему происходит именно так? Потому что «замыкание» это не механизм платформы .NET, а языка C#. Если угодно, это синтаксический сахар, который делает язык красивым и выразительным. Однако, на более низком уровне, любой синтаксический сахар требует низкоуровневой реализации — и это она и есть. Подробнее о замыканиях написал Сергей Тепляков. У него написано много, объясняется сравнительно легко, в том числе затрагиваются особенности работы с замыканиями.
Аллокация при замыкании
Можно заметить, при каждом вызове замыкания «под капотом» создаётся инстанс класса замыкания, в его поля помещаются захватываемые значения, а в нужный нам метод передаётся ссылка на метод closure-класса, где и происходит выполнение логики, указанной в замыкании. Напомню, что инстанс класса размещается в куче, откуда его потом удалит GC.
Кажется, что это совсем не страшно, так как речь в подавляющем большинстве случаев идёт о помещении инстанса в Gen0, откуда он будет быстро удалён. Более того, сам класс замыкания предельно лёгкий и не занимает много места.
Однако, если место использования замыкания горячее (часто вызывается), то GC может не успеть удалить инстансы closure-класса. При самых печальных сценариях, это может привести к «выживанию» классов вплоть до Gen2, с последующим stop the world для проведения вдумчивой очистки кучи.
Более того, не надо забывать, что не все имплементации платформы работают одинаково. Например, игровой движок Unity имеет особенный GC с одним поколением. Это требует от разработчиков очень внимательно относиться к тому, кто и что аллоцирует и в каких количествах.
Имплементация собственного замыкания
Чтобы снизить нагрузку на GC, в некоторых сценариях можно попытаться написать собственную имплементацию замыкания. Кажется, что это просто, так как мы знаем как работает closure.
private sealed class Closure<T> { private readonly Action<T> _action; private readonly Action _closure; private T _value; public Closure(Action<T> action) { _action = action; _closure = Execute; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Clear() => _value = default; [MethodImpl(MethodImplOptions.AggressiveInlining)] public Action Prepare(T value) { _value = value; return _closure; } private void Execute() => _action(_value); }
Если мы знаем количество вызовов этого класса, то мы можем запросто преаллоцировать все замыкания. При использовании мы просто записываем значение аргумента в поле класса (точно также, как сделали бы за нас «под капотом «), а в качестве метода используем заранее созданную ссылку на метод этого же класса.
for (var i = 0; i < _objects.Length; i++) { _tasks[i] = Task.Run(_closures[i].Prepare(in _objects[i])) } Task.WaitAll(_tasks); foreach (var closure in _closures) { closure.Clear(); }
При использовании собственного класса-замыкания при декомпиляции кода мы можем наблюдать более понятную картину без «магии под капотом». Бонусом, мы избавились от создания new Action при передачи ссылки на метод замыкания в нужный нам метод.

Минус подобного использования — инстанс класса замыкания нужно очищать от значения, которое в него было передано ранее. Сделать это необходимо, поскольку это место становится местом потенциальной утечки памяти, так как замыкание будет хранить ссылку на «захваченное» значение вечно.
Ещё один минус — многопоточность. При использовании собственной имплементации замыкания нужно следить за тем, чтобы переданные в замыкание значения были атомарны для каждого из потоков. Как это сделать красиво и без особых сложностей — совершенно другой вопрос.
Уменьшение аллокации при замыкании
Какие же конкретные числа мы можем получить при замене стандартного механизма замыкания на собственный велосипед? Были произведены замеры с использованием известного фреймворка для микробенчмаркинга BenchmarkDotNet. Код бенчмарка находится тут.
|
Method |
Runtime |
Mean |
Error |
StdDev |
Ratio |
Gen 0 |
Gen 1 |
Gen 2 |
Allocated |
|---|---|---|---|---|---|---|---|---|---|
|
AutoClosure |
.NET 6.0 |
6.419 ms |
0.0541 ms |
0.0507 ms |
1.00 |
203.1250 |
— |
— |
1,708 KB |
|
SelfClosure |
.NET 6.0 |
5.922 ms |
0.0521 ms |
0.0487 ms |
0.92 |
93.7500 |
— |
— |
773 KB |
|
|
|
|
|
|
|
|
|
|
|
|
AutoClosure |
.NET Core 3.1 |
7.122 ms |
0.0155 ms |
0.0145 ms |
1.00 |
203.1250 |
— |
— |
1,708 KB |
|
SelfClosure |
.NET Core 3.1 |
6.695 ms |
0.0117 ms |
0.0109 ms |
0.94 |
93.7500 |
— |
— |
774 KB |
|
|
|
|
|
|
|
|
|
|
|
|
AutoClosure |
.NET Framework 4.6.1 |
8.151 ms |
0.0191 ms |
0.0179 ms |
1.00 |
296.8750 |
62.5000 |
15.6250 |
1,895 KB |
|
SelfClosure |
.NET Framework 4.6.1 |
8.348 ms |
0.0189 ms |
0.0177 ms |
1.02 |
140.6250 |
15.6250 |
— |
954 KB |
Приятно, что скорость осталась примерно прежней. Это говорит о том, что сделано более менее правильно.
Столбец «Allocated» бодро рапортует нам о том, что аллокация меньше почти в два раза. Но, собственно, почему же она есть? Если вы посмотрите код бенчмарка, то вы заметите, что я пытаюсь минимизировать аллокацию при запуске Task’ов. Это достаточно распространенный случай использования замыкания. Цифры, которые можно увидеть в столбце Allocated включают в себя затраты платформы на создание Task’ов.
P.S.: Начал писать в телегу и, внезапно, Дзен. Заглядывайте, если интересно.
ссылка на оригинал статьи https://habr.com/ru/post/677818/
Добавить комментарий