Комментарии 24
Очень интересно. Это что, выходит в примере
for (int i = 0; i < Count; i++)
{
var j = i;
ActionsArray[i] = () => Console.WriteLine(j);
}
будет создано Count объектов на хипе для j, под каждую итерацию?
Да, именно так. Это легко продемонстрировать, например вот так с помощью dottrace:
![](https://habrastorage.org/getpro/habr/upload_files/cfb/3db/808/cfb3db808427ca9d57eb59765b0a6c30.png)
В этом простом примере создается 100_000 Action'ов с замыканием. Объект замыкания - объект с одним полем int.
Известно, что такой объект "весит" 24 байта. 24 * 100_000 = 2_400_000 байт, что ~2.2 MB, как и указано в трейсе. Что подтверждает, что мы создали 100_000 объектов на хипе под эти замыкания.
Понятно, что Action<T>
это объект на хипе.
Но из статьи у меня сложилось впечатление, что под переменную j сделается отдельный объект, на который в замыкании будет вести ссылка.
Если мы так сделаем:
var j = i;
var action1 = () => Console.WriteLine($"{j}");
var action2 = () => Console.WriteLine($"{j}");
Объекты action1 и action2 — разные объекты на хипе. Но они оба должны видеть одну и ту же переменную j, и значит, под неё будет ещё одна аллокация?
Да, под переменную j будет ещё одна аллокация.
В моём примере ровно на неё и сделан акцент, посмотрите внимательнее на скриншот. Я обвёл красным не тип System.Action
, а именно <>c__DisplayClass2_0
. Этот тип - и есть аллокации того самого "j".
Точно, невнимательно прочитал. Я было подумал, что аллокаций в сумме 100к, и компилятор смог оптимизировать, положив и Action, и захваченную переменную в одну аллокацию.
А если захватывается 10 переменных, то аллокаций, получается, будет по числу переменных. Тоже хороший задел для будущих оптимизаций.
Конечно, отдельного рассказа стоит рассмотрение различных ситуаций, когда в методе присутствует создания нескольких Action'ов (Func'ов) с замыканиями на различные наборы аргументов (пересекающихся и\или не пересекающихся). Там ух как весело!
Можно продемонстрировать это еще нагляднее с помощью Sharplab:
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++;
}
}
}
А могли бы просто серверов добавить. Я слышал нынче все так делают.
Мне пришлось отклонить некоторые провокационные комментарии к вашему сообщению, поэтому спрошу за них - это же была ирония? :)
А абстрактно в вакууме - очевидно, что ко всем оптимизациям надо подходить рационально. Взвешивать стоимость работы инженера и стоимость потребления ресурсов. В жизни бывают ситуации, когда побеждает и тот и другой вариант.
А просто знать, как работают замыкания, полезно всегда.
[del]
Добавлю, что разработчики дотнета знают о подобной проблеме, поэтому, например, у ConcurrentDictionary (который довольно часто выступает в роли in-memory кэша) есть методы
TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> valueFactory, TArg factoryArgument)
Это позволяет не захватить в замыкание не только ключ, но и дополнительный аргумент, если это нужно.
Также в языке начиная с C# 9 существуют Static anonymous functions. Позволяет на уровне компиляции проверять, что лямбда не захватывает ничего изменяющегося.
Ещё один способ избежать преждевременной аллокации объекта для замыкания — аллоцировать его самому в нужное время:
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) => …;
}
Если в первом примере заменить record class
на record struct
, код тоже компилируется. Но scope лежит на стеке. Что произойдёт, если task начнёт работу после выхода из функции FindQuestion?
Но любой кодер-фронтовик (да и задовики многие, которым надо JSONы перекладывать, много и разные), посмотревши на него, скажет:
— Это ж сколько бойлерплейта писать-то? Проще серверов добавить…
Нет, поскольку даже после встраивания области видимости переменных останутся прежними.
Там, на самом деле, достаточно просто новую переменную объявить и фигурные скобки поставить, чтобы лишняя аллокация пропала:
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.
Спасибо сразу за несколько дополнительных вариантов, как можно избежать преждевременной аллокации объекта для замыкания. И за хорошее дополнение про области видимости в контексте этой оптимизационной "задачки".
Ваши комментарии отлично дополняют статью.
Скорее всего нет. Ишью давно висит. Там ответили что лучше не трогать а кому надо тот знает.
https://github.com/dotnet/roslyn/issues/20777#issuecomment-1379582634
Оценил напряженность сюжет с многими линяими и внезапными поворотами и переходами с линии на линию. Но ничего не понял, пока не прочитал комментарии — где все изложено по-простому, без сюжета. Может, в следующий раз ну его, сюжет?
Пародия на замыкания