понедельник, 18 февраля 2013 г.

MVP Summit. День 0 + кэширование делегатов в C#

В этом году я первый раз попал на MVP Summit, который ежегодно проводит Майкрософт для своих MVP. Он длится 3 дня и многое, что на нем рассказывается попадает под NDA (Non-Disclosure Agreement) и разглашению не подлежит. Но помимо закрытой информации, тут сами MVP делятся друг с другом полезными советами, да и то, что рассказывают сотрудники МС-а довольно часто доступно публично.

Сегодня никакой особой программы не было, помимо регистрации и стартового мероприятия (знакомства всех со всеми), но для dev подразделения было сделано небольшое исключение в виде QA-сессии со Стивеном Таубом (Stephen Toub) и Lucian Wischik (даже не буду пытаться транслитить). Это была чистейшей воды ad hoc сессия на которой обсуждали практически любые вопросы, связанные с асинхронностью и не только (даже успели потролить на тему Ambient Context vs Dependency Injection).

Интересные моменты с асинхронностью, по сути, повторяют несколько статей Стивена, в частности, “Async Performance: Understanding the Costs of Async Methods”, “Are deadlock still possible with await?”, “ExecutionContext vs SynchronizationContext” (во время которого выяснилось, что в .NET существует LogicalCallContext и IllogicalCallContext, что сделало мой сегодняшний день:)). Но была затронута еще одна интересная тема – кэширование делегатов.

Кэширование делегатов

Давайте рассмотрим такой пример. Предположим, у нас есть метод Foo, выполняющий некоторую длительную (или не очень) операцию. Теперь, предположим, мы захотели вызвать его асинхронно с помощью TPL и метода Task.Factory.StartNew, будет ли разница между вызовом метода Foo через группу методов (Method Group Conversion) или с использованием лямбда-выражения?

static void Bar()
{
    
// Не важно
} static void FooWithMethodGroup(string s) {
   
// Вызываем Bar асинхронно за счет преобразования     // имени метода к делегату (т.н. Method Group Conversion)     Task
.Factory.StartNew(Bar); } static void FooWithLambda(string s) {
   
// Вызываем явно с помощью лямбда-выражения     Task.Factory.StartNew(() => Bar()); }

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

static void FooWithMethodGroup(string s)
{ 
   
// Экземпляр делегата Action создается каждый раз     Task.Factory.StartNew(new Action
(Bar)); } private static Action _cachedAnonymousMethodDelegate1 = null; static void FooWithLambda(string s) {
   
// Экземпляр делегата создается только при первом вызове     Action action = _cachedAnonymousMethodDelegate1 == null                         ? (_cachedAnonymousMethodDelegate1 = new Action
(Bar))
                        : _cachedAnonymousMethodDelegate1;
   
Task.Factory.StartNew(action); }

Объясняется такое поведение тем, что Method Group Conversion (т.е. вариант Task.Factory.StartNew(Bar)) появился еще в .NET 2.0 вместе с анонимными методами при этом в спецификации было явно сказано, что такой код будет приводить к созданию нового экземпляра делегата. Когда же на свет появился C# 3.0, то спецификация была более осторожной, оставив определенные вопросы на усмотрение разработчика компилятора (это как раз одна из причин, почему implementation-defined behavior бывает полезным). Теперь, пока спецификация с Method Group Conversion не будет исправлена (если она вообще когда-либо будет исправлена), то мы получаем разное (хотя и не очень заметное) поведение. Понятно, что разница мизерная, но в определенных случаях она может оказаться существенной.

Другим примером подобного же рода является использование замыкания или протаскивания состояния вручную. Так, практически любой API для работы с многопоточностью (класс Thread, метод ThreadPool.QueueUserWorkItem или Task.Factory.StartNew), содержат перегруженные версии метода, принимающие state. В результате, у нас оказывается два варианта: мы можем захватить внешнюю переменную или же “протащить” состояние вручную:

static void FooWithClosure(string s)
{ 
   
// Будет создан объект замыкания, да еще и экземпляр делегата,     // закешировать мы его не можем, поскольку экземпляр     // делегата завязан на состояние замыкания:     // var closure = new Closure();     // var closure.s = s;     // Task.Factory.StartNew(new Action(closure.AnonymousMethod));     Task.Factory.StartNew(() => Console
.WriteLine(s)); } static void FooWithoutClosure(string s) {
   
// Будет создан лишь один экземпляр делегата, да и то, лишь     // при первом обращении! Более никаких аллокаций происходить не будет!     Task
.Factory.StartNew(state =>
    {
       
var data = (string
) state;
       
Console.WriteLine(data);
    }, s); }

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

Интересно, как именно можно посмотреть настоящую реализацию метода, чтобы понять, что же делает компилятор у нас за спиной. Один способ, это анализировать IL с помощью ILDasm-а или чего-то подобного. Другой способ зависит от декомпилятора; так, например, у dotPeek (от JetBrains) есть опция “Show compiler-generated code”, а у Рефлектора достаточно поменять целевой язык программирования с C# 5.0 (если вы изучаете async/await) или с C# 4.0 (для более старых фич), на C# 1.0. Поскольку таких возможностей, как анонимные методы, блоки итераторов и асинхронные возможности изначально не присутствовали, то такой подход позволит посмотреть действия компилятора, но не в “сыром” виде IL-а, а в более понятном синтаксисе языка C#.

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

З.Ы.Ы. У меня есть некоторые сомнения в том, что я смогу делиться чем-то полезным каждый день, но если такая возможность у меня будет (т.е. будет интересная публично доступная информация и у меня хватит на это сил), то я обязательно продолжу публикации.

15 комментариев:

  1. Это поведение не определено стандартом и является прерогативой компилятора. Полагаться на него нельзя.

    ОтветитьУдалить
  2. Эээээ… Так в чём же будет разница между вызовом FooWithMethodGroup и FooWithLambda? Скажется ли на чём-либо качественном в действия компиятора в первом и во-втором слуяае?

    ОтветитьУдалить
  3. @Станислав:
    Не со всем понял, о чем идет речь. В первом случае, при использовании Method Group Conversion мы совершенно точно получим новый экземпляр, поскольку именно об этом говорится в спецификации:
    The result of the conversion is a value of type D - namely newly created delegate that refers to the selected method and target object.

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

    ОтветитьУдалить
  4. @Viacheslav: зависит от задач. Если речь идет о core логике того же РеШарпера - то точно скажется, а если о бизнес-приложении - то не скажется. Ну и может сказаться на низкоуровневых библиотеках, которые мы точно знаем, что будут вызываться тысячи раз в секунду.

    ОтветитьУдалить
  5. Блин, вот всегда мне в решарпере не нравилось предложение - а давайте сделаем Method Group Conversion. Теперь можно обосновать и почему. Лично мне более понятен синтаксис лямбды, чем просто имя метода. Хотя, не исключаю, что это дело привычки. А вот за Task со state - отдельное спасибо :). Раньше как-то боязливо было использовать в местах с высокой нагрузкой...

    ОтветитьУдалить
  6. @eugene: Да, мне тоже вариант с Method Group нравится меньше, так как мне его сложнее читать.

    ОтветитьУдалить
  7. Спасибо больше за статью, хотелось бы еще услышать о саммите MVP или поделится ресурсами, где Майкрософт постит самые актуальные топики саммитов (никогда не приходилось наталкиватся на такие)

    @eugene позвольте вставить свои 5 копеек. Существуют простые сценарии, где Method Group выгладят более привлекательнем (в силу отсутствия церемонии декларирования и передачи локального параметра). Покажу на примере:

    string input = "Remove all digits like 654 and spaces, then convert to upper case";

    var result = input.ToCharArray()
    .Where(char.IsLetter)
    .Select(char.ToUpperInvariant);

    в противовес

    var result = input.ToCharArray()
    .Where(i => char.IsLetter(i))
    .Select(i => char.ToUpperInvariant(i));

    ОтветитьУдалить
  8. @Ilya: топики MVP саммита нигде не публикуются, поскольку 90% всей информации идет под NDA, так что с этим туго.

    ОтветитьУдалить
  9. @Станислав Выщепан, рассчитывать на оптимизацию нельзя, но можно рассчитывать на её невозможность. С method group спецификация позволяет рассчитывать на то что два полученных делегата не будут иметь ref equality.

    @Sergey Teplyakov, полагаю, что при захвате чего-либо включая this это ставится невозможным. Т.е. только чистые (не просто pure, а жёстче) функции можно кешировать.

    Стоит отметить, что подобное кеширование оптимизирует не создание делегата, а поддержку управляемого объекта garbage collector'ом. Создание не должно быть сложнее чем создание класса с одним IntPtr'ом, а в случаее с захватом (без кеширования) ещё с несколькими полями.

    ОтветитьУдалить
  10. @Ilya. Когда я писал, что мне неудобно читать Method Group Conversion я именно это и имел в виду. Многие места, где используется эти соглашения, выглядят как передача свойства. Да, понятно, что в Where не передашь проперти. Однако, читается это не так легко. Лямбду со свойством - не перепутаешь. А вообще, это кому как удобнее. Уж точно, копья ломать из-за этого не стоит.:)

    ОтветитьУдалить
  11. Этот комментарий был удален автором.

    ОтветитьУдалить
  12. @eugene никто особо копья не ломает. Я слышал аргументы против свойств в C#, так как они выглядят как публичные поля при обращении к ним.
    Вещи, связанные с организацией и форматированием кода стоит аргументировано обсуждать и неплохо бы оценивать, насколько валидны аргументы, пытаясь минимизировать субъективную сторону. В случае с Method Group - можно упростить и уменьшить количество кода за счет отсутствия формальных параметров в лямбде, особенно если их несколько. В таких языках как F# это используется довольно часто и я вижу большую долю выгоды в этом. Никаких копьев)
    \сорри за удаленый пост. Привычка со StackOverflow - постить и редактировать, а не наоборот\

    ОтветитьУдалить
  13. Извиняюсь за некромантию, но возник вопрос:
    А как тогда работает отписка от события, использую Method Group? Пример:

    event += SomeNamedMethod
    // где-то в другом месте
    event -= SomeNamedMethod

    Если каждый раз создается новый делегат, то как он удаляется из InvocationList? Неужели сравниваются "по значению"?

    ОтветитьУдалить
    Ответы
    1. Роман, в случае Method Group conversion:

      event += SomeNamedMethod;
      event -= SomeNamedMethod;

      Создается два экземпляра делегата, которые указывают на один и тот же именованный метод класса, с одинаковым "MethodID". При этом делегаты - это такие себе Value Objects (в терминах DDD), эквивалентность которых определяется эквивалентностью списка Invocation List: если два делегата указывают на одни и те же методы в том же порядке, то они равны.

      Делегаты - неизменяемые, поэтому ничто не добавляется и не удаляется из InvocationList: каждый раз создается новый экземпляр, который берет все элементы из исходного invocatino list-а, добавляет новый элемент и только после этого создается новый делегат.

      Удалить
    2. Спасибо. Теперь, наконец-то, дошла статья Steven Cleary почему event практически невозможно сделать полностью thread-safe. Получается, мы можем получить ссылку на делегат, в котором только что отписавшийся хэндлер все еще содержится, так как делегат неизменяем. Как говорится паззл собрался.

      Удалить