Skip to content

Instantly share code, notes, and snippets.

@eterekhin
Last active May 3, 2020 10:57
Show Gist options
  • Save eterekhin/802b8134a9270221eb1b9fdeab808dfd to your computer and use it in GitHub Desktop.
Save eterekhin/802b8134a9270221eb1b9fdeab808dfd to your computer and use it in GitHub Desktop.

Как реализована поддержка dynamic

в .net есть поддержка во время компиляции и исполнения. Во время компиляции на месте dynamic вызовов генерируется создания CallSite экземпляров, пример:

Создается singleton, который переиспользуется при последующих вызовах. В автосгенерированном коде происходит вызов CallSite.Create, единсвенным параметром передается CallSiteBinder. Он может быть разным взависимости от обращения с dynamic объектом, в нашем случае это CSharpInvokeMemberBinder.

Также создается массив CSharpArgumentInfo, аргументы которые используются в динамической операции, у нас их три, первый this, второй константа (CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.Constant), тип разрешен на этапе компиляции. Третий параметр Name равен null. Чтобы он был не null, нужно воспользоваться именованными параметрами.

Получается, что компилятор собирает все необходимую, для дальнейшей работы информацию и сохраняет ее в CallSite.

Посмотрим, как преобразуется код в разных случаях использования dynamic. Можно увидеть создание CSharpConvertBinder, CSharpBinaryOperationBinder, CSharpGetMemberBinder. Главное запомнить, что цель на этапе компиляции, передать как можно больше информации Binder'у.

В приведенном выше скриншоте в конце вызывается метод Target. Это func, который был скомпилирован из Expression. Он выглядит примерно так:

// Expression<Action<CallSite,object,int,int>>
// проверяем что аргументы удовлетворяют restrictions
if($var1 TypeEqual typeof(CreatedForDynamicTestsType) && var2 TypeEqual Int && $var2 == 1 && $var3 TypeEqual Int && var3 == 2)
{
  // возводим флаг о том, что кэш сработал
  matched = true;
  // возвращаем уже созданный делегат
  return cachedDelegate;
}
else{
   // если нет, вызываем свойство Update
   $callsite.Update()
}

Вот реальный Expression построенный DLR:

А вот как выглядит CallSite(выделено важное):

Если мы несколько раз вызываем одно и то же, то будет заходить в первый if и отрабатывать быстро, если нет то будет вызываться делегат Update, который обновляет Target, подставляя в тело ветки if возврат нового делегата, либо генерирует exception

Прежде чем спуститься ниже, посмотрим на устройство DynamicMetaObject, потому что этот класс активно используется внутри dynamic.

DynamicMetaObject - класс, позволяющий управлять Binding'ом, можно у него 12 методов binding'a:

  1. BindConvert. (int) dynamicInstance;
  2. BindGetMember. dynamicInstance.Name
  3. BindSetMember. dynamicInstance.Name = "new"
  4. BindDeleteMember. Не поддерживается в языках со статической типизацией. Можно представить себе удаление поля в объекте
  5. BindGetIndex dynamicInstance[0]
  6. BindSetIndex dynamicInstance[0] = 1;
  7. BindDeleteIndex. Не поддерживается в языках со стат. типизацией. Можно представить себе удаления элемента массива по индексу
  8. BindInvokeMember dynamicInstance.Method()
  9. BindInvoke dynamicInstance()
  10. BindCreateInstance new dynamicInstance()
  11. BindUnaryOperation (-dynamicInstance)
  12. BindBinaryOperation. dynamicInstance * 4444444

Все эти операции это virtual методы в DynamicMetaObject. Пример метода BindSetMember:

В реализации есть две интересные подробности. Первая, вторым аргументом метода приходит аргумент типа DynamicMetaObject, который в свою очередь может скрывать за собой, что угодно. Вторая, первым параметром прмходит SetMemberBinder. Это класс, инкапсулирующий правила изменения значения свойства. Остальные 11 методов также получают первым параметром Binder, и вызывают его Fallback метод. Это сделано для того, чтобы можно было переопределить поведение какого-нибудь метода.

Кроме этого DynamicMetaObject содержит свойство Expression, дерево выражений, описывающее какую-нибудь операцию. Например:

// Provider - реализует интерф IDynamicMetaObjectProvider

public class Provider : IDynamicMetaObjectProvider
{
    public DynamicMetaObject GetMetaObject(Expression parameter)
    {
      return new DynamicObject(parameter, BindingRestrictions.Empty, this);
    }

    public void Sos() => Console.WriteLine("Sos");
}


dynamic d = new Provider();
var t = d+2;

public class DynamicObject : DynamicMetaObject
{

public override DynamicMetaObject BindBinaryOperation(BinaryOperationBinder binder, DynamicMetaObject arg)
{
    // ограничение, что первый аргумент (аргумент d), должен быть типа Provider
    // второй типа int
    var restrictions = Restrictions
        .Merge(arg.Restrictions)
        .Merge(BindingRestrictions.GetTypeRestriction(Expression, typeof(Provider)))
        .Merge(BindingRestrictions.GetTypeRestriction(arg.Expression, typeof(int)));
    
    // дерево выражений можно представить так: arg + arg.Value
    var expression = Expression.TypeAs(Expression.Add(
        arg.Expression,
        Expression.Constant(arg.Value)), typeof(object));

    return new DynamicMetaObject(expression, restrictions);
}
}
//

В примере, мы создаем экземпляр класса Provider, который заставляет dynamic использовать DynamicObject для binding'aa(как увидим позже) и в самом DynamicObject метод BindBinaryOperation.

Теперь спустимся на уровень ниже и посмотрим как происходит обновление делегата Target:

  1. Вызывается метод CallSiteBinder'a (который создался в месте вызова dynamic кода) BindCore(CallSite site, object[] args). args - это те самые аргументы, которые были переданы в метод Target. Они оборачиваются в DynamicMetaObject:

В данном случае Expression parameter, имеет тип ParameterExpression :). И имя arg{порядковый номер}. Самый первый параметр типа CreatedForDynamicTestsType. В скрине важно заметить, что мы если аргумент реализует IDynamicMetaObjectProvider - будет использована его реализация, иначе создан экземпляр DynamicMetaObject.

  1. У первого аргумента(будет называть его receiver), взависимости от типа dynamic операции вызывается binding-метод. В нашем случае будет вызван метод BindInvokeMember, которому передан InvokeMemberBinder:

  2. DynamicMetaObject вызовет переданный InvokeBinder

  3. В методе FallbackInvoke будет вызван BinderHelper.Bind

  1. Метод BinderHelper.Bind делает все сложную работу по формировании нового DynamicMetaObject.

На самом деле интересно, почему в данном случае так жестко кешируются значения, т.е генерируется expression : obj => obj.Sum(1,2), а не (obj,a,b) => obj.Sum(a,b); (Почему?)

  1. MetaObject из Binder'a BinderHelper.Bind возвращается в метод BindCore.

Обратим особое внимание на метод CacheTarget(newRule), в который передается только что созданный делегат. В CallSit'e есть массив состоящий из 10 элементов, туда сохраняются скомпилированные лябмды.

Это тело метода update в виде Expression Tree :

Все эти делегаты также написаны в C# коде в классе System.Dynamic.UpdateDelegates

Так происходит обновление Target'a

А что если наш объект не из C#? Тогда он должен реализовывать DynamicMetaObjectProvider, чтобы Binder использовал его реализацию для построения Expression. DynamicMetaObjectProvider должен возвращать DynamicMetaObject, который не переадресует создание expression'a обратно binder'у, а создает его самостоятельно. Скорее всего ему придется вызывать Api другого языка по сериализовывать и десериализовывать результаты, чтобы представить в c# виде.

Как DLR кеширует запросы

Кэш используемый DLR называется Polimorphic Inline Cache. Он трехуровневый, L0, L1, L2:

L0 - это сам Target, а именно Restrictions, которые были построены при binding'e.:

if(arg1 TypeEqual ...){
    Return cachedDelegate()
}

L1 - Это поиск в массиве Rules

L2 - это кэш на уровне байндера, при создании каждого binder'a, он добавляется в кэш, если еще не был добавлен, либо из кэша забирается уже существующий. Сам кэш выглядит так:

У каждого binder'a GetHashCode учитывает context. context это тип, чаще всего тип класса в котором вызывается метод. Но иногда нет:

Каждый binder по-разному реализует GetHashCode, некоторые строго учитывают, чтобы все имена и флаги аргументов были равными, некоторые нет, это зависит от операции binder'a. Почувствовать применения кэша можно если понять, что вызовы методов в методах одного класса, будут использовать один и тот же CSharpInvokeMemberBinder.

А у самого binder'a есть свой кэш:

Ключ - это тип T, у CallSite, а значение это RuleCache. Пример, когда это полезно:

Для Sum2 и Sum3 будут сгенерированы абсолютно одинаковые Binder'ы, поэтому будет создан только один, а второй взят из binderEquivalenceCache. Но в Cache созданного CsharpBinaryOperationBinder, будет лежать две записи с ключами Func<CallSite, object, int, object>, Func<CallSite, object, char, object>.

Теперь нужно рассмотреть как происходит обновление кэша. Искомый элемент в каждом из кэшей ищется так: Создается пустой CallSite, одно из полей которого, флажок matched, который равен true, при вызове свойства Update этого CallSite, matched проставляется в false. У созданного CallSite'a вызывается метод Target, который может либо вернуть результат (если пройдут все restrictions), либо вызвать Update. После вызова Target'a проверяется флажок matched, если он true, значит Update не был вызван и можно вернуть полученный результат, иначе matched == false и возвращается default значение.

Я напишу по пунктам как работает код, который можно найти в System.Dynamic.UpdateDelegates.

  1. Проверяем L0(Это проверка в Target'e), если все аргументы удовлетворяют restrictions, то вызываем делегат из L0 кэша, он единственный.
  2. Начинаем проверку L1 кэша, там максимум 10 элементов, начинаем с первого, если он подходит, обновляем L0 кэш, сдвигаем найденный в L1 кэше элемент на две позиции вверх, например, если он был четвертым - становится вторым.
  3. Начинаем проверку L2 кэша, в него добавляются все уникальные построенные делегаты c момента создания CallSite'a, до тех пор пока из меньше 128(Если количество элементов в L2 больше 64, то вставка будет происходить на 64 позицию, а элементы с 64 по 128 будут сдвигать вправо, т.е последний 128 элемент будет теряться) . Если нашли искомый элемент, Вставляем этот элемент в начало L0 и L1 кэша и поднимаем на две позиции вверх в L2 кэше.
  4. Не нашли элемент нигде, тогда вызывается CallSiteBinder.BindCore, который обращается к Binder'у (который передан в CallSite). Полученный делегат помещается в L0, L1(в начало), L2 кэши(в конец, или на 64 позицию, если элементов в L2 кэше больше 64).

Такой кэш называется Polimorphic inline cache.
Polimorphic - потому что может обрабатывать делегаты разных типов(Sum2, Sum3)
Inline - потому что формируется на месте вызова динамической функции

Такой кэш будет работать наиболее производительно, если задействовать только L0, L1 кеши, если же случается cache miss, то приходится вызывать 1 + 10 + 128 делегатов. А потом обновлять все кэши

Как устроен ExpandoObject

Сначала разберем какие функции он предоставляет:

dynamic o = new ExpandoObject();
o.name = "my name";
Console.WriteLine(o.name); // my name

//....//
//iterate keys
foreach(var keyName in ((IDictionary<string,object>)o).Keys)
  Console.WriteLine(keyName);

//....//
var obj = new ExpandoObject();
var o = (dynamic) obj;
 
((INotifyPropertyChanged) o).PropertyChanged += (sender, eventArgs) =>
 {
     Console.WriteLine($"Adding new member {eventArgs.PropertyName}");
 };
            
o.name = "my name";
o.surname = "my surname";

// printed on console :
// Adding new member name
// Adding new member surname

Можно представить ExpandoObject как словарь обращение к элементам которого происходит через точку. Рассмотрим как он устроен внутри

Мы обсуждали обращение к динамическим членам, взглянем еще раз во что разворачивается обращение к ExpandoObject'у:

  public void M() {
        dynamic o = new ExpandoObject();
        o.name = "my name";
    }

Binder при строительстве лямбды обращается к o, и пытается скастить к IDynamicMetaObjectProvider, если o не реализует этот интерфейс, то создается дефолтная обертка над типом o (см. пункт Как реализована поддержка dynamic)

Если "o" реализует интерфейс, то вызовем метод GetMetaObject(Expression expr); expr в данном случае - это ParameterExpression типа object(это второй параметр типа object, который придет в callsite). У полученного экземпляра DynamicMetaObject, вызывается метод BindSetMember.Вот в этом методе ExpandoObject перехватыт стандартную реализацию binder'a, когда строится expression, меняющий значение свойства/поля "name".

ExpandoObject в c# сделан потокобезопасным, поэтому большая часть кода это обеспечение thread-safe.

Рассмотрим структуру с помощью debugger'a:

ExpandoObject хранит структуры ExpandoClass и ExpandoData, ExpandoCall содержит перечисление всех добавленных полей/свойств, а ExpandoData - из значения, итератор по ExpandoObject'у сцепляет элементы из обоих массивов вместе:

Если при итерации ExpandoObject был изменен, генерируется InvaldOperationException CollectionModifiedWhileEnumerating.

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

ExpandoData же меняется всегда, за ее изменение отвечает метод ExpandoObject.TrySetValue. Он также довольно интересный. ExpandoObject должен быть потокобезопасным, поэтому рассмотрим такой сценарий:

  1. Мы должны вставить в ExpandoObject свойство "name" // o.name = "my name" // первое обращение к свойству с таким именем;
  2. Мы создаем новый ExpandoClass
  3. Кто-то тоже начинает писать в EO и вставляет свойство "surname" в ЕС и обновляет ЕD
  4. Мы обновляем EC и ED

Тем самым данные запомненные на шаге 3 теряются. Для того чтобы это предотвратить мы вынуждены брать lock.

Теперь сам метод TrySetValue

Все вышеописанные методы встраиваются в Expression Tree, и возвращают DynamicMetaObject с построенным Expression.

Еще интересный момент, что будет если кто-то опередил нас и обновил EO:

Если мы не добавляем значение, а обновляем, то код обновляющий ExpandoPromoteClass, не будет встроен в Expression Tree

Метод, который вызывается при взятии значения какого-нибудь свойства

Я игнорирую параметр upper case потому что мне кажется, что он может быть false, только если используется из другого языка программирования, не из C#. Но я могу ошибаться

Рассмотрим удаление. Его можно сделать так:

            dynamic provider = new ExpandoObject();
            provider.age = "age";
            var view = (IDictionary<string,object>) provider;
            view.Remove("age");

Т.е вызов метода не динамический. Сам код производящий удаление также прост:

Подведем итоги. ExpandoObject, реализует словарик за счет двух массивов EC(ExpandoClass) и ED(ExpandoData), первый массив сохраняет имена свойств, а второй их значения. При попытке обращения к этим "свойствам", в Expression записывается код производящий поиск в массиве EC, узнающий индекс искомого элемента, и возвращающий значение из массива ED по найденному индексу. Все операции добавления/изменения/удаления оборачиваются в lock. Тем самым мы получаем потокобезопасность, вместе с поддержкой ExpandoObject'ом INotifyPropertyChanged это делает его хорошим вариантом использования в UI фреймворках. Вся работа с ExpandoObject со стороны инфраструктуры DLR производится через интерфейс IDynamicMetaObjectProvider, в котором EO возвращает созданный экземпляр MetaExpando (вложенный класс), в котором происходит работа по построению Expression Tree, который должен выызывать методы Expando Object'a. Ну и оборачивать построенный Expression в DynamicMetaObject.

Почему использования dynamic быстрее чем reflection

На самом деле RuntimeBinder, который вызывается из Binder'ов внутри использует reflection, хотя и немного, для получения значений свойства, или параметров метода. Только он это делает один раз и далее компилирует код с использованием Expression Trees. Разница в том, что после построения Expression Tree, вызывается его метод Compile, который строил IL код, а далее компилирует метод, это происходит намного быстрее чем вызов метода Invoke() , у MethodInfo (например). (почему быстрее?). Но главное преимущество, что если вызвать dynamic с теми же параметрами, то он просто выполнил уже закешированный делегат, т.е все расходы происходят при первом вызове
Я подготовил пример:

Dynamic отрабатывает в 170 раз дольше чем прямой вызов метода, но примерно в 25 раз быстрее прямого reflection'a. Это результат на моем конткретном тесте, который (я уверен), не учитывает какие-то особенности исполнения кода

Получается что dynamic можно воспринимать как автоматизированный Expression Tree Builder, который мы используем, если нужно дернуть какой-нибудь метод на горячем пути. Использование DLR в свою очередь дает гибкость, например если происходит вызов метода интерфейса не нужно будет хардкодить в дереве какой именно метод стоит вызвать, можно просто скастить в dynamic и звать.

Что такое CallSite, как он реализован

Класс хранящий L1 кэш, свойства Target и Update. Также он имеет поле mathed, для поиска подходящего делегата в кэше. Наша работа с этим классом, начинается с вызова метода Create:

  public static CallSite<T> Create(CallSiteBinder binder)
        {
            if (!typeof(T).IsSubclassOf(typeof(MulticastDelegate))) throw System.Linq.Expressions.Error.TypeMustBeDerivedFromSystemDelegate();
            ContractUtils.RequiresNotNull(binder, nameof(binder));
            return new CallSite<T>(binder);
        }

Это фабричный метод. Наверное, самый интересный метод в CallSite, это T MakeUpdateDelegate(), который взависимости от типа T (Action<...> или Func<...>) строит дерево выражений, обновляющее значение Target. Если я правильно понял, то все возможные методы делегата Update разобраны в абстрактном классе System.Runtime.CompilerServices.UpdateDelegates

Устройство этого класса CallSite и его роль в DLR описано в первом пункте этого гиста.

Когда dynamic может пригодиться

  1. Вызов методов без доступа к типу объекта. Такое часто встречается при вызове generic методов или методов generic типов
  2. multiple dispatch (привести пример, описать отличие от multiple dispatch и single dispatch, как можно сделать mult dispatch еще). Я думаю, что отличие multiple dispatch от double dispatch, в том, что double dispatch определяется наследником и параметром метода, а multiple displatch параметрами(несколькими) метода (проверить)

В примере ниже, на этапе компиляции известно какой метод будет вызван, это single dispatch, потому что выбор метода происходит исходя только из типа параметра:

        public class Callable
        {
            public virtual void Call(AParameter parameter)
            {
                Console.WriteLine("AParameter");
            }

            public virtual void Call(BParameter parameter)
            {
                Console.WriteLine("BParameter");
            }
            
            public virtual void Call(IParameter parameter)
            {
                Console.WriteLine("IParameter");
            }
        }

        public interface IParameter
        {
        }

        public class AParameter : IParameter
        {
        }

        public class BParameter : IParameter
        {
        }
        
        public Program()
        {
            var aParameter = new AParameter();
            var c = new Callable();
            c.Call(aParameter);
        }

Double dispatch, тут уже нельзя разрешить на этапе компиляции, но C# может выбрать метод в runtime :

        public class CallableDerive : Callable
        {
            public override void Call(AParameter parameter)
            {
                Console.WriteLine("AParameter Derive");
            }

            public override void Call(BParameter parameter)
            {
                Console.WriteLine("BParameter Derive");
            }
            
            public override void Call(IParameter parameter)
            {
                Console.WriteLine("IParameter Derive");
            }
        }

        public Program()
        {
            var aParameter = new AParameter();
            Callable c = new CallableDerive();
            c.Call(aParameter);
        }

Пример выше - double dispatch, для вызова нужно знать реальный тип класса на котором вызывается метод (CallableDerive) и статический тип параметра

А теперь предположим, что реальный тип параметра неизвестен во время компиляции тоже:

 public Program()
        {
            IParameter aParameter = new AParameter();
            CallableDerive c = new CallableDerive(); // <- static type
            c.Call(aParameter); // <-- "IParameter Derive"
            c.Call((dynamic) aParameter) // <-- "AParameter Derive"
        }

Вот это называется Multiple dispatch, когда за разрешение перегрузки метода отвечает реальный тип аргумента. Получается так:

Member Single Dispatch Double Dispatch Multiple Dispatch
Class can be resolved compile time runtime runtime/compile time
Argument can be resolved compile time compile time runtime

Multiple dispath еще называют multi method, что более точно отражает его смысл. В c# можно добиться этого через dynamic, потому что DLR, смотрит реальный тип объекта и находит наиболее подходящий параметр по самому "близкому типу", что C# не делает по умолчанию. Также Multiple Dispatch реализуется через шаблон Visitor.

  1. Работа с XML, или JSON объектом через точку, например : xml.Roots.FirstRoot.Nodes.Person.ChildName - вместо многочисленных обращений к методу GetNodes();

Пример утечки памяти при использовании dynamic

в .net framework вот такое код дает memory leak:

        static void Main(string[] args)
        {
            new Foo().Test();
        }

        public void Test()
        {
            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); // <- leak here
            }
        }

        public void Boo(object obj)
        {
        }

Я снял дамб памяти в dot memory, похоже проблемы с кэшем:

в .net core это пофиксили. Во framework не кэшировались binder'ы, в .net core кешируются

Строка на которой можно посмотреть построенное Expression Tree

System.Linq.Expressions.Compiler.LambdaCompiler, метод EmitLambdaBody, строка 226 в файле. (из-за того, что мы используем release сборки, почти везде возникает исключение is not safe point, при попытке посмотреть значение свойства в отладчике, в этом месте не возникает)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment