В этом году я первый раз попал на 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#.
З.Ы. Только не думайте, что все описанное здесь нужно сразу же использовать на практике. Подобная разница будет играть какую-то роль лишь в очень нагруженных сценариях, да и то, лишь в редких случаях (которые нужно определять с помощью профилирования). Тем не менее, разница эта есть и ее легко можно увидеть с помощью большинства доступных декомпиляторов.
З.Ы.Ы. У меня есть некоторые сомнения в том, что я смогу делиться чем-то полезным каждый день, но если такая возможность у меня будет (т.е. будет интересная публично доступная информация и у меня хватит на это сил), то я обязательно продолжу публикации.
Это поведение не определено стандартом и является прерогативой компилятора. Полагаться на него нельзя.
ОтветитьУдалитьЭээээ… Так в чём же будет разница между вызовом FooWithMethodGroup и FooWithLambda? Скажется ли на чём-либо качественном в действия компиятора в первом и во-втором слуяае?
ОтветитьУдалить@Станислав:
ОтветитьУдалитьНе со всем понял, о чем идет речь. В первом случае, при использовании 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.
В случае же с лямбда-выражениями, спецификацияне такая строгая, что позволяет добавлять оптимизации тогда, когда это возможно. Поскольку в рассматриваемом случае это возможно, то компилятор попытается это сделать. Понятно, что на это не стоит расчитывать, но иногда об этом нужно знать, чтобы не огрести потом по полной.
@Viacheslav: зависит от задач. Если речь идет о core логике того же РеШарпера - то точно скажется, а если о бизнес-приложении - то не скажется. Ну и может сказаться на низкоуровневых библиотеках, которые мы точно знаем, что будут вызываться тысячи раз в секунду.
ОтветитьУдалитьБлин, вот всегда мне в решарпере не нравилось предложение - а давайте сделаем Method Group Conversion. Теперь можно обосновать и почему. Лично мне более понятен синтаксис лямбды, чем просто имя метода. Хотя, не исключаю, что это дело привычки. А вот за Task со state - отдельное спасибо :). Раньше как-то боязливо было использовать в местах с высокой нагрузкой...
ОтветитьУдалить@eugene: Да, мне тоже вариант с Method Group нравится меньше, так как мне его сложнее читать.
ОтветитьУдалитьСпасибо больше за статью, хотелось бы еще услышать о саммите 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));
@Ilya: топики MVP саммита нигде не публикуются, поскольку 90% всей информации идет под NDA, так что с этим туго.
ОтветитьУдалить@Станислав Выщепан, рассчитывать на оптимизацию нельзя, но можно рассчитывать на её невозможность. С method group спецификация позволяет рассчитывать на то что два полученных делегата не будут иметь ref equality.
ОтветитьУдалить@Sergey Teplyakov, полагаю, что при захвате чего-либо включая this это ставится невозможным. Т.е. только чистые (не просто pure, а жёстче) функции можно кешировать.
Стоит отметить, что подобное кеширование оптимизирует не создание делегата, а поддержку управляемого объекта garbage collector'ом. Создание не должно быть сложнее чем создание класса с одним IntPtr'ом, а в случаее с захватом (без кеширования) ещё с несколькими полями.
@Ilya. Когда я писал, что мне неудобно читать Method Group Conversion я именно это и имел в виду. Многие места, где используется эти соглашения, выглядят как передача свойства. Да, понятно, что в Where не передашь проперти. Однако, читается это не так легко. Лямбду со свойством - не перепутаешь. А вообще, это кому как удобнее. Уж точно, копья ломать из-за этого не стоит.:)
ОтветитьУдалитьЭтот комментарий был удален автором.
ОтветитьУдалить@eugene никто особо копья не ломает. Я слышал аргументы против свойств в C#, так как они выглядят как публичные поля при обращении к ним.
ОтветитьУдалитьВещи, связанные с организацией и форматированием кода стоит аргументировано обсуждать и неплохо бы оценивать, насколько валидны аргументы, пытаясь минимизировать субъективную сторону. В случае с Method Group - можно упростить и уменьшить количество кода за счет отсутствия формальных параметров в лямбде, особенно если их несколько. В таких языках как F# это используется довольно часто и я вижу большую долю выгоды в этом. Никаких копьев)
\сорри за удаленый пост. Привычка со StackOverflow - постить и редактировать, а не наоборот\
Извиняюсь за некромантию, но возник вопрос:
ОтветитьУдалитьА как тогда работает отписка от события, использую Method Group? Пример:
event += SomeNamedMethod
// где-то в другом месте
event -= SomeNamedMethod
Если каждый раз создается новый делегат, то как он удаляется из InvocationList? Неужели сравниваются "по значению"?
Роман, в случае Method Group conversion:
Удалитьevent += SomeNamedMethod;
event -= SomeNamedMethod;
Создается два экземпляра делегата, которые указывают на один и тот же именованный метод класса, с одинаковым "MethodID". При этом делегаты - это такие себе Value Objects (в терминах DDD), эквивалентность которых определяется эквивалентностью списка Invocation List: если два делегата указывают на одни и те же методы в том же порядке, то они равны.
Делегаты - неизменяемые, поэтому ничто не добавляется и не удаляется из InvocationList: каждый раз создается новый экземпляр, который берет все элементы из исходного invocatino list-а, добавляет новый элемент и только после этого создается новый делегат.
Спасибо. Теперь, наконец-то, дошла статья Steven Cleary почему event практически невозможно сделать полностью thread-safe. Получается, мы можем получить ссылку на делегат, в котором только что отписавшийся хэндлер все еще содержится, так как делегат неизменяем. Как говорится паззл собрался.
Удалить