Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности

в 4:55, , рубрики: .net, .net core, C#, dynamic, memory leak, optimization, Программирование

Прелюдия

Рассмотрим следующий код:

//Any native COM object
var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601"));

while (true)
{
    dynamic com = Activator.CreateInstance(comType);

    //do some work

    Marshal.FinalReleaseComObject(com);
}

Сигнатура метода Marshal.FinalReleaseComObject выглядит следующим образом:

public static int FinalReleaseComObject(Object o)

Создаем простой COM-объект, выполняем какую-то работу и тут же его освобождаем. Казалось бы, что может пойти не так? Да, создание объекта внутри бесконечного цикла — не очень хорошая практика, но GC возьмет на себя всю грязную работу. Реальность оказывается несколько иной:

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 1

Чтобы понять, почему утекает память, нужно разобраться в том, как работает dynamic. На Хабре уже есть несколько статей на эту тему, например эта, но они не углубляются в детали реализации, так что проведем собственное исследование.

Сначала мы детально рассмотрим механизм работы dynamic, затем сведем полученные знания в единую картину и в конце порассуждаем о причинах этой утечки и о том, как её избежать. Прежде чем нырять в код, давайте уточним исходные данные: какая именно комбинация факторов приводит к утечке?

Эксперименты

Возможно, создавать множество native COM объектов это сама по себе плохая идея? Давайте проверим:


//Any native COM object
var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601"));

while (true)
{
    dynamic com = Activator.CreateInstance(comType);
}

В этот раз все хорошо:

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 2

Вернемся к исходному варианту кода, но поменяем тип объекта:

//Any managed type include managed COM
var type = typeof(int);

while (true)
{
    dynamic com = Activator.CreateInstance(type);
    //do some work
    Marshal.FinalReleaseComObject(com);
}

И снова никаких неожиданностей:

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 3

Попробуем третий вариант:

//Simple COM object
var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95"));

while (true)
{
    dynamic com = Activator.CreateInstance(comType);
    //do some work
    Marshal.FinalReleaseComObject((object) com);
}

Ну сейчас-то мы точно должны получить такое же поведение! Да? Нет :(

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 4

Аналогичная картина будет, если объявить com как object или если работать с Managed COM. Обобщим результаты экспериментов:

  1. Инстанциирование native COM объектов само по себе не приводит к утечкам, — GC успешно справляется с очисткой памяти
  2. При работе с любым Managed классом утечек не возникает
  3. При явном приведении объекта к object тоже все хорошо

Забегая вперед, к первому пункту можно добавить тот факт, что работа с dynamic объектами (вызов методов или работа со свойствами) сама по себе тоже не вызывает утечек. Вывод напрашивается такой: утечка памяти возникает когда мы передаем dynamic объект (без «ручного» приведения типов), содержащий в себе native COM, как параметр метода.

We Need To Go Deeper

Самое время вспомнить, что вообще такое этот dynamic:

Краткая справка

C# 4.0 предоставляет новый тип dynamic. Этот тип избегает статической проверки типов компилятором. В большинстве случаев, он работает как тип object. Во время компиляции предполагается, что элемент, объявленный как dynamic, поддерживает любые операции. Это означает, что вам нет необходимости задумываться о том, откуда объект берет свои значения — из COM API, динамического языка вроде IronPython, при помощи рефлексии или откуда-нибудь ещё. При этом, если код невалидный, ошибки будут выбрасываться в рантайме.

Например, если метод exampleMethod1 в нижеследующем коде имеет ровно один параметр, компилятор распознает, что первый вызов метода ec.exampleMethod1(10, 4) невалиден, потому что он содержит два параметра. Это приведет к ошибке компиляции. Второй вызов метода, dynamic_ec.exampleMethod1(10, 4) не проверяется компилятором, поскольку dynamic_ec объявлен как dynamic, следовательно. ошибок компиляции не будет. Тем не менее, ошибка не останется незамеченной навсегда — она будет обнаружена в рантайме.

static void Main(string[] args)
{
    ExampleClass ec = new ExampleClass();
    // Этот вызов приведет к ошибке компиляции, если exampleMethod1 имеет только один параметр. 
    //ec.exampleMethod1(10, 4);

    dynamic dynamic_ec = new ExampleClass();
    // Этот вызов не вызовет ошибку компиляции, но 
    // приведет к исключению в рантайме
    dynamic_ec.exampleMethod1(10, 4);

    // Эти вызовы тоже не приведут к ошибкам компиляции, независимо 
    // от того, существуют такие методы или нет
    dynamic_ec.someMethod("some argument", 7, null);
    dynamic_ec.nonexistentMethod();
}
class ExampleClass
{
    public ExampleClass() { }
    public ExampleClass(int v) { }

    public void exampleMethod1(int i) { }

    public void exampleMethod2(string str) { }
}

Код, использующий dynamic переменные, при компиляции претерпевает значительные изменения. Этот код:

dynamic com = Activator.CreateInstance(comType);
Marshal.FinalReleaseComObject(com);

Превращается в следующий:

object instance = Activator.CreateInstance(typeFromClsid);

// ISSUE: reference to a compiler-generated field
if (Foo.o__0.p__0 == null)
{
    // ISSUE: reference to a compiler-generated field
    Foo.o__0.p__0 = CallSite<Action<CallSite, Type, object>>.Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
    {
        CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null),
        CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null)
    }));
}

// ISSUE: reference to a compiler-generated field
// ISSUE: reference to a compiler-generated field
Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance);

Где o__0 — сгенерированный статический класс, а p__0 — статическое поле в нём:

private class o__0 {
    public static CallSite<Action<CallSite, Type, object>> p__0;
}

Note: для каждого взаимодействия с dynamic создается свое CallSite поле. Это, как будет видно дальше, необходимо для оптимизации производительности

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

Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2]
{
    CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null),
    CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null)
})

Это описание нашей динамической операции. Напомню, что мы передаем dynamic переменную в метод FinalReleaseComObject.

  • CSharpBinderFlags.ResultDiscarded — результат выполнения метода не используется в дальнейшем
  • «FinalReleaseComObject» — имя вызываемого метода
  • typeof (Foo) — контекст операции; тип, из которого происходит вызов

CSharpArgumentInfo — описание параметров байндинга. В нашем случае:

  • CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null) — описание первого параметра — класса Marshal: он является статичным и его тип должен учитываться при байндинге
  • CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null) — описание параметра метода, здесь обычно нет дополнительной информации.

Если бы речь шла не о вызове метода, а о, например, вызове свойства от dynamic объекта, то был бы только один CSharpArgumentInfo, описывающий сам dynamic объект.

CallSite — это обертка над динамическим выражением. Он содержит два важных для нас поля:

  • public T Update
  • public T Target

Из сгенерированного кода видно, что при выполеннии любой операции вызывается Target с параметрами, её описывающими:

Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance);

В связке с описанными выше CSharpArgumentInfo этот код означает следующее: нужно вызвать метод FinalReleaseComObject у статического класса Marshal с параметром instance. В момент первого вызова в Target хранится такой же делегат, как и в Update. Делегат Update отвечает за две важных задачи:

  1. Байндинг динамической операции к статической (сам механизм байдинга выходит за рамки этой статьи)
  2. Формирование кэша

Нас интересует второй пункт. Здесь нужно отметить, что при работе с dynamic объектом перед нами встает необходимость каждый раз проверять допустимость операции. Это довольно ресурсоемкая задача, поэтому хочется кэшировать результаты таких проверок. Применительно к вызову метода с параметром нам нужно запомнить следующее:

  1. Тип, у которого вызывается метод
  2. Тип объекта, который передается параметром (чтобы быть уверенным в том, что его можно привести к типу параметра)
  3. Валидна ли операция

Тогда при повторном вызове Target нам не нужно проводить относительно дорогостоящий байндинг: достаточно сравнить типы и, если они совпадают, вызвать целевую функцию. Чтобы решить эту задачу, на каждую динамическую операцию создается ExpressionTree, в котором хранятся ограничения и целевая функция, к которой было привязано динамическое выражение.

Эта функция может быть двух видов:

  • Binding error: например, у dynamic объекта вызывается метод, которого не существует или dynamic объект невозможно привести к типу параметра, которым его передают: тогда нужно выбросить исключение вида Microsoft.CSharp.RuntimeBinderException: 'NoSuchMember'
  • Вызов легален: тогда просто выполнить требуемое действие

Этот ExpressionTree формируется при выполнении делегата Update и сохраняется в Target. Target L0 кэш, подробнее о кэше поговорим позже.

Итак, в Target хранится последний ExpressionTree, сгенерированный через делегат Update. Давайте посмотрим, как это правило выглядит на примере Managed типа, передаваемого в метод Boo:

public class Foo
    {
        public void Test()
        {
            var type = typeof(int);

            dynamic instance = Activator.CreateInstance(type);
            Boo(instance);
        }

        public void Boo(object o) { }
    }
.Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>(
    Actionsss.CallSite $$site,
    ConsoleApp12.Foo $$arg0,
    System.Object $$arg1) {
    .Block() {
        .If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32) {
            .Return #Label1 { .Block() {
                .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1));
                .Default(System.Object)
            } }
        } .Else {
            .Default(System.Void)
        };
        .Block() {
            .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso ($arg1 TypeEqual Int32)), returnUnamedLabel_0 ({ ... }) , default(Void)));
            .Label
            .LabelTarget CallSiteBinder.UpdateLabel:
        };
        .Label
            .If (
                .Call Actionsss.CallSiteOps.SetNotMatched($$site)
            ) {
                .Default(System.Void)
            } .Else {
                .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)(
                    $$site,
                    $$arg0,
                    $$arg1)
            }
        .LabelTarget #Label1:
    }
}

Самый важный для нас блок:

.If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32)

$$arg0 и $$arg1 — параметры, с которыми вызывается Target:


Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>);

В переводе на человеческий, это означает следующее:

Мы уже проверили, что если первый параметр принадлежит типу Foo, а второй параметр Int32, значит можно смело вызывать Boo((object) $$arg1).

.Return #Label1 { .Block() {
               .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1));
                .Default(System.Object)
            }

Note: в случае ошибки байндинга, блок Label1 выглядит примерно так:

.Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember")

Эти проверки называются ограничениями. Ограничения бывают двух типов: по типу объекта и по конкретному инстансу объекта (объект должен быть ровно тем же самым). Если хотя бы одно из ограничений провалится, нам придется заново проверять динамическое выражение на валидность, для этого вызовем делегат Update. Он по уже известной нам схеме проведет байндинг с новыми типами и сохранит в Target новый ExpressionTree.

Кэш

Мы уже выяснили, что Target — это L0 кэш. Каждый раз, когда Target будет вызван, первым делом мы пройдемся по ограничениям, уже в нем хранящимся. Если ограничения провалятся и будет сгенерирован новый байндинг, то старое правило уходит одновременно в L1 и L2. В дальнейшем, при промахе по L0 кэшу, будут перебираться правила из L1 и L2 до тех пор, пока не найдется подходящее.

  • L1: последние десять правил, ушедших из L0 (хранятся непосредственно в CallSite)
  • L2: последние 128 правил, созданных при помощи конкретного инстанса байндера (который CallSiteBinder, уникальный для каждого CallSite)

Теперь мы наконец можем сложить эти детали в единое целое и в виде алгоритма описать что происходит при вызове Foo.Bar(someDynamicObject):

1. Создается байндер, запоминающий контекст и вызываемый метод на уровне их сигнатур

2. При первом вызове операции создается ExpressionTree, в котором хранятся:
2.1 Ограничения. В данном случае это будут два ограничения по типу текущих параметров байндинга
2.2 Целевая функция: либо throw some exception (в данном случае невозможно, так как любой dynamic будет успешно приведет к object) либо вызов метода Bar

3. Компилируем и исполняем получившийся ExpressionTree

4. При повторном вызове операции возможны два варианта:
4.1 Ограничения сработали: просто вызываем Bar
4.2 Ограничения не сработали: повторить пункт 2 для новых параметров байндинга

Итак, на примере Managed типа стало примерно понятно, как dynamic работает изнутри. В описанном кейсе мы никогда не будем промахиваться по кэшу, так как типы всегда одни и те же*, следовательно Update вызовется ровно один раз при инициализации CallSite. Затем для каждого вызова будут только проверяться ограничения и сразу вызываться целевая функция. Это отлично согласуется с нашими наблюдениями за памятью: нет вычислений — нет утечек.

*Именно для этого компилятор на каждый чих генерирует свои CallSite'ы: предельно снижается вероятность промаха по L0 кэшу

Самое время выяснить, чем эта схема отличается в случае native COM объектов. Давайте посмотрим на ExpressionTree:

.Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>(
    Actionsss.CallSite $$site,
    ConsoleApp12.Foo $$arg0,
    System.Object $$arg1) {
    .Block() {
        .If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) {
            $var1 = .Constant<System.WeakReference>(System.WeakReference).Target;
            $var1 != null && (System.Object)$$arg1 == $var1
        }) {
            .Return #Label1 { .Block() {
                .Call $$arg0.Boo((System.__ComObject)$$arg1);
                .Default(System.Object)
            } }
        } .Else {
            .Default(System.Void)
        };
        .Block() {
            .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso {var Param_0; ... }), returnUnamedLabel_1 ({ ... }) , default(Void)));
            .Label
            .LabelTarget CallSiteBinder.UpdateLabel:
        };
        .Label
            .If (
                .Call Actionsss.CallSiteOps.SetNotMatched($$site)
            ) {
                .Default(System.Void)
            } .Else {
                .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)(
                    $$site,
                    $$arg0,
                    $$arg1)
            }
        .LabelTarget #Label1:
    }
}

Видно, что разница только во втором ограничении:

.If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) {
            $var1 = .Constant<System.WeakReference>(System.WeakReference).Target;
            $var1 != null && (System.Object)$$arg1 == $var1
        })

Если в случае с Managed кодом у нас было два ограничения по типу объектов, то здесь мы видим, что второе ограничение проверяет эквивалентность инстансов через WeakReference.

Note: ограничение по инстансу помимо COM-объектов используется ещё и для TransparentProxy

На практике, исходя из наших знаний о работе кэша, это означает что каждый раз, когда мы в цикле пересоздаем COM объект, мы будем промахиваться по L0 кэшу (и по L1/L2 тоже, т.к. там будут храниться старые правила с ссылками на старые инстансы). Первое предположение, которое просится в голову: течет кэш правил. Но код там довольно простой и там все хорошо: старые правила удаляются корректно. В то же время использование WeakReference в ExpressionTree не блокирует GC от сбора ненужных объектов.

Механизм сохранения правил в L1 кэш:

const int MaxRules = 10;
        internal void AddRule(T newRule) {
            T[] rules = Rules;
            if (rules == null) {
                Rules = new[] { newRule };
                return;
            }

            T[] temp;
            if (rules.Length < (MaxRules - 1)) {
                temp = new T[rules.Length + 1];
                Array.Copy(rules, 0, temp, 1, rules.Length);
            } else {
                temp = new T[MaxRules];
                Array.Copy(rules, 0, temp, 1, MaxRules - 1);
            }
            temp[0] = newRule;
            Rules = temp;
        }

Так в чем же всё-таки дело? Попробуем уточнить гипотезу: утечка памяти происходит где-то при байндинге COM объекта.

Эксперименты, часть 2

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

//Simple COM object
var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95"));

var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null,
        typeof(Foo), new CSharpArgumentInfo[2]
        {
            CSharpArgumentInfo.Create(
                CSharpArgumentInfoFlags.UseCompileTimeType,
                null),
            CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
        });

    var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder);

while (true)
{
    object instance = Activator.CreateInstance(comType);  
    callSite.Target(callSite, this, instance);
}

Проверяем:

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 5

Утечка сохранилась. Справедливо. Но в чем всё-таки причина? После изучения кода байндеров (которое мы оставим за скобками) видно, что единственное, на что влияет тип нашего объекта — вариант ограничения. Возможно, дело не в COM объектах, а в байндере? Выбора особо нет, давайте спровоцируем множественный байндинг для Managed типа:

while (true)
            {
                object instance = Activator.CreateInstance(typeof(int));

                var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null,
                    typeof(Foo), new CSharpArgumentInfo[2]
                    {
                        CSharpArgumentInfo.Create(
                            CSharpArgumentInfoFlags.UseCompileTimeType, null),
                        CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
                    });

                var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder);

                callSite.Target(callSite, this, instance);
            }

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 6

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

Выводы

Утечка памяти

Если вы работаете с dynamic переменными, которые содержат в себе native COM или TransparentProxy — никогда не передавайте их в качестве параметров методов. Если вам все-таки нужно это сделать, используйте явное приведение к object и тогда компилятор от вас отстанет

Неправильно:

dynamic com = Activator.CreateInstance(comType);
//do some work
Marshal.FinalReleaseComObject(com);

Правильно:

dynamic com = Activator.CreateInstance(comType);
//do some work
Marshal.FinalReleaseComObject((object) com);

В качестве дополнительной предосторожности старайтесь как можно реже инстанциировать такие объекты. Актуально для всех версий .NET Framework. (Пока) не очень актуально для .NET Core, так как там нет поддержки dynamic COM объектов.

Производительность

В ваших интересах чтобы промахи по кэшу случались как можно реже, так как в этом случае не возникает необходимости в поиске подходящего правила в высокоуровневых кэшах. Промахи по L0 кэшу будут возникать в основном в случае несовпадения типа dynamic объекта с сохраненными ограничениями.

dynamic com = GetSomeObject();

public object GetSomeObject() {
    //правильно: возвращать объект только одного типа
    //неправильно: если этот метод будет возвращать объекты нескольких типов
}

Впрочем, разницу в производительности на практике вы скорее всего не заметите, если только количество вызовов этой функции не будет измеряться миллионами или если вариативность типов не будет необычно большой. Затраты в случае промаха по L0 кэшу такие, N — количество типов:

  • N<10. При промахе только перебор существующих правил L1 кэша
  • 10<N<128. Перебор L1 и L2 кэша (максимум 10 и N итераций). Создание и заполнение массива из 10 элементов
  • N>128. Перебор L1 и L2 кэша. Создание и заполнение массивов из 10 и 128 элементов. При промахе по L2 кэшу повторный байндинг

Во втором и третьем случае ещё увеличится нагрузка на GC.

Заключение

К сожалению, мы так и не нашли настояющую причину утечки памяти, для этого потребуется отдельное исследование байндера. К счастью, WinDbg дарит подсказку для дальнейшего расследования: что-то плохое происходит в DLR. Первый столбик — количество объектов

Подробно о dynamic: подковерные игры компилятора, утечка памяти, нюансы производительности - 7

Бонус

Почему явное приведение к object позволяет избежать утечки?
Любой тип возможно привести к object, так что операция перестает быть динамической.

Почему нет утечек при работе с полями и методами COM объекта?
Вот так выглядит ExpressionTree для обращения по полю:

.If (
            .Call System.Dynamic.ComObject.IsComObject($$arg0)
        ) {
            .Return #Label1 { .Dynamic GetMember ComMarks(.Call System.Dynamic2.ComObject.ObjectToComObject($$arg0)) }
        }

Автор: Alexey Plokhikh

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js