Pull to refresh

Вызываем обработчики событий потокобезопасно без лишнего присваивания в C# 6

Reading time 8 min
Views 12K
Original author: Jon Skeet

От переводчика


Часто начинающие разработчики спрашивают, зачем при вызове обработчика нужно копировать его в локальную переменную, а как показывает код ревью, даже опытные разработчики забывают об этом. В C# 6 разработчики языка добавили много синтаксического сахара, в том числе null-conditional operator (null-условный оператор или Элвис-оператор — ?.), который позволяет нам избавиться от ненужного (на первый взгляд) присваивания. Под катом объяснения от Джона Скита — одного из самых известных дот нет гуру.

Проблема


Вызов обработчика в языке C# всегда сопровождался не самым очевидным кодом, потому что событие, у которого нет подписчиков, представлено в виде null ссылки. Из-за этого мы обычно писали так:
public event EventHandler Foo;
 
public void OnFoo()
{
    EventHandler handler = Foo;
    if (handler != null)
    {
        handler(this, EventArgs.Empty);
    }   
}

Локальную переменную handler нужно использовать потому, что без нее к обработчику события Foo доступ идет 2 раза (при проверке на null и при самом вызове). В таком случае есть вероятность, что последний подписчик удалится как раз между этими доступами к Foo.
// Плохой код, не делайте так!
if (Foo != null)
{
    // Foo может быть null, если доступ
    // к классу идет из нескольких потоков.
    Foo(this, EventArgs.Empty);
}

Этот код можно упростить, создав метод расширения:
public static void Raise(this EventHandler handler, object sender, EventArgs args)
{
    if (handler != null)
    {
        handler(sender, args);
    }   
}

Тогда используя этот метод расширения, первый вызов перепишется:
public void OnFoo()
{
    Foo.Raise(this, EventArgs.Empty);
}

Минус данного подхода в том, что метод расширения придется писать для каждого типа обработчика.

C# 6 нас спасет!


Null-условный оператор (?.), появившийся в C# 6, может использоваться не только для доступа к свойствам, но и для вызова методов. Компилятор вычисляет выражение только один раз, поэтому код можно писать без использования метода расширения:
public void OnFoo()
{
    Foo?.Invoke(this, EventArgs.Empty);
}

Ура! Этот код никогда не выбросит NullReferenceException, и нам не нужны вспомогательные классы.

Конечно, было бы лучше, если бы мы могли написать Foo?(this, EventArgs.Empty), но тогда это был бы уже не ?. оператор, что немного усложнило бы язык. Поэтому дополнительный вызов Invoke меня не сильно беспокоит.

Что это за штука — потокобезопасность?


Написанный нами код является «потокобезопасным» в том смысле, что ему все равно, что делают другие потоки — мы никогда не получим NullReferenceException. Однако, если другие потоки подписываются или отменяют подписку на событие, мы можем не увидеть самые последние изменения в списке подписчиков события. Это происходит из-за сложностей в реализации общей модели памяти.

В C# 4 события реализованы с помощью метода Interlocked.CompareExchange, поэтому мы просто можем использовать правильный метод Interlocked.CompareExchange, чтобы убедиться, что получим самое последнее значение. Теперь мы можем объединить эти 2 подхода и написать:
public void OnFoo()
{
    Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty);
}

Теперь без написания дополнительного кода мы можем уведомить самый последний набор подписчиков, без риска свалиться с NullReferenceException. Спасибо David Fowler за напоминание о такой возможности.

Конечно, вызов CompareExchange выглядит некрасиво. Начиная с .NET 4.5 и выше существует метод Volatile.Read, который может решить нашу проблему, но мне не до конца ясно (если читать документацию), делает ли этот метод то, что нужно. (В описании метода говорится, что он запрещает ставить последующие операции чтения/записи до этого метода, в нашем же случае нужно запретить ставить предшествующие операции записи после этого изменяемого чтения).
public void OnFoo()
{
    // .NET 4.5+, может быть потокобезопасно, а может и не быть...
    Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty);
}

Такой подход мне не нравится, потому что я не уверен, что все предусмотрел. Продвинутые читатели, возможно, смогут подсказать, почему такой подход не верен и не попал в BCL.

Альтернативный подход


В прошлом я пользовался таким альтернативным решением: создаем пустой фиктивный обработчик события, используя одно преимущество анонимных методов, которое у них есть по сравнению с лямбда-выражениями — возможность не указывать список параметров:
public event EventHandler Foo = delegate {}
 
public void OnFoo()
{
    // Foo will never be null
    Volatile.Read(ref Foo).Invoke(this, EventArgs.Empty);   
}

При таком подходе все еще остаются проблемы с тем, что мы можем вызывать не самый последний список подписчиков, но зато нам не надо волноваться о проверке на null и NullReferenceException.

Исследуем MSIL


От переводчика: этой части нет в статье Джона, это мои личный изыскания в ildasm'е.
Посмотрим, какой MSIL код генерируется в разных случаях.
Плохой код
public event EventHandler Foo; 
public void OnFoo()
{
    if (Foo != null)
    {
        Foo(this, EventArgs.Empty);
    }
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       35 (0x23)
  .maxstack  3
  .locals init ([0] bool V_0)
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем this в стек  
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo
  IL_0007:  ldnull  // кладем в стек null
  IL_0008:  cgt.un  // сравниваем 2 верхних значения в стеке (Foo и null) - если равны, то кладем в стек 0 (false)
  IL_000a:  stloc.0  // сохраняем результат во временную локальную переменную типа bool
  IL_000b:  ldloc.0  // кладем ее в стек
  IL_000c:  brfalse.s  IL_0022  // если в стеке лежит false, то переходим к IL_0022 (return)
  IL_000e:  nop
  IL_000f:  ldarg.0   // кладем в стек this
  IL_0010:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo - !!!Вот тут можем положить уже null
  IL_0015:  ldarg.0  // кладем в стек this
  IL_0016:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек System.EventArgs::Empty
  IL_001b:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Foo(this, EventArgs.Empty)
  IL_0020:  nop
  IL_0021:  nop
  IL_0022:  ret
} // end of method A::OnFoo


В этом коде мы дважды обращаемся к полю Foo: для сравнения с null (IL_0002: ldfld) и собственно вызова (IL_0010: ldfld). Между тем, как мы проверили Foo на равенство null, и тем, как заново получили к нему доступ, положили в стек и вызвали метод, от события могли отписаться последние подписчики, и второй раз загружен будет null — здравствуй, NullReferenceException.

Посмотрим, как решится проблема с помощью использования дополнительной локальной переменной.
Используя переменную
public event EventHandler Foo; 
public void OnFoo()
{
    EventHandler handler = Foo;
    if (handler != null)
    {
        handler(this, EventArgs.Empty);
    }   
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       32 (0x20)
  .maxstack  3
  .locals init ([0] class [mscorlib]System.EventHandler 'handler',
           [1] bool V_1)
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем this в стек                        
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  //ищем поле Foo, теперь оно наверху стека
  IL_0007:  stloc.0  // сохраняем Foo в переменную handler
  IL_0008:  ldloc.0  // кладем в стек handler
  IL_0009:  ldnull  // кладем в стек null
  IL_000a:  cgt.un  // сравниваем 2 верхних значения в стеке (handler и null) - если равны, то кладем в стек 0 (false)
  IL_000c:  stloc.1  // сохраняем результат во временную локальную переменную типа bool
  IL_000d:  ldloc.1 // кладем ее в стек
  IL_000e:  brfalse.s  IL_001f // если в стеке лежит false, то переходим к IL_001f (return)
  IL_0010:  nop
  IL_0011:  ldloc.0  // кладем в стек handler
  IL_0012:  ldarg.0  // кладем в стек this
  IL_0013:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек System.EventArgs::Empty
  IL_0018:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs) // вызываем handler(this, EventArgs.Empty)
  IL_001d:  nop
  IL_001e:  nop
  IL_001f:  ret
} // end of method A::OnFoo


В этом случае все просто: доступ к Foo происходит один раз (IL_0002: ldfld), потом вся работа идет с переменной handler, поэтому опасности получить NullReferenceException нет.

Теперь решение с использованием оператора ?..
C# 6
public event EventHandler Foo;
public void OnFoo()
{
    Foo?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       26 (0x1a)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldfld      class [mscorlib]System.EventHandler A::Foo  // кладем в стек поле Foo
  IL_0007:  dup  // дублируем в стеке Foo
  IL_0008:  brtrue.s   IL_000d  // если в стеке лежит true или не null и не 0, то переходим к IL_000d (вызов метода)
  IL_000a:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_000b:  br.s       IL_0019  // выходим из метода
  IL_000d:  ldarg.0  // кладем в стек this (мы пришли сюда, если Foo != null)
  IL_000e:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_0013:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_0018:  nop
  IL_0019:  ret
} // end of method A::OnFoo


В C# 6 с использованием оператора ?. все становится интереснее. Мы кладем в стек поле Foo, дублируем его (IL_0007: dup — вся магия тут), потом если оно не null — то идем к IL_000d и вызываем метод Invoke. Если же Foo == null, то очищаем стек и выходим (IL_000b: br.s IL_0019). Мы действительно всего один раз считываем Foo, поэтому NullReferenceException не произойдет.

Используем оператор ?. и Interlocked.CompareExchange.
Interlocked.CompareExchange
public event EventHandler Foo;
public void OnFoo()
{
    Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       33 (0x21)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldflda     class [mscorlib]System.EventHandler A::Foo  // кладем в стек адрес поля Foo
  IL_0007:  ldnull  // кладем в стек null
  IL_0008:  ldnull  // кладем в стек null
  IL_0009:  call       !!0 [mscorlib]System.Threading.Interlocked::CompareExchange<class [mscorlib]System.EventHandler>(!!0&,
                                                                                                                        !!0,
                                                                                                                        !!0)  // вызываем Interlocked::CompareExchange
  IL_000e:  dup  // дублируем в стеке Foo - последнюю версию, полученную через Interlocked::CompareExchange
  IL_000f:  brtrue.s   IL_0014  // если в стеке лежит true или не null и не 0, то переходим к IL_0014 (вызов метода)
  IL_0011:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_0012:  br.s       IL_0020  // выходим из метода
  IL_0014:  ldarg.0  // кладем в стек this
  IL_0015:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_001a:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_001f:  nop
  IL_0020:  ret
} // end of method A::OnFoo


Этот код отличается от предыдущего только вызовом Interlocked.CompareExchange (IL_0009: call !!0 [mscorlib]System.Threading.Interlocked::CompareExchange), потом код точно такой же, как и в предыдущем методе (начиная с IL_000e).

Используем оператор ?. и Volatile.Read.
Volatile.Read
public event EventHandler Foo;
public void OnFoo()
{
    Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty);
}

.method public hidebysig instance void  OnFoo() cil managed
{
  // Code size       31 (0x1f)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.0  // кладем в стек this
  IL_0002:  ldflda     class [mscorlib]System.EventHandler A::Foo  // кладем в стек адрес поля Foo
  IL_0007:  call       !!0 [mscorlib]System.Threading.Volatile::Read<class [mscorlib]System.EventHandler>(!!0&)  // вызываем Volatile::Read
  IL_000c:  dup  // дублируем в стеке Foo - последнюю версию, полученную через Volatile::Read
  IL_000d:  brtrue.s   IL_0012  // если в стеке лежит true или не null и не 0, то переходим к IL_0012 (вызов метода)
  IL_000f:  pop  // очищаем стек - вытаскиваем из него Foo (мы попали сюда, если Foo == null)
  IL_0010:  br.s       IL_001e  // выходим из метода
  IL_0012:  ldarg.0  // кладем в стек this
  IL_0013:  ldsfld     class [mscorlib]System.EventArgs [mscorlib]System.EventArgs::Empty  // кладем в стек EventArgs::Empty
  IL_0018:  callvirt   instance void [mscorlib]System.EventHandler::Invoke(object,
                                                                           class [mscorlib]System.EventArgs)  // вызываем Invoke
  IL_001d:  nop
  IL_001e:  ret
} // end of method A::OnFoo


В этом случае вызов Interlocked.CompareExchange меняется на вызов Volatile.Read, а потом (начиная с IL_000c: dup) все без изменений.

Все решения с использованием оператора ?. отличаются тем, что доступ к полю происходит один раз, для вызова обработчика используется его копия (MSIL команда dup), поэтому мы вызываем Invoke для точной копии объекта, который и сравнивали с null — NullReferenceException произойти не может. В остальном методы отличаются только тем, насколько быстро они подхватывают изменения в многопоточной среде.

Заключение


Да, C# 6 рулит — и не в первый раз. И нам уже доступна стабильная версия!
Tags:
Hubs:
+9
Comments 27
Comments Comments 27

Articles