Почему это лямбда-закрытие генерирует мусор, хотя оно не выполняется во время выполнения?

Я заметил, что следующий код генерирует выделение кучи, которое в какой-то момент запускает сборщик мусора, и я хотел бы знать, почему это так и как этого избежать:

private Dictionary<Type, Action> actionTable = new Dictionary<Type, Action>();

private void Update(int num)
{
    Action action;
//  if (!actionTable.TryGetValue(typeof(int), out action))
    if (false)
    {
        action = () => Debug.Log(num);
        actionTable.Add(typeof(int), action);
    }
    action?.Invoke();
}

Я понимаю, что использование лямбды, такой как () => Debug.Log(num), создаст небольшой вспомогательный класс (например, ‹>c__DisplayClass7_0) для хранения локальной переменной. Вот почему я хотел проверить, могу ли я кэшировать это распределение в словаре. Однако я заметил, что вызов Update приводит к выделению памяти, даже если лямбда-код никогда не достигается из-за оператора if. Когда я комментирую лямбду, выделение исчезает из профилировщика. Я использую инструмент Unity Profiler (инструмент для создания отчетов о производительности в игровом движке Unity), который показывает такие распределения в байтах на кадр в режиме разработки/отладки.

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

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

Отказ от ответственности: это в основном теоретический вопрос из интереса. Я понимаю, что большинство приложений не выиграют от такой микрооптимизации.


person Xarbrough    schedule 11.12.2018    source источник
comment
Я не знаком с термином выделения сборщика мусора. Есть выделение кучи и сборка мусора. Вы имели в виду что-то из этого или что-то другое? Как именно вы определяете, что что-то выделено в вашем коде (то есть, какой инструмент вы используете и что вы видите)?   -  person John Wu    schedule 12.12.2018
comment
У вас отключены оптимизации? Я удивлен, что есть какой-либо код, связанный с лямбдой, поскольку он находится под if(false), который будет полностью удален оптимизатором. Если вы запускаете это с выключенным оптимизатором, вы не должны ожидать оптимизированного кода, когда отключите оптимизатор!   -  person Eric Lippert    schedule 12.12.2018
comment
Хорошая мысль об оптимизаторе, но я разместил закомментированную строку, потому что хотел показать, что в моем реальном коде я использую словарь, который модифицируется во время выполнения, и, следовательно, я предполагаю, что оператор if все равно не будет оптимизирован. . У меня сейчас нет инструментов для проверки этих конкретных выделений в режиме выпуска, хотя было бы интересно проверить.   -  person Xarbrough    schedule 12.12.2018
comment
См. также stackoverflow.com/q/3885106/18192.   -  person Brian    schedule 12.12.2018


Ответы (2)


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

Рассмотрим случай, когда существует более одного анонимного метода с замыканием в одном и том же методе (достаточно частое явление). Вы хотите создать новый экземпляр для каждого или просто использовать один экземпляр? Они пошли с последним. У любого подхода есть преимущества и недостатки.

Наконец, есть ли способ кэшировать делегаты таким образом, не выделяя и не заставляя вызывающий код кэшировать действие заранее?

Просто переместите этот анонимный метод в отдельный метод, чтобы при вызове этого метода анонимный метод создавался безоговорочно.

private void Update(int num)
{
    Action action = null;
    //  if (!actionTable.TryGetValue(typeof(int), out action))
    if (false)
    {
        Action CreateAction()
        {
            return () => Debug.Log(num);
        }
        action = CreateAction();
        actionTable.Add(typeof(int), action);
    }
    action?.Invoke();
}

(Я не проверял, произошло ли выделение для вложенного метода. Если это так, сделайте его невложенным методом и передайте int.)

person Servy    schedule 11.12.2018

Ответ Серви правильный и дает хороший обходной путь. Я подумал, что могу добавить еще несколько деталей.

Прежде всего: варианты реализации компилятора C# могут быть изменены в любое время и по любой причине; ничего из того, что я здесь говорю, не является требованием языка, и вы не должны зависеть от этого.

Если у вас есть закрытая внешняя переменная лямбды, то все закрытые переменные превращаются в поля класса закрытия, и этот класс закрытия выделяется из долгосрочного пула («кучи»), как только функция активирован. Это происходит независимо от того, считывается ли когда-либо класс замыкания.

Команда компилятора могла бы отложить создание класса замыкания до первой точки, где он использовался: когда локальный объект был прочитан или записан или был создан делегат. Однако тогда это добавит дополнительную сложность методу! Это делает метод больше, делает его медленнее, повышает вероятность промаха кэша, усложняет работу джиттера, создает больше базовых блоков, поэтому джиттер может пропускать оптимизацию и так далее. Эта оптимизация, скорее всего, не окупится.

Однако команда компилятора делает аналогичную оптимизацию в тех случаях, когда она с большей вероятностью окупится. Два примера:

  • С вероятностью 99,99 % для блока итератора (метод с yield return в нем) будет то, что IEnumerable вызовет GetEnumerator ровно один раз. Таким образом, сгенерированное перечисляемое имеет логику, которая реализует как IEnumerable, так и IEnumerator; при первом вызове GetEnumerator объект преобразуется в IEnumerator и возвращается. Во второй раз мы выделяем второй счетчик. Это сохраняет один объект в наиболее вероятном сценарии, а сгенерированный дополнительный код довольно прост и редко вызывается.
  • Для методов async характерно иметь «быстрый путь», который возвращает без ожидания — например, у вас может быть дорогостоящий асинхронный вызов в первый раз, а затем результат кэшируется и возвращается во второй раз. Компилятор C# генерирует код, который избегает создания замыкания "конечного автомата" до тех пор, пока не встретится первый await, и, следовательно, предотвращает выделение на быстром пути, если таковое имеется.

Эти оптимизации, как правило, окупаются, но в 99% случаев, когда у вас есть метод, выполняющий замыкание, он действительно выполняет замыкание. Откладывать действительно не стоит.

person Eric Lippert    schedule 11.12.2018