Pull to refresh

Comments 22

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

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

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

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

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


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


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

Ну и как вы избавитесь от объекта на куче, когда делегат утекает в глобальную коллекцию внутри планировщика задач? Это просто невозможно.


И в Java аналогичный код точно так же будет создавать объекты на куче.

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

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

Да, вы правы.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

void DoWork(object p) { }

...

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

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

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

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

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

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

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

Sign up to leave a comment.

Articles