Click here to Skip to main content
15,561,422 members
Articles / Programming Languages / C#
Article
Posted 9 Mar 2022

Stats

17.3K views
177 downloads
19 bookmarked

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
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 17:45
mvaPeter Huber SG20-Mar-22 17:45 
QuestionEvents and Delegates are more the same than different, and info here is not quite accurate Pin
Stacy Dudovitz14-Mar-22 10:55
professionalStacy Dudovitz14-Mar-22 10:55 
AnswerRe: Events and Delegates are more the same than different, and info here is not quite accurate Pin
swdvlpr15-Mar-22 14:07
swdvlpr15-Mar-22 14:07 
GeneralRe: Events and Delegates are more the same than different, and info here is not quite accurate Pin
Paulo Zemek17-Mar-22 10:27
Paulo Zemek17-Mar-22 10:27 
GeneralMy vote of 5 Pin
tal_segal11-Mar-22 3:45
tal_segal11-Mar-22 3:45 
QuestionWhat about this pattern? Pin
James H10-Mar-22 13:48
James H10-Mar-22 13:48 
AnswerRe: What about this pattern? Pin
tal_segal11-Mar-22 3:49
tal_segal11-Mar-22 3:49 
GeneralRe: What about this pattern? Pin
James H19-Mar-22 8:41
James H19-Mar-22 8:41 
GeneralMy vote of 5 Pin
Klaus Luedenscheidt9-Mar-22 19:44
Klaus Luedenscheidt9-Mar-22 19:44 
GeneralSome issues Pin
Paulo Zemek9-Mar-22 19:42
Paulo Zemek9-Mar-22 19:42 
GeneralRe: Some issues Pin
Mark Pelf 10-Mar-22 7:18
professionalMark Pelf 10-Mar-22 7:18 
GeneralRe: Some issues Pin
Paulo Zemek10-Mar-22 13:19
Paulo Zemek10-Mar-22 13:19 
GeneralRe: Some issues Pin
Mark Pelf 11-Mar-22 0:07
professionalMark Pelf 11-Mar-22 0:07 
GeneralRe: Some issues Pin
Paulo Zemek11-Mar-22 0:24
Paulo Zemek11-Mar-22 0:24 
GeneralRe: Some issues Pin
Paulo Zemek11-Mar-22 17:19
Paulo Zemek11-Mar-22 17:19 
I think I got a better example.
You are talking that an event and a delegate is "somewhat" the same because in your test:
Console.WriteLine(Event.GetType());
//and
Console.WriteLine(someDelegate.GetType());

You will get the same output.

Yet, that's like saying a variable and an object are the same thing.
If I do new object().GetType(); or if I have something like:
var obj = new object();
Console.WriteLine(obj.GetType());

I will get the same result.

Yet, the variable is not the object. It holds a reference to the object.
That's the same with events. Events are their own entities, that CONTAIN a delegate to be able to work.
The container and their contents aren't the same thing.

But when you write container.Execute() or variable.Execute() you are going to the content, be it inside a container or outside of it.

In fact, I think that you need to implement events using the add/remove methods to better understand them.
For example:
public event EventHandler MyEvent;

Can be invoked by doing:
MyEvent(this, EventArgs.Empty);

I am not going to discuss thread safety or null checks here. What I want to say is that C# compiler is creating too many things for you. And by using the event name you are also accessing its inner field (that contains a delegate).

In fact, without the synctactic sugar that C# provides us, we will need to write something like:
private EventHandler _myEvent;
public event EventHandler MyEvent
{
  add => _myEvent += value;
  remove => _myEvent -= value;
}

And if you try to call MyEvent(this, EventArgs.Empty) the compiler will complain.
That's because MyEvent is the event, not the field that contains the delegate.
You will need to use the actual delegate for the invocation to work.
But when you use "auto-implemented" events, calling it directly will access its inner field "transparently".

And when you do: _myEvent(this, EventArgs.Empty) you are reading the variable named _myEvent, getting the delegate it points to (or null), and then trying to call its Invoke() method, even though you didn't write .Invoke(). This doesn't make the _myEvent be the method. It is just a "shortcut" created by the C# compiler.

modified 11-Mar-22 22:32pm.

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.