Как стать автором
Обновить

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

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


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

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

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

В этом простом примере создается 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 переменных, то аллокаций, получается, будет по числу переменных. Тоже хороший задел для будущих оптимизаций.

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

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

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

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

Можно продемонстрировать это еще нагляднее с помощью 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++;
        }
    }
}

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

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

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

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

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

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

Добавлю, что разработчики дотнета знают о подобной проблеме, поэтому, например, у 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?

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


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

Ну, способ-то — он способ…
Но любой кодер-фронтовик (да и задовики многие, которым надо 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.

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

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

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий