Click here to Skip to main content
15,885,546 members
Articles / Programming Languages / C#

Thread-safe Events in C#

Rate me:
Please Sign up or sign in to vote.
4.89/5 (12 votes)
11 Mar 2022MIT6 min read 25.7K   190   19   15
Discussion on proper method to check for null-value and raise Event in C#
In this article, we discuss three most common ways to check for null-value and raise Event in C#. Thread safety is analyzed. Then, in a small demo program, by creating thread race situation, we attack each solution and demo its thread-safety.

Three Most Common Ways to Check for null-value and Raise an Event

In articles on the internet, you will find a lot of discussions on what is the best and thread-safe way to check for null-value and raise Event in C#. Usually, there are three methods mentioned and discussed:

C#
public static event EventHandler<EventArgs> MyEvent;

Object obj1 = new Object();
EventArgs args1 = new EventArgs();

//Method A
if (MyEvent != null)            //(A1)
{
    MyEvent(obj1, args1);       //(A2)
}

//Method B
var TmpEvent = MyEvent;         //(B1)
if (TmpEvent != null)           //(B2)
{
    TmpEvent(obj1, args1);      //(B3)
}

//Method C
MyEvent?.Invoke(obj1, args1);   //(C1)

Let us immediately give an answer: method A is not thread-safe, while methods B and C are thread-safe ways to check for null-value and raise an Event. Let us provide an analysis of each of them.

Analyzing Method A

In order to avoid NullReferenceException, in (A1) we check for null, then in (A2) we raise the Event. The problem is that in the time between (A1) and (A2), some other thread can access Event MyEvent and change its status. So, this approach is not thread safe. We demo that in our code (below) where we successfully launch race-thread attack on this approach.

Analyzing Method B

Key to understanding this approach is to really understand well what is happening in (B1). There, we have objects and assignment between them.

At first, one might think, we have two C# object references and assignment between them, So, they should be pointing to the same C# object. That is not the case here, since then there would be no point of that assignment. Events are C# objects (you can assign Object obj=MyEvent, and that is legal), but that assignment in (B1) is different there.

The real type of TmpEvent generated by compiler is EventHandler<EventArgs>. So, we basically have assignment of an Event to a delegate. If we assume that Events and Delegates are different types (see text below), conceptually compiler is doing implicit cast, that is the same as if we wrote:

C#
//not needed, just a concept of what compiler it is implicitly doing
EventHandler<EventArgs> TmpEvent = EventA as EventHandler<EventArgs>;  //(**)

As explained in [1], Delegates are immutable reference types. This implies that the reference assignment operation for such types creates a copy of an instance unlike the assignment of regular reference types which just copies the values of references. The key thing here is what really happens with InvocationList (that is of type Delegate[]) which contains list of all added delegates. What it seems is that list is Cloned in that assignment. That is the key reason why method B will work, because nobody else has access to newly created variable TmpEvent and its inner InvocationList of type Delegate[].

We demo that this approach is thread safe in our code (below) where we launch race-thread attack on this approach.

Analyzing Method C

This method is based on null-conditional operator that is available from C#6. For thread safety, we need to trust Microsoft and its documentation. In [2], they say:

“The ‘?.’ operator evaluates its left-hand operand no more than once, guaranteeing that it cannot be changed to null after being verified as non-null…. Use the ?. operator to check if a delegate is non-null and invoke it in a thread-safe way (for example, when you raise an event).”

We demo that this approach is thread safe in our code (below) where we launch race-thread attack on this approach.

Are Events same as Delegates?

In the above text at (**), we were arguing that in (B1), we have implicit cast from Event to a Delegate. But, are Events and Delegates the same or different type in C#?

If you look at [3], you will find the author Jon Skeet strongly arguing that Events and Delegates are not the same. To quote:

Events aren't delegate instances. It's unfortunate in some ways that C# lets you use them in the same way in certain situations, but it's very important that you understand the difference. I find the easiest way to understand events is to think of them a bit like properties. While properties look like they're fields, they're definitely not ….. Events are pairs of methods, appropriately decorated in IL to tie them together ……

So, based on the text above by Jon Skeet and comments on this article below by Paulo Zemek, we can accept the interpretation that “events are like special kind of properties”. Following on that analogy, we can in our demo program below replace:

C#
public static event EventHandler<EventArgs> EventA;
public static event EventHandler<EventArgs> EventB;
public static event EventHandler<EventArgs> EventC;

with:

C#
public static EventHandler<EventArgs> EventA { get; set; } = null;
public static EventHandler<EventArgs> EventB { get; set; } = null;
public static EventHandler<EventArgs> EventC { get; set; } = null;

and everything will still work. Also, it is interesting to try this code:

C#
public static event EventHandler<EventArgs> EventD1;
public static EventHandler<EventArgs> EventD2 { get; set; } = null;
public static EventHandler<EventArgs> EventD3;

EventD1 = EventD2 = EventD3 = delegate { };
Console.WriteLine("Type of EventD1: {0}", EventD1.GetType().Name);
Console.WriteLine("Type of EventD2: {0}", EventD2.GetType().Name);
Console.WriteLine("Type of EventD3: {0}", EventD3.GetType().Name);

You will get a response:

Type of EventD1: EventHandler`1
Type of EventD2: EventHandler`1
Type of EventD3: EventHandler`1

But, going back to reality, events are created by “event” keyword and therefore they are separate construct in C# language, then properties or delegates. We can “interpret” them that they are “alike” properties or delegates, but they are not the same. Truth is Events are whatever compiler is doing with that keyword “event”, and it seems that it makes them look like C# Delegates.

I am inclined to think like this: Events and Delegates are strictly speaking not the same, but in C# language, it seems that they are treated interchangeably in a very similar manner, so it has become accustomed in the industry to talk about them as they are the same, interchangeably. Even in Microsoft documentation [2], the author is interchangeably using terms Event and Delegate when discussing null-conditional operator “?.”. In one moment, the author talks about “..raise an event”, then the next sentence says “…delegate instances are immutable…” etc.

Race-Thread Attack on Three Proposed Approaches

In order to verify thread safety of the three proposed approaches, we created a small demo program. This program is not a definite answer for all cases and cannot be considered as a “proof”, but still can show/demo some interesting points. In order to setup race situations, we slow down threads with some Thread.Sleep() calls.

Here is the demo code:

C#
internal class Client
{
    public static event EventHandler<EventArgs> EventA;
    public static event EventHandler<EventArgs> EventB;
    public static event EventHandler<EventArgs> EventC;
    public static void HandlerA1(object obj, EventArgs args1)
    {
        Console.WriteLine("ThreadId:{0}, HandlerA1 invoked",
            Thread.CurrentThread.ManagedThreadId);
    }
    public static void HandlerB1(object obj, EventArgs args1)
    {
        Console.WriteLine("ThreadId:{0}, HandlerB1 invoked",
            Thread.CurrentThread.ManagedThreadId);
    }

    public static void HandlerC1(object obj, EventArgs args1)
    {
        Console.WriteLine("ThreadId:{0}, HandlerC1 - Start",
            Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(3000);
        Console.WriteLine("ThreadId:{0}, HandlerC1 - End",
            Thread.CurrentThread.ManagedThreadId);
    }
    public static void HandlerC2(object obj, EventArgs args1)
    {
        Console.WriteLine("ThreadId:{0}, HandlerC2 invoked",
            Thread.CurrentThread.ManagedThreadId);
    }

    static void Main(string[] args)
    {
        // Demo Method A for firing of Event-------------------------------
        Console.WriteLine("Demo A =========================");

        EventA += HandlerA1;

        Task.Factory.StartNew(() =>  //(A11)
        {
            Thread.Sleep(1000);
            Console.WriteLine("ThreadId:{0}, About to remove handler HandlerA1",
                Thread.CurrentThread.ManagedThreadId);
            EventA -= HandlerA1;
            Console.WriteLine("ThreadId:{0}, Removed handler HandlerA1",
                Thread.CurrentThread.ManagedThreadId);
        });

        if (EventA != null)
        {
            Console.WriteLine("ThreadId:{0}, EventA is null:{1}",
                Thread.CurrentThread.ManagedThreadId, EventA == null);
            Thread.Sleep(2000);
            Console.WriteLine("ThreadId:{0}, EventA is null:{1}",
                Thread.CurrentThread.ManagedThreadId, EventA == null);

            Object obj1 = new Object();
            EventArgs args1 = new EventArgs();

            try
            {
                EventA(obj1, args1);  //(A12)
            }
            catch (Exception ex)
            {
                Console.WriteLine("ThreadId:{0}, Exception:{1}",
                    Thread.CurrentThread.ManagedThreadId, ex.Message);
            }
        }

        // Demo Method B for firing of Event-------------------------------
        Console.WriteLine("Demo B =========================");

        EventB += HandlerB1;

        Task.Factory.StartNew(() =>  //(B11)
        {
            Thread.Sleep(1000);
            Console.WriteLine("ThreadId:{0}, About to remove handler HandlerB1",
                Thread.CurrentThread.ManagedThreadId);
            EventB -= HandlerB1;
            Console.WriteLine("ThreadId:{0}, Removed handler HandlerB1",
                Thread.CurrentThread.ManagedThreadId);
        });

        var TmpEvent = EventB;
        if (TmpEvent != null)
        {
            Console.WriteLine("ThreadId:{0}, EventB is null:{1}",
                Thread.CurrentThread.ManagedThreadId, EventB == null);
            Console.WriteLine("ThreadId:{0}, TmpEvent is null:{1}",
                Thread.CurrentThread.ManagedThreadId, TmpEvent == null);
            Thread.Sleep(2000);
            Console.WriteLine("ThreadId:{0}, EventB is null:{1}",   //(B13)
                Thread.CurrentThread.ManagedThreadId, EventB == null);
            Console.WriteLine("ThreadId:{0}, TmpEvent is null:{1}",   //(B14)
                Thread.CurrentThread.ManagedThreadId, TmpEvent == null);

            Object obj1 = new Object();
            EventArgs args1 = new EventArgs();

            try
            {
                TmpEvent(obj1, args1);  //(B12)
            }
            catch (Exception ex)
            {
                Console.WriteLine("ThreadId:{0}, Exception:{1}",
                    Thread.CurrentThread.ManagedThreadId, ex.Message);
            }
        }

        // Demo Method C for firing of Event-------------------------------
        Console.WriteLine("Demo C =========================");

        EventC += HandlerC1;
        EventC += HandlerC2;  //(C11)

        Task.Factory.StartNew(() =>   //(C12)
        {
            Thread.Sleep(1000);
            Console.WriteLine("ThreadId:{0}, About to remove handler HandlerC2",
                Thread.CurrentThread.ManagedThreadId);
            EventC -= HandlerC2;
            Console.WriteLine("ThreadId:{0}, Removed handler HandlerC2",
                Thread.CurrentThread.ManagedThreadId);
        });

        Console.WriteLine("ThreadId:{0}, EventC has EventHandlers:{1}",
            Thread.CurrentThread.ManagedThreadId, EventC?.GetInvocationList().Length);

        try
        {
            Object obj1 = new Object();
            EventArgs args1 = new EventArgs();

            EventC?.Invoke(obj1, args1);

            Console.WriteLine("ThreadId:{0}, EventC has EventHandlers:{1}",
            Thread.CurrentThread.ManagedThreadId, EventC?.GetInvocationList().Length);  //(C13)
        }
        catch (Exception ex)
        {
            Console.WriteLine("ThreadId:{0}, Exception:{1}",
                Thread.CurrentThread.ManagedThreadId, ex.Message);
        }

        Console.WriteLine("End =========================");
        Console.ReadLine();
    }
}

And here is the execution result:

Image 1

A) In order to attack Method A, we at (A11) launch new racing thread that is going to do some damage. We will see that it succeeds to create NullReferenceException at (A12)

B) In order to attack Method B, we at (B11) launch new racing thread that is going to do some damage. We will see that at (B12) nothing eventful will happen and this approach will survive this attack. Key thing is printout at (B13) and (B14) that will show that TmpEvent is not affected by changes to EventB.

C) We will attack method C in a different way. We know that EventHandlers are invoked synchronously. We will create 2 EventHandlers (C11) and will during execution of the first one, attack with racing thread (C12) and try to remove the second handler. We will from printouts see that attack has failed and both EventHandlers were executed. It is interesting to look at output at (C13) that shows that AFTER EventC, reports decreased number of EventHandlers.

Conclusion

The best solution is to avoid thread-racing situations, and to access Events from a single thread. But, if you need, Method C based on null-conditional operator is the preferred way to check for null-value and raise an Event.

References

History

  • 10th March, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Software Developer
Serbia Serbia
Mark Pelf is the pen name of just another Software Engineer from Belgrade, Serbia.
My Blog https://markpelf.com/

Comments and Discussions

 
QuestionYou are not testing properly for multithreading problems :-( Pin
Peter Huber SG20-Mar-22 16:45
mvaPeter Huber SG20-Mar-22 16:45 
QuestionEvents and Delegates are more the same than different, and info here is not quite accurate Pin
Stacy Dudovitz14-Mar-22 9:55
professionalStacy Dudovitz14-Mar-22 9:55 
I appreciate what you are trying to accomplish here in this article... but I worry that there is some confusion and incorrect understanding and information being disseminated here. Also, the sample code presented here would fail simply by attempting to marshal values from a background thread back onto a UI thread, which is a NO NO!! But lets tackle first things first...

Lets start with delegates and events, and what they truly are and are not. Delegates were introduced back in .NET 1.0, and offered the first attempt at threading using the Invoke and Begin/End keywords. It also came in two flavors, single and multi-cast, the latter being more prevalent. It utilized the += operator to allow addition of handlers. The problem is that it also allowed all handlers to be overwritten by the = operator. Here is an example:
C#
public class Animal
{
    public Action Run {get; set;}

    public void RaiseEvent()
    {
        if (Run != null)
        {
            Run();
        }
    }
}

To use the delegate, you should do something like this:
C#
Animal animal= new Animal();
animal.Run += () => Console.WriteLine("I'm running");
animal.Run += () => Console.WriteLine("I'm still running") ;
animal.RaiseEvent();

The code works, but if you do this:
C#
animal.Run += () => Console.WriteLine("I'm running");
animal.Run += () => Console.WriteLine("I'm still running");
animal.Run = () => Console.WriteLine("I'm sleeping") ;
All previous handlers (behaviors) are lost.

Here's where things take a left turn. Events use compiler 'magic' to wrap a delegate to solve this problem. If you were to look at the code generated for an event, you would actually see something like this:
C#
private EventHandler<SomeStandardArgs> _OnFireNiceEvent;

public void add_OnFireNiceEvent(EventHandler<SomeStandardArgs> handler)
{
    Delegate.Combine(_OnFireNiceEvent, handler);
}

public void remove_OnFireNiceEvent(EventHandler<SomeStandardArgs> handler)
{
    Delegate.Remove(_OnFireNiceEvent, handler);
}

public event EventHandler<SomeStandardArgs> OnFireNiceEvent
{
    add
    {
        add_OnFireNiceEvent(value)
    }
    remove
    {
        remove_OnFireNiceEvent(value)
     }
}

Yes, the compiler is using the BCL methods Delegate.XXX to wrap the event. But what is our gain from this?

Let's go back to the Animal class.
C#
public class ArgsSpecial : EventArgs
{
    public ArgsSpecial (string val)
    {
        Operation = val;
    }

    public string Operation { get; set;}
} 
C#
public class Animal
{
    // Empty delegate. In this way you are sure that value is always != null 
    // because no one outside of the class can change it.
    public event EventHandler<ArgsSpecial> Run = delegate{} 

    public void RaiseEvent()
    {  
         Run(this, new ArgsSpecial("Run faster"));
    }
}

To call the events:
C#
Animal animal= new Animal();
animal.Run += (sender, e) => Console.WriteLine("I'm running. My value is {0}", e.Operation);
animal.RaiseEvent();

The key differences:
  • The Run variable is a Field, not a property.
  • Only the += operator is public, the = operator is not.
Ah, but there is more. Now that you know that the addand remove keyword expose functionality as well, and that the backing store is exposed as well, you can now roll your own events with custom backing stores, security, etc.

Look what the cat dragged in!

For reference, I refer you to the post on StackOverflow by faby here:
What are the differences between delegates and events?
Also my discussion on StackOverflow on event composition:
event Action<> vs event EventHandler<>

One more thing, I alluded to this as well, marshaling back to the correct thread... the code should really be synchronization context aware. This is beyond the scope of this question, I wrote extensively on this subject on StackOverflow here:
Difference between Synchronization Context and Dispatcher

One last point... instead of relying on using the null conditional operator ?., you might considering, since you are already creating a collection of Task<> objects using Task.WaitAll() with a Cancellation token to exit the wait gracefully. This avoids the whole race condition you are addressing in the first place, just something to consider.

Hey, I could have written an article at this point... Smile | :)

modified 7-Jul-22 2:47am.

AnswerRe: Events and Delegates are more the same than different, and info here is not quite accurate Pin
swdvlpr15-Mar-22 13:07
swdvlpr15-Mar-22 13:07 
GeneralRe: Events and Delegates are more the same than different, and info here is not quite accurate Pin
Paulo Zemek17-Mar-22 9:27
mvaPaulo Zemek17-Mar-22 9:27 
GeneralMy vote of 5 Pin
tal_segal11-Mar-22 2:45
tal_segal11-Mar-22 2:45 
QuestionWhat about this pattern? Pin
James H10-Mar-22 12:48
James H10-Mar-22 12:48 
AnswerRe: What about this pattern? Pin
tal_segal11-Mar-22 2:49
tal_segal11-Mar-22 2:49 
GeneralRe: What about this pattern? Pin
James H19-Mar-22 7:41
James H19-Mar-22 7:41 
GeneralMy vote of 5 Pin
Klaus Luedenscheidt9-Mar-22 18:44
Klaus Luedenscheidt9-Mar-22 18:44 
GeneralSome issues Pin
Paulo Zemek9-Mar-22 18:42
mvaPaulo Zemek9-Mar-22 18:42 
GeneralRe: Some issues Pin
Mark Pelf 10-Mar-22 6:18
mvaMark Pelf 10-Mar-22 6:18 
GeneralRe: Some issues Pin
Paulo Zemek10-Mar-22 12:19
mvaPaulo Zemek10-Mar-22 12:19 
GeneralRe: Some issues Pin
Mark Pelf 10-Mar-22 23:07
mvaMark Pelf 10-Mar-22 23:07 
GeneralRe: Some issues Pin
Paulo Zemek10-Mar-22 23:24
mvaPaulo Zemek10-Mar-22 23:24 
GeneralRe: Some issues Pin
Paulo Zemek11-Mar-22 16:19
mvaPaulo Zemek11-Mar-22 16:19 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.