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

Original author: Jon Skeet
  • Translation

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


Часто начинающие разработчики спрашивают, зачем при вызове обработчика нужно копировать его в локальную переменную, а как показывает код ревью, даже опытные разработчики забывают об этом. В 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 рулит — и не в первый раз. И нам уже доступна стабильная версия!
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 27

    +2
    А разве нельзя так, без Volatile, Read, Invoke
    public event EventHandler Foo = delegate {}
    
    public void OnFoo()
    {    
        Foo(this, EventArgs.Empty);
    }
    

    ?
      +1
      Да, можно — это решит NullReferenceException, но:
      1. Это не очень красиво.
      2. Используем не самый последний список подписчиков.
        +3
        1. Это не очень красиво.

        Почему? По-моему красивее, чем описанные в статье варианты, в т.ч. c новым оператором.
        2. Используем не самый последний список подписчиков.

        А разве вариант с?.. решает эту проблему?
          0
          Насчет красоты — конечно, субъективно, но по задумке команды разработчиков C# новый оператор как раз для того случая. Да и для всех случаев, когда можно убрать if (smth != null).

          Нет,?.. — только NRE, а вот Volatile.Read / Interlocked.CompareExchange старается решить.
        +1
        Знающие товарищи замеряли. Это потеря производительности в несколько наносекунд на пустой делегат. Если у вас событий дёргаются тысячи, то вы теряете микросекунды. Для сравнения, присвоение и проверка на null сильно быстрее.
          +1
          Спасибо за комментарий! Да, как показывает MSIL, присвоение и проверка на null — это очень быстро.
            +1
            Полагаю, для подавляющего большинства проектов на C# такая потеря производительности не стоит внимания. Но вообще да, надо понимать, что пустой делегат не бесплатен.
          +1
          > Минус данного подхода в том, что метод расширения придется писать для каждого типа обработчика

          а дженерики нам на что даны?
          public static void Raise<T>(this EventHandler<T> evt, object sender, T args) where T : EventArgs
          
            0
            Да, тоже подумал, когда читал, почему-то Джон не включил это в статью.
            Но и?.. все равно легче, потому что никакие дополнительные методы не нужны.
              0
              Не все события используют EventHandler
                0
                Да, точно, некоторые старые обработчики до .NET 2 используют свои классы.
                  0
                  если нужен лишь факт срабатывания события без каких-либо параметров, можно написать
                  public event Action Foo = delegate {}
                  

                  и рейзить его очень просто, без метода-обёртки
                  Foo();
                  
          • UFO just landed and posted this here
              +1
              А как же точка под вопросительным знаком? ?.
              • UFO just landed and posted this here
                  0
                  Да, в последних релизах C# старается брать что-то от динамических и функциональных языков, и я считаю, что это здорово. Например, string interpolation в C# 6 хорош (взятый из Ruby).
            +2
            Мне не понятно только одно, почему все постоянно так озадачены тем, что Event?.Invoke использует не наисвежайшую версию делегата?
            И да не один из вышеописанных способов не гарантирует получение наисвежайшей версии, между вызовами Volatile.Read и Interlocked.CompareExchange и непосредственно вызовом делегата, оригинал может быть изменен ровно с такой же вероятностью как и без них.
            Если нужна гарантия вызова актульной версии, поможет только блокировка.
              0
              Мне кажется, в 99.9% случаев достаточно просто вызвать текущий список и никакой проблемы не будет, чем добавлять локи и получать проблемы в производительности. Но представлять как все работает — полезно для разработчика.
                +1
                Знать полезно это точно, но не раз натыкался, чуть ли не на панику, что вызовется делегат который уже успели отписать!
                И каждый раз когда поднимается проблема вызова делегата события, пытаются решить эту проблему странными не эффективными методами.
                Но я не понимаю зачем?
                  0
                  Я тоже не разделяю этих панических настроений, в пример приводилась отписка в Dispose() и затем ObjectDisposedException при вызове делегата объекта. Ситуация теоретически возможная, но городить огород в общей архитектуре ради гипотетической ситуации неразумно, имхо.
              0
              А откуда пошло название «Элвис-оператор»?
              Я вот что-то не могу найти сходства с Элвисом
              0
              Имхо вся эта возня как верно заметили решается пустым делегатом с ничтожной потерей производительности. Намного больше проблем может возникнуть, если мы сохранили список с делегатами, а пока мы их вызываем, делегат из конца списка отписался и освободил ресурсы.
              using System;
              using System.Threading;
              
              namespace ConsoleApplication25
              {
                  class Program
                  {
                      static void Main(string[] args)
                      {
                          var example = new Example();
                          for (int i = 0; i < 10; i++)
                          {
                              int j = i;
                              example.Foo += (sender, eventArgs) =>
                                             {
                                                 Thread.Sleep(100);
                                                 Console.WriteLine(j);
                                             };
                          }
                          using (var disposable = new MyDisposable())
                          {
              
                              example.Foo += (sender, eventArgs) => disposable.Foo();
                              new Thread(() => example.OnFoo()).Start();
                          }
                          Console.WriteLine("Waiting...");
                          Console.ReadKey();
                      }
                  }
              
                  class Example
                  {
                      public event EventHandler Foo;
              
                      public void OnFoo()
                      {
                          EventHandler handler = Foo;
                          if (handler != null)
                          {
                              handler(this, EventArgs.Empty);
                          }
                      } 
                  }
              
                  class MyDisposable : IDisposable
                  {
                      private bool _disposed;
              
                      public void Dispose()
                      {
                          _disposed = true;
                      }
              
                      public void Foo()
                      {
                          if (_disposed)
                              throw new ObjectDisposedException("MyDisposable");
                          Console.WriteLine("Hello world!");
                      }
                  }
              }

              Никакой C# 6.0 тут нас не спасет (равно как и пустой делегат, к слову), так что нужно просто смириться с тем, что вызывающему коду нужно быть очень аккуратным в работе с подпиской/отпиской.

              Only users with full accounts can post comments. Log in, please.