Недавно у меня появилась задача по снижению аллокации в очень горячем месте кода. Там происходит тривиальное: запускаются 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

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.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.: Начал писать в телегу и, внезапно, Дзен. Заглядывайте, если интересно.

Комментарии (21)


  1. tbl
    19.07.2022 22:05

    при каждом вызове замыкания "под капотом" создаётся инстанс класса замыкания

    Здесь жесть конечно. Объект иммутабельный же (только параметризация вызова отличается). Здесь можно выполнить прямые инлайны и оптимизацию по месту при кодогенерации в JIT без всяких созданий управляемых объектов на куче, а просто на стеке. В java 11+ смогли такое для случаев, когда при кодогенерации в инлайне доступно тело лямбды. А с легковесными виртуальными потоками, которые приедут в Java 19, это будет доступно и для межпоточного взаимодействия.


    1. teoadal Автор
      19.07.2022 22:36

      Да, к сожалению, вот так. Я не знаю о чем думали создатели этого механизма и может быть есть более оптимальный способ (знатоки подскажут)... но я использовал наиболее простой и самый распространенный способ создания Task'a. Возможно, была надежда на то, что получится короткоживущий объект и он будет удалён из кучи почти сразу. Но это, к сожалению, не так и бенчмарк это подтверждает - "объекты замыкания" существуют в Gen2.


      1. tbl
        19.07.2022 23:45
        +1

        Ну, когда в java 8 вводили лямбды (один из видов замыканий), то планировали, что в будущем в JVM не будет лишних созданий объектов на уровне оптимизированной компиляции байткода там, где это возможно. И в следующем релизе JVM это подогнали (java 11). Странно, что в экосистеме .net языки следуют на 2-3 (а то и больше) поколений впереди VM. Такое ощущение, что виртуальная машина не развивается.


    1. lair
      20.07.2022 02:07
      +5

      В общем случае замыкание в C# не иммутабельно.


      Типичный пример:


      string a;
      Some(p => a = p.a);


  1. navferty
    19.07.2022 22:46
    +2

    Класс специально называется хитрым образом (в моём случае он называется DisplayClass4_0)

    Надо заметить, что название класса <>DisplayClass4_0, и эти две угловые скобки в начале имени дают гарантию, что в пользовательском коде на языке C# точно не будет коллизий с таким сгенерированным классом.


    1. teoadal Автор
      19.07.2022 22:56
      +2

      Да, вы правы.

      Я объяснюсь. При написании статьи бывает чертовски сложно балансировать на определённом уровне знаний, которые, волей-неволей, предъявляются к читателю. Детальное разжевывание мелочей имплементации захламляет статью и отпугивает знатоков. Увы, это и не привлекает людей уровня junior, так как для них это просто не интересно. У них задача "сделать", а не "понятно как сделать, но нужно, чтобы работало быстрее".


  1. Deosis
    20.07.2022 07:24
    +1

    В данном случае стоит протестировать также метод Parallel.For, так как он специально сделан для параллельной обработки массивов.


    1. teoadal Автор
      20.07.2022 10:39

      Очень хороший вопрос, который я забыл осветить в статье!

      Действительно, использование Parallel.For и Parallel.Foreach значительно повышает скорость работы. Однако, к сожалению, их использование существенно увеличивают аллокацию (почти в три раза выше на .NET 6):

      Дьявол, к сожалению, кроется в деталях. Parallel.Foreach принимает в качестве аргумента IEnumerable<T>, который возвращает IEnumerator<T>. Объект, который будет расположен в куче. Parallel.For снова создаёт то самое замыкание, что также влияет на аллокацию.


      1. Deosis
        20.07.2022 11:29

        У вас в тестах для SelfClosure память выделяется заранее до теста, так что сравнение неспортивное.


        1. teoadal Автор
          20.07.2022 11:55

          Во-первых, потому что я могу это сделать и это действительно будет работать именно так. В реальном приложении я, опуская детали, точно также беру заранее подготовленный набор замыканий через Interlocked.Exchange. Если он null, я создаю новый массив с замыканиями. После использования, я кладу массив обратно. Короче говоря, в самом плохом сценарии получаю плюс-минус тот же результат, что и в AutoClosure.

          Во-вторых, а зачем, собственно, мне создавать массив с замыканиями на каждый запрос? Зачем мне вообще создавать объект замыканий, если я могу их предсоздать и запулить. Если бы я назвал это Pool, было бы проще? Воспринимайте это как пул замыканий (а-ля вот так), сильно упрощённый для теста.

          В-третьих, для Parallel.ForEach я тоже заранее создаю набор "замыканий". От этого ничего не меняется.


      1. soalexmn
        20.07.2022 12:35
        +2

        Лучшее решение все-таки Parallel.For/Parallel.ForEach так как никаких замыканий практически не создается.

        2-3КБ которые аллоцируются на Parallel - это внутренние таски/структуры, необходимые для метода. Например, в вашем тесте можно поменять количество хэндлеров-заданий с 10 на 400 и получить уже совсем другую картину:

        |          Method |       Mean |     Error |     StdDev |     Median | Ratio | RatioSD |      Gen 0 |    Gen 1 | Allocated |
        |---------------- |-----------:|----------:|-----------:|-----------:|------:|--------:|-----------:|---------:|----------:| 
        |     AutoClosure | 132.774 ms | 9.6346 ms | 28.4079 ms | 137.038 ms |  1.00 |    0.00 | 10500.0000 | 750.0000 |     64 MB | 
        |     ParallelFor |   7.057 ms | 0.1364 ms |  0.1912 ms |   7.142 ms |  0.07 |    0.01 |   421.8750 |        - |      3 MB | 
        | ParallelForeach |   7.415 ms | 0.0224 ms |  0.0209 ms |   7.418 ms |  0.08 |    0.01 |   453.1250 |        - |      3 MB | 
        |     SelfClosure | 102.784 ms | 2.4890 ms |  7.2998 ms | 105.092 ms |  0.81 |    0.19 |  4500.0000 | 333.3333 |     27 MB | 

        Кстати, перфоманс AutoClosure/SelfClosure такой низкий из-за слишком маленьких заданий - рантайм тратит больше времени на шедулинг чем на сами задания, где-то был доклад или статья по этому поводу.


        1. teoadal Автор
          20.07.2022 12:59

          О, спасибо большое! Я обязательно добавлю это в статью и доработаю бенчмарк. Но я всё ещё рад, что SelfClosure обходит AutoClosure по аллокации)

          Вот за это я люблю Хабр! Комменты от профессионального сообщества всегда интереснее и полезнее, чем на всяких других площадках.


  1. Aquahawk
    20.07.2022 09:16
    +1

    Только не при каждом вызове, в при каждом создании замыкания. При выхове ничего не аллоцируется.


  1. astec
    20.07.2022 11:02

    Вот так не будет проще/быстрее?

    var j = i;
    _tasks[j] = Task.Run(() => _handlers[j].Handle(_objects[j]));


    1. teoadal Автор
      20.07.2022 11:14

      Простите, а почему вы так считаете?

      Ничего же не изменилось: замыкание существует. Ваш результат - AutoClosureWhat.


      1. astec
        20.07.2022 12:18

        Ну я не утвеждал, а спрашивал. Может быть .NET достаточно умный чтобы аллоцировать такое на стэке.


  1. gdt
    20.07.2022 12:06

    Может быть, в таком случае есть смысл сделать какую-то свою очередь/пул задач? Ну то есть, у вас под капотом условно крутится N тасок или потоков, которые из условного ConcurrentDictionary выгребают Action<T> action и T obj, и делают action(obj), а вы у себя в коде делаете что-то типа pool.FireAndForget(handler, obj), который их туда складывает? Всё равно ваш obj будет копироваться в замыкание, так что потерь точно быть не должно :)

    Также, не пробовали ридонли структуры для своих замыканий? Если там не сотни тысяч одновременно, теоретически может помочь (как минимум, есть смысл замерить).


    1. teoadal Автор
      20.07.2022 12:28

      Может быть, в таком случае есть смысл сделать какую-то свою очередь/пул задач

      Вы прям описали одну известную библиотеку для background-обработки задач. Для случаев "fire and forget" она подходит идеально и построена примерно так, как вы написали.

      В моём случае понадобилось небольшое вкрапление (микрооптимизация) в горячем месте кода. Что-то вроде вот такого, что я писал для Mediator (использовать в продакшене не рекомендую!).

      Также, не пробовали ридонли структуры для своих замыканий?

      В них же нужно запихивать значение. Либо пересоздавать всю структуру каждый раз, при каждом вызове. Если будет время, то попробую, спасибо.


  1. DriverEntry
    20.07.2022 13:07
    +3

    Вообще без замыканий тоже достаточно удобно и лаконично.

    void DoWork(object p) { }

    ...

    Task.Factory.StartNew(DoWork, objects[i], CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);


    1. teoadal Автор
      20.07.2022 14:35

      Да, всё верно. Можно и так.

      Только, во-первых, надо использовать не DoWork, а сделать переменную и туда положить DoWork, иначе будет аллокация, о чём честно предупреждает Rider.

      Во-вторых, необходимо всё-таки выполнить условия задачи, совместив объект данных с handler'ом. Для этого я обновил бенчмарк и сделал объект TaskFactoryClosure. Я понимаю, что из этого синтетического теста не очень понятно, что надо совместить данные + обработчик, но представим себе, что они у вас разные и формируются по разному под данные. Из теста это исключено, чтобы не замерять бизнес-логику и сконцентрироваться на аллокации. Статья-то про это)

      Ну и вот результаты: плюс-минус аналогичные SelfClosure. Круто!


      1. epeshk
        20.07.2022 16:01

        Только, во-первых, надо использовать не DoWork, а сделать переменную и туда положить DoWork, иначе будет аллокация, о чём честно предупреждает Rider

        В будущих версиях языка (начиная с C# 11) это будет не нужно
        https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-11#improved-method-group-conversion-to-delegate