Search
Write a publication
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