В предыдущих сериях

Нет, не про наскучившие области видимости и прочую чепуху, пренепременно встречаемую по первым ссылкам в интернете. А про то, как, казалось бы, абсолютно корректным, но неаккуратным замыканием можно, как бы лучше выразиться… отстрелить себе ногу.

Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.

На самом краю села Инкапсуляцкого, в небольшом уютном офисе Рихфри расположились после вечерних посиделок с настолками инженеры. Их осталось только двое: инженер Иван Иваныч и преподаватель университета Буркин. Остальные уже отправились по домам.

Не расходились, несмотря на поздний час. Иван Иваныч крутил в руках игральные кости, сидя у окна; его освещала луна и тусклый свет от гирлянд в опенспейсе. Буркин лежал в тени на диване и копался в телефоне.

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

— Что же тут удивительного! — сказал Буркин. — Пусть язык диктует высокий уровень абстракции. Пусть язык предоставляет огромное количество сахара, который так и манит бездумно им пользоваться. И кто-то может возразить — «нет, но ведь это обязанность разработчика этого языка давать такой инструмент, чтобы он был и удобным, и эффективным!». А я скажу вам, ничего подобного. Разработчики языков, вообще-то, тоже люди. И они не в состоянии сделать язык идеальным сразу. И сейчас он такой, какой он есть.

Ну вот, например, представь себе. Делаешь ты язык с высоким уровнем абстракции, кроссплатформенный, удобный, с кучей сахара. А ресурс-то у тебя ограниченный. Уже через полгода выпускать надо. И если не успеешь выпустить работоспособный вариант, и об этом прознает начальство, как бы чего не вышло.

И весь язык начинает обрастать всяческими особенностями. Лишь бы работало корректно. Да вот, недалеко искать, возьмите C#, а точнее .Net. Как не пойдёшь с массивами работать, пусть даже при идеальных условиях, когда всё совершенно ясно, так всё равно без проверок на выход за границы массивов ничего не будет компилироваться. Всё оборачивается в дополнительные проверки.

Если оглянуться немного в прошлое, то куда ни глянь, так всё оборачивается в объекты. Как в чехол. Как бы чего не вышло, пусть лучше ссылка на хипе будет. Так оно проще. Сейчас, правда, оно несколько лучше стало, но всё равно.

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

И вот он весь такой, этот язык. В нём наблюдается непреодолимое стремление окружить каждую свою частичку оболочкой. Компилятор создаёт себе под каждое действие, так сказать, футляр. Который бы защищал его от внешних обстоятельств, от ошибок разработчиков. И, в том числе, от недостатка ресурса разработчиков самого языка и компилятора. И к счастью, со временем, от этих оболочек избавляются. То тут, то там. И хочется надеяться, что настанет время, когда такие футляры перестанут возникать повсеместно.

Главное, чтобы насмешки и колкие замечания окружающих не похоронили язык. Не заставили его замкнуться в своём футляре, в котором его и придётся похоронить, под нагнетанием сообщества. Главное, чтобы он успевал развиваться быстрее, чем окружающие успеют достаточно рассердиться на его, кхм, особенности. И тогда, возможно, эта история закончится лучше, чем одна всем нам известная.

— Луна-то, луна! — сказал Иван Иваныч, глядя вверх.

— Ладно, уж пора спать, — сказал Буркин. — До завтра!

И минут через десять Буркин уже сидел в такси, ехал домой. А Иван Иваныч всё ворочался у окна и вздыхал. Вставал, ходил взад вперед, и снова садился у окна. Его всё не отпускали мысли, как же соблюдать баланс между удобством написания кода и эффективностью работы программы.

— То-то вот оно и есть, — говорил про себя Иван Иваныч. — А разве мы сами не размещаем себя в точно такие же футляры? Вот, например, описываешь ты класс. И предполагается конкурентное использование его методов и полей. И пишешь ты volatile. Так, на всякий случай. Оборачиваешь переменную в оболочку. Да, зачастую volatile необходим. Но бывают ситуации, когда на самом деле volatile и не нужен. Но всё равно, пусть будет, как бы чего не вышло.

Все имена повествования вымышленные, все совпадения случайные.

Где-то в интернетахъ

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

Давайте для начала вспомним, что такое замыкание. Рассмотрим типичный вариант, чтя традиции интернета, предлагаемый в статьях «какие замыкания коварные» и «как набажить с замыканиям». Ох, если бы всё ограничивалось только этими детскими проблемами..

public void ClosureExample()
{
    for (int i = 0; i < Count; i++)
    {
        var j = i;
        ActionsArray[i] = () => Console.WriteLine(j);
    }
}

Тут мы замыкаемся на локальную переменную j. И все кругом тычут носом, что ни в коем случае нельзя замыкаться на i, иначе все наши Action'ы будут смотреть на одно и то же, последнее значение переменной i = Count - 1. Но почти никто не говорит, почему оно произойдёт именно так.

Тем временем, где-то в 518 кабинете в Контуре

Инженер Ч приступил к выполнению одной очень интересной задачи. Для того, чтобы можно было решить задачу, выдавался доступ к настоящим дампам! Такое нельзя было пропускать.

Дамп вскоре оказался под пристальным изучением. Помимо того, что ожидалось увидеть в дампе, к большому удивлению инженера Ч там было кое-что очень интересное…

>DumpHeap -stat
Count     Size
....
4150480    132815360 Vostok.ConfigurationProvider+<>c__DisplayClass17_0`1[[Kanso3d.Core.Configuration.DangerousCommonSettings, Kanso3d.Core.Server]]
 157499    202821177 Kanso3d.Chunkserver.Chunks.Index.ChunkBlockType[]
  37110    204087504 System.UInt16[]
 204172    260030246 System.Byte[]
8534648    273108736 Vostok.ConfigurationProvider+<>c__DisplayClass17_0`1[[Kanso3d.Chunkserver.Configuration.ChunkserverSettingsShared, Kanso3d.Chunkserver]]
3079593    313819122 System.String 
 384110   1613646180 System.Int32[]
 
Total 37051961 objects
//Да, дамп самый настоящий

Охота велась целенаправленно на System.Int32[]. Но помимо них поймалось целое стадо каких-то чудовищ, со всякими страшными +<>c_DisplayClass17_0 в именах!

Что не часто показывают в интернетахъ

Какие есть распространённые варианты использовать лямбды с замыканиями? Ну, например, организовывать кеши. Если в кеше что-то есть, то возвращаем ответ сразу. Иначе запускаем тяжелую работу с различными лямбдами, замкнутыми на аргументы, с которыми в метод пришли.

Или запускать какие-нибудь Task.Run'ы, где в теле задачи будет лямбда, замкнутая на переменные в теле метода.

Как всегда, замыкания сплошь и рядом встречаются рядом с LINQ. Например, когда используется конструкция вроде Where(x => x.Match(localVariable)), функция в аргументе Where замыкается на локальную переменную localVariable. Но LINQ мы уже давно не жалуем, поэтому в эту сторону даже не смотрим.

Давайте объединим два способа, чтобы сконструировать тренировочный пример. В котором специальным образом подготовим кеш — ответ уже будет лежать в кеше, и мы будем всегда сразу доставать его. И нам никогда не нужно будет конструировать лямбду, замыкаться на какие-нибудь переменные.

private Dictionary<string, string> cache = new Dictionary<string, string>();
 
[GlobalSetup]
public void SetUp()
{
    cache["Ring-ding"] = "What does the fox say?";
}
 
[Benchmark]
[Arguments("Ring-ding")]
public string FindQuestion(string answer)
{
    if (cache.TryGetValue(answer, out var question))
    {
        return question;
    }
 
    Task.Run(async () =>
    {
        await NPCompleteProblemSolver(answer);
    });
 
    return "I dont't know the question right now..";
}
 
private async Task NPCompleteProblemSolver(string answer)
{
    await Task.Delay(100000);
    //Непотокобезопасно! Но для примера сойдёт.
    //Всё равно сюда не зайдём ни разу.
    cache[answer] = "What is the sense of life?";
}

Если нам не нужно билдить никаких лямбд (та, что внутри Task.Run, замыкающаяся на answer), мы должны сразу выйти из метода, достав результат из словарика-кеша. Быстро и просто. Кажется на первый взгляд. Что же мы увидим в бенчмарке?

|            Method |     Mean |  Gen 0 | Allocated |
|------------------ |---------:|-------:|----------:|
|      FindQuestion | 25.05 ns | 0.0076 |      32 B |

У нас появились какие-то аллокации объектов на хипе! Откуда они?

Где-то в 518 кабинете в Контуре изучают много миллионов чудовищ

Инженер Ч поискал gcroot'ы у объектов с c__DisplayClass17_0, но не нашёл ни одного живого. Все чудовища уже мертвы, но их просто не собрали сборщики мусора?

>DumpHeap -stat -live
  Count    Size
....
 157499     202821177 Kanso3d.Chunkserver.Chunks.Index.ChunkBlockType[]
 204172     260030246 System.Byte[]
3079593     313819122 System.String 
 384110    1613646180 System.Int32[]
 
Total 24356833 objects
//Да, UInt16[] тоже оказались в основном мертвыми и исчезли из топа. 
//Но речь сегодня не о них. Тем более, что их мало (количественно).

Запросив анализ только по живым объектам (ключ -live), то есть только по тем, на которые есть ссылки, было замечено, что все страшные объекты с c__DisplayClass17_0 в именах пропали из топа. Их осталось буквально несколько сотен, очень-очень далеко от топа, в самой глубине отчета.

12 миллионов объектов и ~360MB RAM процесса завалены бесполезными трупами.. Инженера Ч это очень обеспокоило.

О чем в интернетахъ не часто говорят

Всё-таки не сложно узнать, что каждая переменная, на которую происходит замыкание, превращается в анонимный класс. Который появляется уже в моменте компиляции. То есть да, если возвращаться к популярному примеру про замыкания (цикл в самом начале статьи), то в том коде у нас не просто структура int j. У нас, на самом деле, анонимный класс, который будет аллоцироваться на хипе. А в этом классе лежит наш один int j (в этом не сложно убедиться с помощью дампа).

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

Давайте обратимся к тому, какой в итоге код скомпилировал нам .net. Все ответы обычно лежат там. Я специально вырезал всё неинтересное мясо, оставил только самое важное:

; benchmarks.QuestionFinder.FindQuestion(System.String)
...
; Буквально в первой же строчке мы вызываем CORINFO_HELP_NEWSFAST
; А аргумент у него - MT_benchmarks.QuestionFinder+<>c__DisplayClass2_0
; То есть мы выделаяем объект на хипе! А тип объекта - то страшное название.
; Это и есть анонимный класс. Именно его можно видеть в дампах.
       mov       rcx,offset MT_benchmarks.QuestionFinder+<>c__DisplayClass2_0
       call      CORINFO_HELP_NEWSFAST
...
; Назначаем ссылку на выделенное место
       call      CORINFO_HELP_ASSIGN_REF
...
; Где-то тут проверяем кеш.
...
; Только здесь мы проверяем, а надо ли нам идти и создавать делегат.
; Если надо - прыгаем на M00_L00.
; Иначе - сразу выходим, сложив ответ в RAX.
; И в бенчмарке мы всегда идём по пути "сразу выйти", а не создаём делегат.
       test      eax,eax
       je        short M00_L00
...  
       mov       rax,[rsp+28]
       ret
 
M00_L00:
; Сюда мы никогда не заходили, но всё же.
; Тут мы создаём объект. Делегат. Знакомое имя - Func<Task>.
       mov       rcx,offset MT_System.Func`1[[System.Threading.Tasks.Task, System.Private.CoreLib]]
       call      CORINFO_HELP_NEWSFAST
...
; Назначаем ссылку на выделенное место
       call      CORINFO_HELP_ASSIGN_REF
...
; А вот мы зовём наш Task.Run с делегатом внутри.
       call      System.Threading.Tasks.Task.Run(System.Func`1<System.Threading.Tasks.Task>, System.Threading.CancellationToken)
       mov       rax,1786DE59B58
       mov       rax,[rax]
       ret
<details>
Под катом код целиком и без комментариев
; benchmarks.QuestionFinder.FindQuestion(System.String)
       push      rdi
       push      rsi
       push      rbx
       sub       rsp,30
       xor       eax,eax
       mov       [rsp+28],rax
       mov       rsi,rcx
       mov       rdi,rdx
       mov       rcx,offset MT_benchmarks.QuestionFinder+<>c__DisplayClass2_0
       call      CORINFO_HELP_NEWSFAST
       mov       rbx,rax
       lea       rcx,[rbx+8]
       mov       rdx,rsi
       call      CORINFO_HELP_ASSIGN_REF
       lea       rcx,[rbx+10]
       mov       rdx,rdi
       call      CORINFO_HELP_ASSIGN_REF
       mov       rcx,[rsi+8]
       mov       rdx,[rbx+10]
       lea       r8,[rsp+28]
       cmp       [rcx],ecx
       call      qword ptr [7FF93505CD20]
       test      eax,eax
       je        short M00_L00
       mov       rax,[rsp+28]
       add       rsp,30
       pop       rbx
       pop       rsi
       pop       rdi
       ret
M00_L00:
       mov       rcx,offset MT_System.Func`1[[System.Threading.Tasks.Task, System.Private.CoreLib]]
       call      CORINFO_HELP_NEWSFAST
       mov       rsi,rax
       lea       rcx,[rsi+8]
       mov       rdx,rbx
       call      CORINFO_HELP_ASSIGN_REF
       mov       rcx,offset benchmarks.QuestionFinder+<>c__DisplayClass2_0.<FindQuestion>b__0()
       mov       [rsi+18],rcx
       mov       rcx,rsi
       xor       edx,edx
       call      System.Threading.Tasks.Task.Run(System.Func`1<System.Threading.Tasks.Task>, System.Threading.CancellationToken)
       mov       rax,1786DE59B58
       mov       rax,[rax]
       add       rsp,30
       pop       rbx
       pop       rsi
       pop       rdi
       ret
; Total bytes of code 175

Что в итоге? Даже не смотря на то, что нам ни разу не захотелось сконструировать Func, объект на хипе под замыкание создался всё равно. Чуть ли не самым первым действием в этом методе дотнет создал нам анонимный объект под потенциальное замыкание. Даже не узнав, а понадобится ли оно нам вообще. How dare you!

В 518 кабинете в Контуре разыскивают рассадник чудищ

На текущий момент времени Инженеру Ч уже было понятно, что где-то в классе ConfigurationProvider в каком-то методе расположено замыкание на какую-то переменную. Можно было открыть код и методом пристального взгляда и ленинского прищура найти все такие места. Но было важно понять, почему этих чудовищных анонимных объектов появилось так много, откуда они все повылазили.

Раз все объекты мертвые, дамп тут не самый подходящий инструмент для анализа. Нужно изучать ситуацию в динамике. Инженер Ч расчехлил PerfView.

Первый отчет был готов буквально через несколько минут. Действительно, что-то постоянно создаёт объекты с c__DisplayClass17_0 в именах. И это что-то — top-2 мусорщик .net объектами в процессе. Такое в 518 кабинете не прощается. Такое инженер Ч не оставляет просто так.

Дальнейшие углубления в результаты работы PerfView показали, что метод ConfigurationProvider.Get() вызывают в цикле много раз подряд. Сотни тысяч раз. И такой цикл периодически повторяют, достаточно часто, через небольшой интервал времени. Это очевидно следовало из устройства метода FindChunksToHash.

На лице инженера Ч появилась едва уловимая улыбка. Рассадник чудовищ был найден.

В интернетахъ никто не рассказывает, как бороться с дуростью дотнета

Ну как, конечно же обо всём рассказывают. Если задавать правильные вопросы и знать, что искать. Но речь не об этом.

Вернёмся к нашему примеру. К методу FindQuestion. Раз уж дотнет не хочет разбираться сам, пригодится ли ему замыкание, а мы знаем, что в 99.9% случаев оно не пригодится, придётся ему помочь. Очевидно, если в методе нет ни намёка на возможность построить делегат с замыканием, то и замыкания возникать не будет. Создадим такие условия сами.

[Benchmark]
[Arguments("Ring-ding")]
public string FindQuestionSmart(string answer)
{
    if (cache.TryGetValue(answer, out var question))
    {
        return question;
    }
 
    UpdateSmart(answer);
 
    return "I dont't know the question right now..";
}
 
private void UpdateSmart(string answer)
{
    Task.Run(async () =>
    {
        await NPCompleteProblemSolver(answer);
    });
}

Как можно заметить, в методе FindQuestionSmart замыкания больше нет. Оно спряталось в методе UpdateSmart. Давайте проверим, сработает ли такой костыль.

|            Method |     Mean | Ratio |  Gen 0 | Allocated |
|------------------ |---------:|------:|-------:|----------:|
|      FindQuestion | 25.05 ns |  1.00 | 0.0076 |      32 B |
| FindQuestionSmart | 16.27 ns |  0.65 |      - |         - |

Сработало! Такой код работает почти в полтора раза быстрее и наконец-то не выделяет объектов на хипе. И если уж так вышло, что надо обновить запись в кеше, пойдём вызывать функцию. И уже в ней будут возникать все эти замыкания. Лишний вызов метода в этой ситуации — не великая плата, можно сказать эпсилон, в сравнении со всем остальным.

К концу дня в 518 кабинете в Контуре те, кому нужно, знали то, что нужно

Инженер Ч незамедлительно доложил о проблеме куда следует. Реакция оперативной группы была молниеносна как всегда. Место, откуда чудовища лезли толпами, было элегантно выжжено.

Теперь процессам можно будет держать ещё немного меньше мусора в памяти. А ресурсы, которые тратились на бесполезную работу и работу GarbageCollector'а, будут перераспределены на более важные вычисления. Также чудовища перестанут забредать и в другие сервисы. Положительный эффект от проведённой операции могут ощутить на себе не только сотрудники из 518 кабинета.

Теперь инженеру Ч ничего не мешает приступить к своей основной задаче.

Дак что все-таки с примером из этих интернетовъ?

Ещё реже говорят, почему так, зачем, и как именно проявляется баг с замыканием на ту самую переменную цикла i. Но на самом деле, ответ лежит на поверхности. Переменная цикла i (да и j, если говорить о «правильном варианте») — она живет лишь на стеке, в момент выполнения функции ClosureExample. А лямбды, которые мы создали (я в примере специально сложил их в массив) могут остаться жить вечно. И они никак не смогут дотянуться до переменной из того метода, который уже давно сняли со стека. Потому эти переменные и кладут в «надежное место» — в хип, в виде reference-типа. И держат на них ссылки.

Теперь не сложно догадаться, как проявляется собственно баг в замыкании на переменную i. Переменную i объявляют один раз, перед циклом. Причем объявляют сразу в виде анонимного класса. То есть в виде одного объекта. И всем лямбдам пихают ссылку на этот класс, то есть на один и тот же объект. А цикл заботливо делает ++ полю int i в этом объекте. Так, по окончанию работы цикла, в анонимном классе в его единственном поле int i лежит значение последней итерации цикла.

Где-то в 518 кабинете в Контуре подчищают следы

Благодаря инженеру Ч и его блестящей реакции на подозрительные сигналы на приборах в 518 кабинете, как уже известно, все, кому нужно, знали всё, что нужно. Инцидент не мог пройти бесследно.

Не дожидаясь очередной эскалации и дабы заранее пресечь возможные подобные ситуации, было решено провести ещё несколько прицельных разведывательных операций. В результате чего малые группы других инженеров обнаружили и устранили ещё несколько рассадников чудовищ. Пока ещё не больших, но уже заметных на радарах. Так, удалось пресечь дальнейшее распространение чудищ со следующими признаками (пример с очередного настоящего дампа другого приложения):

>dumpheap -stat
...
00007f53c6839248    39136       939264 Vostok.Datacenters.Kontur.Helpers.KonturDatacenterMappingProvider+<>c__DisplayClass4_1
00007f53c6836eb8    39133      1878384 Vostok.Commons.Helpers.Network.DnsResolver+<>c__DisplayClass7_0
00007f53c68396d0    39135      2504640 System.Func`2[[Vostok.Datacenters.Kontur.Helpers.NetworkToDatacenterMapping, Vostok.Datacenters.Kontur],[System.Boolean, System.Private.CoreLib]]
...
Total 1921691 objects //из них ~120k объектов или ~6% - мусорные замыкания и функции.

Отчеты о проделанной работе: раз, <censored>.

P.S.

Если затаргетиться на .Net 6, то ничего не меняется.

Вот картинка бенчмарка, кому интересно.

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


  1. qw1
    24.04.2023 07:09
    +1

    Очень интересно. Это что, выходит в примере


        for (int i = 0; i < Count; i++)
        {
            var j = i;
            ActionsArray[i] = () => Console.WriteLine(j);
        }

    будет создано Count объектов на хипе для j, под каждую итерацию?


    1. deniaa Автор
      24.04.2023 07:09

      Да, именно так. Это легко продемонстрировать, например вот так с помощью dottrace:

      В этом простом примере создается 100_000 Action'ов с замыканием. Объект замыкания - объект с одним полем int.

      Известно, что такой объект "весит" 24 байта. 24 * 100_000 = 2_400_000 байт, что ~2.2 MB, как и указано в трейсе. Что подтверждает, что мы создали 100_000 объектов на хипе под эти замыкания.


      1. qw1
        24.04.2023 07:09

        Понятно, что Action<T> это объект на хипе.
        Но из статьи у меня сложилось впечатление, что под переменную j сделается отдельный объект, на который в замыкании будет вести ссылка.


        Если мы так сделаем:


        var j = i;
        var action1 = () => Console.WriteLine($"{j}");
        var action2 = () => Console.WriteLine($"{j}");

        Объекты action1 и action2 — разные объекты на хипе. Но они оба должны видеть одну и ту же переменную j, и значит, под неё будет ещё одна аллокация?


        1. deniaa Автор
          24.04.2023 07:09
          +1

          Да, под переменную j будет ещё одна аллокация.

          В моём примере ровно на неё и сделан акцент, посмотрите внимательнее на скриншот. Я обвёл красным не тип System.Action, а именно <>c__DisplayClass2_0. Этот тип - и есть аллокации того самого "j".


          1. qw1
            24.04.2023 07:09

            Точно, невнимательно прочитал. Я было подумал, что аллокаций в сумме 100к, и компилятор смог оптимизировать, положив и Action, и захваченную переменную в одну аллокацию.


            А если захватывается 10 переменных, то аллокаций, получается, будет по числу переменных. Тоже хороший задел для будущих оптимизаций.


            1. deniaa Автор
              24.04.2023 07:09
              +1

              А если захватывается 10 переменных, то аллокаций, получается, будет по числу переменных

              А вот и нет, не всегда. Я поспешил ответить на этот вопрос чуть ниже, на ваше предыдущее сообщение.


        1. deniaa Автор
          24.04.2023 07:09

          Конечно, отдельного рассказа стоит рассмотрение различных ситуаций, когда в методе присутствует создания нескольких Action'ов (Func'ов) с замыканиями на различные наборы аргументов (пересекающихся и\или не пересекающихся). Там ух как весело!


          1. qw1
            24.04.2023 07:09

            В идеале бы дать программисту контроль, захватывать переменную по ссылке или по значению, как в C++
            В 99% достаточно захватывать значение, и тогда не нужно копировать стековые переменную в кучу.


      1. Usul
        24.04.2023 07:09
        +5

        Можно продемонстрировать это еще нагляднее с помощью Sharplab:

        https://sharplab.io/#v2:EYLgtghglgdgNAExAagD4AEBMBGAsAKAPQGYACLUgYVIG8DSHSAHAJygDcIAXAU1IGMA9jADOXUrHGVBAVxjiAvKQAsmANz1GrDtz4seEBMIA2AT3LZMAbQC6FzCICCLFhHNKYPAO72r0uVw2GviMpJoMJOTKpACyABQAlGEhjHQpoQwAZoIspHGSEqRKAAxqhQA8VLLyZVDIyAnhGWkZraScuQBWRRLBbRnolk4ublZQdkqJRQB8FgCccZ0JfW0Avk3r+KtAA==

        public class C
        {
            [CompilerGenerated]
            private sealed class <>c__DisplayClass2_0
            {
                public int j;
        
                internal void <M>b__0()
                {
                    Console.WriteLine(j);
                }
            }
        
            private const int Count = 42;
        
            [System.Runtime.CompilerServices.Nullable(1)]
            private readonly Action[] ActionsArray = new Action[42];
        
            public void M()
            {
                int num = 0;
                while (num < 42)
                {
                    <>c__DisplayClass2_0 <>c__DisplayClass2_ = new <>c__DisplayClass2_0();
                    <>c__DisplayClass2_.j = num;
                    ActionsArray[num] = new Action(<>c__DisplayClass2_.<M>b__0);
                    num++;
                }
            }
        }


  1. euroUK
    24.04.2023 07:09
    +5

    А могли бы просто серверов добавить. Я слышал нынче все так делают.


    1. deniaa Автор
      24.04.2023 07:09

      Мне пришлось отклонить некоторые провокационные комментарии к вашему сообщению, поэтому спрошу за них - это же была ирония? :)

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

      А просто знать, как работают замыкания, полезно всегда.


      1. euroUK
        24.04.2023 07:09

        Тэг сарказм отклеился.

        Мне кажется, для SAAS решений подобные оптимизации имеют смысл всегда. Вопрос лишь в том, что для решения таких задач не подходит вариант "числом поболее, ценою подешевле" при приеме разрабов.


  1. kekekeks
    24.04.2023 07:09

    [del]


  1. Anton92nd
    24.04.2023 07:09
    +5

    Добавлю, что разработчики дотнета знают о подобной проблеме, поэтому, например, у ConcurrentDictionary (который довольно часто выступает в роли in-memory кэша) есть методы

    • TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)

    • TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> valueFactory, TArg factoryArgument)

    Это позволяет не захватить в замыкание не только ключ, но и дополнительный аргумент, если это нужно.


    1. FFoxDiArt
      24.04.2023 07:09
      +4

      Также в языке начиная с C# 9 существуют Static anonymous functions. Позволяет на уровне компиляции проверять, что лямбда не захватывает ничего изменяющегося.


  1. mayorovp
    24.04.2023 07:09
    +2

    Ещё один способ избежать преждевременной аллокации объекта для замыкания — аллоцировать его самому в нужное время:


    public string FindQuestion(string answer)
    {
        if (cache.TryGetValue(answer, out var question))
            return question;
    
        var scope = new Scope(answer);
        Task.Run(scope.Run);
    
        return "I dont't know the question right now..";
    }
    
    private record class Scope(string answer)
    {
        public Task Run() => NPCompleteProblemSolver(answer);
    }

    В частном случае, когда замыкаемая переменная всего одна — скоуп в принципе не нужен, и можно связать с делегатом непосредственно саму переменную:


    public string FindQuestion(string answer)
    {
        if (cache.TryGetValue(answer, out var question))
            return question;
    
        Task.Run(answer.NPCompleteProblemSolver);
    
        return "I dont't know the question right now..";
    }
    
    private static class Helpers {
        public static Task NPCompleteProblemSolver(this string answer) => …;
    }


    1. qw1
      24.04.2023 07:09

      Если в первом примере заменить record class на record struct, код тоже компилируется. Но scope лежит на стеке. Что произойдёт, если task начнёт работу после выхода из функции FindQuestion?


      1. mayorovp
        24.04.2023 07:09
        +1

        Ничего особенного не произойдёт, при создании делегата структура будет упакована.


        В конце концов, делегаты создаются через конструктор с сигнатурой (object target, void* method)


    1. mvv-rus
      24.04.2023 07:09
      -1

      Ну, способ-то — он способ…
      Но любой кодер-фронтовик (да и задовики многие, которым надо JSONы перекладывать, много и разные), посмотревши на него, скажет:
      — Это ж сколько бойлерплейта писать-то? Проще серверов добавить…


  1. 0xd34df00d
    24.04.2023 07:09

    А может ли достаточно умный вредный угодливый компилятор заинлайнить UpdateSmart в своём следующем релизе и сломать ваш фикс?


    1. mayorovp
      24.04.2023 07:09
      +3

      Нет, поскольку даже после встраивания области видимости переменных останутся прежними.


      Там, на самом деле, достаточно просто новую переменную объявить и фигурные скобки поставить, чтобы лишняя аллокация пропала:


      string FindQuestion(string answer)
      {
          if (cache.TryGetValue(answer, out var question))
          {
              return question;
          }
      
          {  // теперь вредный c__DisplayClass17_0 создаётся вот тут
              string a2 = answer;
              Task.Run(async () =>
              {
                  await NPCompleteProblemSolver(a2);
              });
          }
      
          return "I dont't know the question right now..";
      }

      Тут скорее можно ожидать, что достаточно умный компилятор однажды научится бить скоупы самостоятельно. Лет через 30.


      1. deniaa Автор
        24.04.2023 07:09

        Спасибо сразу за несколько дополнительных вариантов, как можно избежать преждевременной аллокации объекта для замыкания. И за хорошее дополнение про области видимости в контексте этой оптимизационной "задачки".

        Ваши комментарии отлично дополняют статью.


      1. AdAbsurdum
        24.04.2023 07:09
        +1

        Скорее всего нет. Ишью давно висит. Там ответили что лучше не трогать а кому надо тот знает.

        https://github.com/dotnet/roslyn/issues/20777#issuecomment-1379582634


  1. mvv-rus
    24.04.2023 07:09
    -1

    Оценил напряженность сюжет с многими линяими и внезапными поворотами и переходами с линии на линию. Но ничего не понял, пока не прочитал комментарии — где все изложено по-простому, без сюжета. Может, в следующий раз ну его, сюжет?