Автор: Sergey Teplyakov

В этом году я первый раз попал на 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#.

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

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

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).