В предыдущих сериях
ThreadPool.Intro #dotnet #methods #gc
Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview
Сказка про Method as Parameter #dotnet #methods #gc
Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization
Нет, не про наскучившие области видимости и прочую чепуху, пренепременно встречаемую по первым ссылкам в интернете. А про то, как, казалось бы, абсолютно корректным, но неаккуратным замыканием можно, как бы лучше выразиться… отстрелить себе ногу.
Художественное отступление, шутка на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом совершенно не обязательно.
На самом краю села Инкапсуляцкого, в небольшом уютном офисе Рихфри расположились после вечерних посиделок с настолками инженеры. Их осталось только двое: инженер Иван Иваныч и преподаватель университета Буркин. Остальные уже отправились по домам.
Не расходились, несмотря на поздний час. Иван Иваныч крутил в руках игральные кости, сидя у окна; его освещала луна и тусклый свет от гирлянд в опенспейсе. Буркин лежал в тени на диване и копался в телефоне.
Рассказывали разные истории. Между прочим говорили о том, что даёт и что отнимает высокий уровень абстракции, на котором сегодня принято работать в современных языках программирования.
— Что же тут удивительного! — сказал Буркин. — Пусть язык диктует высокий уровень абстракции. Пусть язык предоставляет огромное количество сахара, который так и манит бездумно им пользоваться. И кто-то может возразить — «нет, но ведь это обязанность разработчика этого языка давать такой инструмент, чтобы он был и удобным, и эффективным!». А я скажу вам, ничего подобного. Разработчики языков, вообще-то, тоже люди. И они не в состоянии сделать язык идеальным сразу. И сейчас он такой, какой он есть.
Ну вот, например, представь себе. Делаешь ты язык с высоким уровнем абстракции, кроссплатформенный, удобный, с кучей сахара. А ресурс-то у тебя ограниченный. Уже через полгода выпускать надо. И если не успеешь выпустить работоспособный вариант, и об этом прознает начальство, как бы чего не вышло.
И весь язык начинает обрастать всяческими особенностями. Лишь бы работало корректно. Да вот, недалеко искать, возьмите 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.
![](https://habrastorage.org/getpro/habr/upload_files/47b/1a2/242/47b1a2242a72cdcdfa66a2a617351ec4.png)
Первый отчет был готов буквально через несколько минут. Действительно, что-то постоянно создаёт объекты с c__DisplayClass17_0
в именах. И это что-то — top-2 мусорщик .net объектами в процессе. Такое в 518 кабинете не прощается. Такое инженер Ч не оставляет просто так.
Дальнейшие углубления в результаты работы PerfView показали, что метод ConfigurationProvider.Get()
вызывают в цикле много раз подряд. Сотни тысяч раз. И такой цикл периодически повторяют, достаточно часто, через небольшой интервал времени. Это очевидно следовало из устройства метода FindChunksToHash
.
![](https://habrastorage.org/getpro/habr/upload_files/f39/270/876/f39270876466c3830dd8ef60264e5e4a.png)
На лице инженера Ч появилась едва уловимая улыбка. Рассадник чудовищ был найден.
В интернетахъ никто не рассказывает, как бороться с дуростью дотнета
Ну как, конечно же обо всём рассказывают. Если задавать правильные вопросы и знать, что искать. Но речь не об этом.
Вернёмся к нашему примеру. К методу 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, то ничего не меняется.
Вот картинка бенчмарка, кому интересно.
![](https://habrastorage.org/getpro/habr/upload_files/0b9/83e/597/0b983e597566d5c08fec6b70027873e4.png)