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 такой низкий из-за слишком маленьких заданий - рантайм тратит больше времени на шедулинг чем на сами задания, где-то был доклад или статья по этому поводу.
Только не при каждом вызове, в при каждом создании замыкания. При выхове ничего не аллоцируется.
Вот так не будет проще/быстрее?var j = i;
_tasks[j] = Task.Run(() => _handlers[j].Handle(_objects[j]));
Может быть, в таком случае есть смысл сделать какую-то свою очередь/пул задач? Ну то есть, у вас под капотом условно крутится 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
Снижение аллокации при замыкании (closure)