Снижение аллокации при замыкании (closure)

от автора

Недавно у меня появилась задача по снижению аллокации в очень горячем месте кода. Там происходит тривиальное: запускаются Task’и в которых заранее известным набором handler’ов обрабатываются объекты. Вооружившись профайлером, я с удивлением обнаружил, что много памяти (и много времени GC) затрачивается на удаление объектов-замыканий.

Что такое замыкание в C#?

Замыкания (closure) это очень крутая штука, которая помогает писать более лаконичный код на C#. Под капотом, замыкание это более-менее обычный класс, который «захватывает» ссылки на переменные, которые участвуют в замыкании.

Думаю вы видели, как многие IDE честно подсказывают, что в месте использования замыкания возникает захват переменных:

Уведомление о closure в IDE Rider
Уведомление о closure в IDE Rider

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

В специальном классе создаётся набор полей по количеству переменных, захватываемых замыканием. Также, создаётся метод, ссылка на который передаётся в метод Task.Run.

В декомпилированном коде (я использую dotPeek) это выглядит примерно вот так:

Декомпилированный код с замыканием в C#
Декомпилированный код с замыканием в C#

Почему происходит именно так? Потому что «замыкание» это не механизм платформы .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 при передачи ссылки на метод замыкания в нужный нам метод.

Декомпилированный код с собственным замыканием в C#
Декомпилированный код с собственным замыканием в C#

Минус подобного использования — инстанс класса замыкания нужно очищать от значения, которое в него было передано ранее. Сделать это необходимо, поскольку это место становится местом потенциальной утечки памяти, так как замыкание будет хранить ссылку на «захваченное» значение вечно.

Ещё один минус — многопоточность. При использовании собственной имплементации замыкания нужно следить за тем, чтобы переданные в замыкание значения были атомарны для каждого из потоков. Как это сделать красиво и без особых сложностей — совершенно другой вопрос.

Уменьшение аллокации при замыкании

Какие же конкретные числа мы можем получить при замене стандартного механизма замыкания на собственный велосипед? Были произведены замеры с использованием известного фреймворка для микробенчмаркинга 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *