📜 ⬆️ ⬇️

Call event handlers thread-safe without extra assignment in C # 6

From translator


Often, novice developers ask why when calling a handler you need to copy it to a local variable, and as the review code shows, even experienced developers forget about it. In C # 6, language developers added a lot of syntactic sugar, including a null-conditional operator (null-conditional operator or Elvis-operator - ?. ), Which allows us to get rid of unnecessary (at first glance) assignment. Under the cut explanations from John Skit - one of the most famous pillboxes there are no gurus.

Problem


Calling a handler in the C # language was always accompanied by not the most obvious code, because an event that has no subscribers is represented as a null link. Because of this, we usually wrote this:
public event EventHandler Foo; public void OnFoo() { EventHandler handler = Foo; if (handler != null) { handler(this, EventArgs.Empty); } } 

The local variable handler should be used because without it, access to the Foo event handler is 2 times (when checked for null and at the call itself). In this case, there is a possibility that the last subscriber will be removed just between these accesses to Foo.
 //  ,   ! if (Foo != null) { // Foo   null,   //      . Foo(this, EventArgs.Empty); } 

This code can be simplified by creating an extension method:
 public static void Raise(this EventHandler handler, object sender, EventArgs args) { if (handler != null) { handler(sender, args); } } 

Then using this extension method, the first call will overwrite:
 public void OnFoo() { Foo.Raise(this, EventArgs.Empty); } 

The disadvantage of this approach is that the extension method will have to be written for each type of handler.

C # 6 will save us!


The null-conditional operator ( ?. ), Which appeared in C # 6, can be used not only to access properties, but also to call methods. The compiler evaluates the expression only once, so the code can be written without using the extension method:
 public void OnFoo() { Foo?.Invoke(this, EventArgs.Empty); } 

Hooray! This code will never throw a NullReferenceException, and we do not need helper classes.

Of course, it would be better if we could write Foo? (This, EventArgs.Empty), but then it would be no longer ? an operator that would complicate the language a little. Therefore, the extra call Invoke doesn’t bother me much.
')

What is this thing - thread safety?


The code we write is “thread-safe” in the sense that it doesn’t care what other threads do — we will never get a NullReferenceException. However, if other threads subscribe or cancel a subscription to an event, we may not see the most recent changes in the list of event subscribers. This is due to the difficulties in implementing a common memory model.

In C # 4, events are implemented using the Interlocked.CompareExchange method, so we can simply use the correct Interlocked.CompareExchange method to make sure we get the latest value. Now we can combine these 2 approaches and write:
 public void OnFoo() { Interlocked.CompareExchange(ref Foo, null, null)?.Invoke(this, EventArgs.Empty); } 

Now, without writing additional code, we can notify the most recent set of subscribers, without the risk of falling out of a NullReferenceException. Thanks to David Fowler for reminding me of this feature.

Of course, the CompareExchange call looks ugly. Starting from .NET 4.5 and higher, there is a Volatile.Read method that can solve our problem, but it’s not completely clear to me (if you read the documentation) whether this method does what you need. (The description of the method says that it prohibits placing subsequent read / write operations before this method; in our case, it is necessary to prohibit previous write operations after this modified read).
 public void OnFoo() { // .NET 4.5+,   ,     ... Volatile.Read(ref Foo)?.Invoke(this, EventArgs.Empty); } 

I don’t like this approach because I’m not sure that I’ve foreseen everything. Advanced readers may be able to suggest why this approach is not correct and did not get into the BCL.

Alternative approach


In the past, I used this alternative solution: create an empty fictitious event handler, using one advantage of anonymous methods that they have in comparison with lambda expressions - the ability not to specify a list of parameters:
 public event EventHandler Foo = delegate {} public void OnFoo() { // Foo will never be null Volatile.Read(ref Foo).Invoke(this, EventArgs.Empty); } 

With this approach, there are still problems with the fact that we can not cause the most recent list of subscribers, but we do not need to worry about checking for null and NullReferenceException.

Explore MSIL


From the translator: this part is not in the article of John, this is my personal research in ildasm.
Let's see what MSIL code is generated in different cases.
Bad code
 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 


In this code, we twice refer to the Foo field: for comparison with null (IL_0002: ldfld) and the actual call (IL_0010: ldfld). Meanwhile, as we checked Foo for null equality, and once we got access to it, we put it on the stack and called the method, the last subscribers could unsubscribe from the event, and the second time it will be loaded null - hello, NullReferenceException.

Let's see how the problem is solved by using an additional local variable.
Using variable
 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 


In this case, everything is simple: access to Foo occurs once (IL_0002: ldfld), then all the work goes with the variable handler, so there is no danger of getting a NullReferenceException.

Now the solution using the operator ? .
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 


In C # 6 using the ? Operator . everything becomes more interesting. We put the Foo field on the stack, duplicate it (IL_0007: dup - all the magic is here), then if it is not null, then go to IL_000d and call the Invoke method. If Foo == null, then clear the stack and exit (IL_000b: br.s IL_0019). We are really only reading Foo once, so a NullReferenceException will not occur.

Use the operator ? and 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 


This code differs from the previous one only by calling Interlocked.CompareExchange (IL_0009: call !! 0 [mscorlib] System.Threading.Interlocked :: CompareExchange), then the code is exactly the same as in the previous method (starting with IL_000e).

Use the operator ? and 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 


In this case, the call to Interlocked.CompareExchange changes to a call to Volatile.Read, and then (starting with IL_000c: dup), all without changes.

All solutions using the operator ? differ in that the access to the field occurs once, to call the handler, its copy is used (MSIL command dup), so we call Invoke to make an exact copy of the object, which was compared to null - NullReferenceException cannot occur. The rest of the methods differ only in how quickly they catch changes in a multi-threaded environment.

Conclusion


Yes, C # 6 taxis - and not for the first time. And we already have a stable version!

Source: https://habr.com/ru/post/272571/


All Articles