Thread Safe Events in C # or John Skeet vs. Jeffrey Richter



    I was preparing somehow for an interview on C # and among other things I found a question about the following contents:
    "How to organize a thread-safe event call in C #, given that a large number of threads are constantly subscribing to and unsubscribing from an event?"


    The question is quite concrete and clearly posed, so I did not even doubt that the answer to it can also be given clearly and unequivocally. But I was very wrong. It turned out that this is an extremely popular, battered, but still open topic. I also noticed a not very pleasant feature - in Russian-language resources very little attention is paid to this issue (and Habr is no exception), so I decided to collect all the information I found and digested on this issue.
    We’ll also get to John Skeet and Jeffrey Richter, and they, in fact, played a key role in my common understanding of the problem of how events work in a multi-threaded environment.

    A particularly attentive reader will find two xkcd-style comics in the article.
    (Caution, inside two pictures of approximately 300-400 kb)


    Duplicate the question that needs to be answered:

    "How to organize a thread-safe event call in C #, given that a large number of threads are constantly subscribing to and unsubscribing from an event?"


    I had the assumption that some of the questions are based on the CLR via C # book , all the more so since my favorite C # 5.0 in a Nutshell did not address such a question at all, so let's start with Jeffrey Richter (CLR via C #).

    The Way of Jeffrey Richter


    A small excerpt from the written:

    For a long time, the recommended method of triggering events was approximately the following construction:

    Option 1:
    public event Action MyLittleEvent;
    ...
    protected virtual void OnMyLittleEvent()
    {
        if (MyLittleEvent != null) MyLittleEvent();
    }
    


    The problem with this approach is that in the method OnMyLittleEventone thread can see that our event is MyLittleEventnot equal null, and the other thread, right after this check, but before the event is called, can remove its delegate from the list of subscribers, and thus make it from our event MyLittleEvent null, which will cause an outlier NullReferenceExceptionat the place of the event call.

    Here is a small xkcd-style comic that clearly illustrates this situation (two threads work in parallel, time goes from top to bottom):
    Expand



    In general, everything is logical, we have the usual race condition (hereinafter - race condition). And here is how Richter solves this problem (and this option is most often encountered):

    Add a local variable to our method of calling the event, into which we will copy our event at the time of the “call” to the method. Since delegates are immutable objects (hereinafter - immutable), we get a “frozen” copy of the event, from which no one can unsubscribe. When unsubscribing from an event, a new delegate object is created, which replaces the object in the field MyLittleEvent, while we still have a local reference to the old delegate object.

    Option 2:
    protected virtual void OnMyLittleEvent()
    {
        Action tempAction = MyLittleEvent; // "Заморозили" наше событие для текущего метода
        // На наш tempAction теперь никто не может повлиять, он никогда не станет равен null ни при каких условиях
        if (tempAction != null) tempAction (); 
    }
    


    It is further described by Richter that the JIT compiler may well just omit the creation of a local variable for the sake of optimization, and make the first of the second option, that is, skip the "freeze" event. In the end, it is recommended that you copy through Volatile.Read(ref MyLittleEvent), that is:

    Option 3:
    protected virtual void OnMyLittleEvent()
    {
        // Ну теперь уж точно заморозили
        Action tempAction = Volatile.Read(ref MyLittleEvent); 
        if (tempAction != null) tempAction (); 
    }
    


    You Volatilecan talk about it separately for a long time, but in the general case it "just allows you to get rid of unwanted JIT compiler optimization." There will be more clarifications and details in this regard, but for now we will focus on the general idea of ​​the current decision by Jeffrey Richter:

    To ensure a thread-safe event call, you need to “freeze” the current list of subscribers by copying the event to a local variable, and then, if the received list is not empty, call all the handlers from the “frozen” list. Thus we get rid of the possibleNullReferenceException.


    I was immediately confused by the fact that we are triggering events on unsubscribed objects / threads . It is unlikely that someone unsubscribed just like that - it is likely that someone did this during the general "cleaning" of traces - along with closing write / read streams (for example, a logger that was supposed to write data to a file on an event), closing connections etc., that is, the internal state of the subscribing object at the time of calling its handler may not be suitable for further work.
    For example, suppose our subscriber implements a method IDisposableand follows a convention that determines that when trying to call any method on a freed (hereinafter, disposed) object, it should throw it away ObjectDisposedException. We also agree that we unsubscribe from all events in the method Dispose.
    Now imagine such a scenario - we call the method on this object Disposeexactly after the moment when another thread "froze" the list of its subscribers. The thread successfully calls the handler of the unsubscribed object, and that one, during an attempt to process the event, the object sooner or later realizes that it has already been freed and throws it away ObjectDisposedException. Most likely this exception is not caught in the handler itself, because it is quite logical to assume: "If our subscriber unsubscribed and was released, then his handler will never be called." There will either be a crash of the application, or a leak of unmanaged resources, or the call to the event will be interrupted when it first appears ObjectDisposedException(if we catch an exception when it is called), but the event will not get to normal "live" handlers.

    Back to the comic book. The story is the same - two streams, time goes from top to bottom. Here's what actually happens:
    Expand



    This situation, in my opinion, is much more serious than possible NullReferenceExceptionwith an event call.
    Interestingly, there are tips for implementing thread-safe event triggering on the sides of the Observed Object, but no tips for implementing thread-safe Handlers.

    What is StackOverflow talking about?


    On SO, you can find a detailed “article” (yes, this question draws a whole small article) dedicated to this issue.

    In general, my point of view is shared there, but here is what this comrade adds:

    It seems to me that all this hype with local variables is nothing but Cargo Cult Programming . A large number of people solve the problem of thread-safe events in this way, while for full thread safety much more needs to be done. I can say with confidence that those people who do not add such checks to their code can do without them. This problem simply does not exist in a single-threaded environment, and considering that it is rarely possible to meet a keyword in online code examples volatile, this additional check may well be pointless. If our goal is tracking NullReferenceException, is it possible to do without checking at all null, assigning an emptydelegate { } to our event during class object initialization?


    This brings us to another solution to the problem.

    public event Action MyLittleEvent = delegate {};
    


    MyLittleEventIt will never be equal null, and you can simply not do the extra check. In a multi-threaded environment, you only need to synchronize the addition and removal of event subscribers, but you can call it without fear of receiving NullReferenceException:

    Option 4:
    public event Action MyLittleEvent = delegate {};
    protected virtual void OnMyLittleEvent()
    {
        // Собственно, это все
        MyLittleEvent();
    }
    


    The only drawback of this approach compared to the previous one is a small overhead for calling an empty event (the overhead turned out to be approximately 5 nanoseconds per call). You might also think that in the case of a large number of different classes with different events, these empty “gags” for events will take up a lot of RAM, but if you believe John Skeet in the answer to SO , starting with C # 3.0, the compiler uses the same the same object is an empty delegate for all "gags". I’ll add on my own that when checking the resulting IL code, this statement is not confirmed, empty delegates are created on an event-by-piece basis (checked using LINQPad and ILSpy). В крайнем случае можно сделать общее на проект статическое поле с пустым делегатом, к которому можно обращаться из всех участков программы.

    Путь Джона Скита



    Раз уж мы добрались до Джона Скита, стоит отметить его реализацию потокобезопасных событий, которую он описал в C# in Depth в разделе Delegates and Events (статья онлайн и перевод товарища Klotos)

    Суть в том, чтобы закрыть add, remove и локальную «заморозку» в lock, что позволит избавиться от возможных неопределенностей с одновременной подпиской на событие нескольких потоков:

    Немного кода
    SomeEventHandler someEvent;
    readonly object someEventLock = new object();
    public event SomeEventHandler SomeEvent
    {
        add
        {
            lock (someEventLock)
            {
                someEvent += value;
            }
        }
        remove
        {
            lock (someEventLock)
            {
                someEvent -= value;
            }
        }
    }
    protected virtual void OnSomeEvent(EventArgs e)
    {
        SomeEventHandler handler;
        lock (someEventLock)
        {
            handler = someEvent;
        }
        if (handler != null)
        {
            handler (this, e);
        }
    } 
    



    Despite the fact that this method is deprecated (the internal implementation of events since C # 4.0 looks completely different, see the list of sources at the end of the article), it clearly shows that you can’t just wrap the event call, subscription and unsubscribe in lock, since with a very high probability it can lead to deadlock (hereinafter - deadlock). In lockis only copied to a local variable, the call itself the event occurs outside of the structure.

    But this does not completely solve the problem of calling handlers for unsubscribed events.

    Back to the question on SO. Daniel, in response to all of our prevention methods, NullReferenceExceptionexpresses a very interesting thought:

    Yes, I really figured out this tip about trying to prevent it NullReferenceExceptionat all costs. I say that in our particular case it NullReferenceExceptioncan only occur if another thread is unsubscribing from the event. And he does this only in order to never receive events again , which, in fact, we do not achieve when using checks of local variables. Where we hide the state of the race, we can open it and correct the consequences. NullReferenceExceptionallows you to determine the moment of improper handling of your event. In general, I affirm that this technique of copying and checking - Simple Cargo-cult programming, which adds confusion and noise to your code, but does not solve the problem of multithreaded events at all.


    Among others, John Skeet answered the question, and this is what he writes.

    John Skeet vs. Jeffrey Richter



    The JIT compiler does not have the right to optimize the local reference to the delegate, because there is a condition. This information was “thrown” some time ago, but this is not true (I clarified this question either with Joe Duffy or with Vance Morrison). Without a modifier, it’s volatilejust possible that the local reference to the delegate will be a bit outdated, but overall that’s all. This will not result in NullReferenceException.

    And yes, we definitely have a race condition, you are right. But it will always be present. Let's say we remove the check on nulland just write
    MyLittleEvent();
    

    Now imagine that our list of subscribers consists of 1000 delegates. It is possible that we will start to trigger an event before one of the subscribers unsubscribes from it. In this case, it will still be called, since it will remain in the old list (do not forget that delegates are immutable). As far as I understand, this is completely inevitable.
    Using the empty one delegate {};saves us from having to check the event for null, but it will not save us from the next state of the race. Moreover, this method does not guarantee that we will use the most recent version of the event.


    Now it should be noted that this answer was written in 2009, and CLR via C # 4th edition - in 2012. So who to trust in the end?
    In fact, I did not understand why Richter describes the case of copying to a local variable through Volatile.Read, since he further confirms Skeet's words:
    Although it is recommended to use version c Volatile.Readas the best and technically correct, Option 2 can be dispensed with , since the JIT compiler knows what it can accidentally do by optimizing a local variable tempAction. Purely theoretically , this may change in the future, so it is recommended to use Option 3 . But in fact, Microsoft is unlikely to make such changes, since this can break a huge number of ready-made programs.


    Everything becomes completely confusing - both options are equivalent, but the one with is Volatile.Readmore equivalent. And no option will save you from the state of the race when calling unsubscribed handlers.

    Maybe a thread-safe way to call events does not exist at all? Why NullReferenceExceptionis so much effort and time spent on preventing the improbable , but not on preventing a no less probable call of an unsubscribed handler? This I did not understand. But in the process of searching for answers, I realized a lot of other things, and here is a small summary.

    What do we have in the end



    • The most popular method is not thread safe due to the possibility of turning the delegate into nullafter checking for inequality. There is a danger ofNullReferenceException
      public event Action MyLittleEvent;
      ...
      protected virtual void OnMyLittleEvent()
      {
          if (MyLittleEvent != null) 
                  // Опасность NullReferenceException
                  MyLittleEvent();
      }
      

    • The methods of Skeet and Richter help to avoid the occurrence NullReferenceException, but are not thread safe , since there remains the possibility of calling already unsubscribed handlers.
      Skeet Method
      SomeEventHandler someEvent;
      readonly object someEventLock = new object();
      public event SomeEventHandler SomeEvent
      {
          add
          {
              lock (someEventLock)
              {
                  someEvent += value;
              }
          }
          remove
          {
              lock (someEventLock)
              {
                  someEvent -= value;
              }
          }
      }
      protected virtual void OnSomeEvent(EventArgs e)
      {
          SomeEventHandler handler;
          lock (someEventLock)
          {
              handler = someEvent;
          }
          if (handler != null)
          {
              handler (this, e);
          }
      } 
      


      Richter Method
      protected virtual void OnMyLittleEvent()
      {
          // Ну теперь уж точно заморозили
          Action tempAction = Volatile.Read(ref MyLittleEvent); 
          if (tempAction != null) tempAction (); 
      }
      


    • The empty method delegate {};allows you to get rid of it NullReferenceExceptiondue to the fact that the event never accesses null, but is not thread safe, since there remains the possibility of calling already unsubscribed handlers. Moreover, without a modifier volatile, we have the opportunity to get the latest version of the delegate when the event is called.
    • You can’t just wrap the addition, deletion and invocation of an event in lock, as this will create a deadlock hazard. Technically, this can save you from calling unsubscribed handlers, but we cannot be sure what actions the subscribing object did before unsubscribing from the event, so we can still run into a “damaged” object (see example c ObjectDisposedException) . This method is also not thread safe .
    • An attempt to catch unsubscribing delegates after a local “freeze” event is pointless - with a large number of subscribers, the probability of calling unsubscribed handlers (after the start of the event call) is even higher than with a local “freezing”.


    Technically, none of the options presented is a thread - safe way to trigger an event. Moreover, adding a delegate verification method using local copies of delegates creates a false sense of security . The only way to completely protect yourself is to force the event handlers to check if they have already unsubscribed from a particular event. Unfortunately, unlike common prevention practices NullReferenceExceptionwhen triggering events, there are no prescriptions for handlers. If you make a separate library, then most often you cannot influence its users in any way - you cannot force clients to assume that their handlers will not be called after unsubscribing from the event.

    After realizing all these problems, I still had mixed feelings about the internal implementation of delegates in C #. On the one hand, since they are immutable, there is no chance of getting, InvalidOperationExceptionas in the case of enumerating a changing collection through foreach, but on the other, there is no way to check whether someone unsubscribed from the event during the call or not. The only thing that can be done by the holder of the event is to secure oneself from NullReferenceExceptionand hope that subscribers will not ruin anything. As a result, the question posed can be answered as follows:

    It is impossible to provide a thread-safe event call in a multi-threaded environment, since there is always the possibility of calling handlers of unsubscribed subscribers. This uncertainty contradicts the definition of the term “thread safety”, in particular clause
    Implementation is guaranteed to be free of race conditions when accessed by multiple threads simultaneously.



    Additional reading


    Of course, I could not just copy / translate everything I found. Therefore, I will leave a list of sources that have been directly or indirectly used.


    Also popular now: