C# 的事件,一般你不需要担心它的线程安全问题!

2023-10-22 11:42:35 浏览数 (2)

时不时会有小伙伴跟我提到在 C# 写事件 = -= 以及 Invoke 时可能遇到线程安全问题。然而实际上这些操作并不会有线程安全问题,所以我特别写一篇博客来说明一下,从原理层面说说为什么不会有线程安全问题。

顺便再提一下哪种情况下你却可能遇到线程安全问题。

委托是不可变类型

委托是不可变类型。

这点很重要,这是 C# 事件一般使用场景不会发生线程安全问题的关键!

那既然委托是不可变类型,那我们在写 = -= 以及引发事件的时候,是如何处理最新注册或注销的事件呢?

=-= 的本质

我们随便写一个类型,里面包含一个事件:

1 2 3 4 5 6 7 8 9

using System; namespace Walterlv.TempDemo { class DemoClass { public event EventHandler SomeEvent; } }

从外表上,这个事件就像一个字段一样的不线程安全。但实际上,他像一个属性一样能处理好线程安全问题。

众所周知,这个事件会编译成以下两个方法:

  • add_SomeEvent
  • remove_SomeEvent

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86

// Methods // Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250 .method public hidebysig specialname instance void add_SomeEvent ( class [System.Runtime]System.EventHandler 'value' ) cil managed { .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // Header Size: 12 bytes // Code Size: 41 (0x29) bytes // LocalVarSig Token: 0x11000001 RID: 1 .maxstack 3 .locals init ( [0] class [System.Runtime]System.EventHandler, [1] class [System.Runtime]System.EventHandler, [2] class [System.Runtime]System.EventHandler ) /* 0x0000025C 02 */ IL_0000: ldarg.0 /* 0x0000025D 7B01000004 */ IL_0001: ldfld class [System.Runtime]System.EventHandler Walterlv.TempDemo.DemoClass::SomeEvent /* 0x00000262 0A */ IL_0006: stloc.0 // loop start (head: IL_0007) /* 0x00000263 06 */ IL_0007: ldloc.0 /* 0x00000264 0B */ IL_0008: stloc.1 /* 0x00000265 07 */ IL_0009: ldloc.1 /* 0x00000266 03 */ IL_000A: ldarg.1 /* 0x00000267 280D00000A */ IL_000B: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Combine(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate) /* 0x0000026C 740D000001 */ IL_0010: castclass [System.Runtime]System.EventHandler /* 0x00000271 0C */ IL_0015: stloc.2 /* 0x00000272 02 */ IL_0016: ldarg.0 /* 0x00000273 7C01000004 */ IL_0017: ldflda class [System.Runtime]System.EventHandler Walterlv.TempDemo.DemoClass::SomeEvent /* 0x00000278 08 */ IL_001C: ldloc.2 /* 0x00000279 07 */ IL_001D: ldloc.1 /* 0x0000027A 280100002B */ IL_001E: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0) /* 0x0000027F 0A */ IL_0023: stloc.0 /* 0x00000280 06 */ IL_0024: ldloc.0 /* 0x00000281 07 */ IL_0025: ldloc.1 /* 0x00000282 33DF */ IL_0026: bne.un.s IL_0007 // end loop /* 0x00000284 2A */ IL_0028: ret } // end of method DemoClass::add_SomeEvent // Token: 0x06000002 RID: 2 RVA: 0x00002088 File Offset: 0x00000288 .method public hidebysig specialname instance void remove_SomeEvent ( class [System.Runtime]System.EventHandler 'value' ) cil managed { .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // Header Size: 12 bytes // Code Size: 41 (0x29) bytes // LocalVarSig Token: 0x11000001 RID: 1 .maxstack 3 .locals init ( [0] class [System.Runtime]System.EventHandler, [1] class [System.Runtime]System.EventHandler, [2] class [System.Runtime]System.EventHandler ) /* 0x00000294 02 */ IL_0000: ldarg.0 /* 0x00000295 7B01000004 */ IL_0001: ldfld class [System.Runtime]System.EventHandler Walterlv.TempDemo.DemoClass::SomeEvent /* 0x0000029A 0A */ IL_0006: stloc.0 // loop start (head: IL_0007) /* 0x0000029B 06 */ IL_0007: ldloc.0 /* 0x0000029C 0B */ IL_0008: stloc.1 /* 0x0000029D 07 */ IL_0009: ldloc.1 /* 0x0000029E 03 */ IL_000A: ldarg.1 /* 0x0000029F 280F00000A */ IL_000B: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Remove(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate) /* 0x000002A4 740D000001 */ IL_0010: castclass [System.Runtime]System.EventHandler /* 0x000002A9 0C */ IL_0015: stloc.2 /* 0x000002AA 02 */ IL_0016: ldarg.0 /* 0x000002AB 7C01000004 */ IL_0017: ldflda class [System.Runtime]System.EventHandler Walterlv.TempDemo.DemoClass::SomeEvent /* 0x000002B0 08 */ IL_001C: ldloc.2 /* 0x000002B1 07 */ IL_001D: ldloc.1 /* 0x000002B2 280100002B */ IL_001E: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0) /* 0x000002B7 0A */ IL_0023: stloc.0 /* 0x000002B8 06 */ IL_0024: ldloc.0 /* 0x000002B9 07 */ IL_0025: ldloc.1 /* 0x000002BA 33DF */ IL_0026: bne.un.s IL_0007 // end loop /* 0x000002BC 2A */ IL_0028: ret } // end of method DemoClass::remove_SomeEvent

于是 =-= 本质上是调用了 Delegate.Combine 方法和 Delegate.Remove 方法。而 Delegate.CombineDelegate.Remove 不会修改原委托,只会生成新的委托。

于是,任何时候当你拿到这个事件的一个实例,并将它存在一个变量里之后,只要不给这个变量额外赋值,这个变量包含的已注册的委托数就已经完全确定了下来。之后无论什么时候再 =-= 这个事件,已经跟这个变量无关了。

Delegate.CombineDelegate.Remove

现在让我们再来看看 Delegate.Combine 的实现(Remove 就不举例了,相反操作)。

1 2 3 4 5 6 7 8 9

[return: NotNullIfNotNull("a")] [return: NotNullIfNotNull("b")] public static Delegate? Combine(Delegate? a, Delegate? b) { if (a is null) return b; return a.CombineImpl(b); }

最终调用了实例的 CombineImpl 方法,不过 Delegate 基类的 CombineImpl 方法没有实现(只有个异常)。

为了实现事件的 =-=,事件实际上是 MultiCastDelegate 类型,其实现如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85

// This method will combine this delegate with the passed delegate // to form a new delegate. protected sealed override Delegate CombineImpl(Delegate? follow) { if (follow is null) return this; // Verify that the types are the same... if (!InternalEqualTypes(this, follow)) throw new ArgumentException(SR.Arg_DlgtTypeMis); MulticastDelegate dFollow = (MulticastDelegate)follow; object[]? resultList; int followCount = 1; object[]? followList = dFollow._invocationList as object[]; if (followList != null) followCount = (int)dFollow._invocationCount; int resultCount; if (!(_invocationList is object[] invocationList)) { resultCount = 1 followCount; resultList = new object[resultCount]; resultList[0] = this; if (followList == null) { resultList[1] = dFollow; } else { for (int i = 0; i < followCount; i ) resultList[1 i] = followList[i]; } return NewMulticastDelegate(resultList, resultCount); } else { int invocationCount = (int)_invocationCount; resultCount = invocationCount followCount; resultList = null; if (resultCount <= invocationList.Length) { resultList = invocationList; if (followList == null) { if (!TrySetSlot(resultList, invocationCount, dFollow)) resultList = null; } else { for (int i = 0; i < followCount; i ) { if (!TrySetSlot(resultList, invocationCount i, followList[i])) { resultList = null; break; } } } } if (resultList == null) { int allocCount = invocationList.Length; while (allocCount < resultCount) allocCount *= 2; resultList = new object[allocCount]; for (int i = 0; i < invocationCount; i ) resultList[i] = invocationList[i]; if (followList == null) { resultList[invocationCount] = dFollow; } else { for (int i = 0; i < followCount; i ) resultList[invocationCount i] = followList[i]; } } return NewMulticastDelegate(resultList, resultCount, true); } }

计算好新委托所需的委托列表和个数后,创建一个新的委托实例,然后用计算所得的结果初始化它。这座实了委托不变,于是不存在线程安全问题。

线程安全的事件引发

从 C# 6.0 开始,大家引发事件都喜欢使用下面这样的方式:

1

SomeEvent?.Invoke(this, EventArgs.Empty);

不用担心,这就是线程安全的写法!

以上这个写法是空传递写法,相当于:

1 2 3 4 5

var handler = SomeEvent; if (handler != null) { handler.Invoke(this, EventArgs.Empty); }

我们前面已经通过原理证实了“委托不变”,所以这里我们用变量存这个事件的时候,这个变量就完全确认了此时此刻已经注册的所有委托,后面的判空和引发都不会受与之发生在同一时刻的 =-= 的影响。

有人说以上写法有可能会被编译器优化掉(《CLR via C#》说的),造成意料之外的线程安全问题,于是推荐写成下面这样:

1 2 3 4 5

var handler = Volatile.Read(ref SomeEvent); if (handler != null) { handler.Invoke(this, EventArgs.Empty); }

这样写当然是没有问题的。可是这样就没有 C#6.0 带来的一句话写下来的畅快感了!实际上,你根本无需担心编译器会对你引发事件带来线程不安全的优化,因为现在的 C# 编译器和 .NET 运行时很聪明,非常清楚你是在引发事件,于是不会随便优化掉你这里的逻辑。

归根结底,只需要用 C# 6.0 的空传递操作符写引发事件就没有问题了。

是否可能出现线程不安全的情况呢?

从前面原理层面的剖析,我们可以明确知道,普通的事件 =-= 和引发是不会产生线程安全问题的;但这不代表任何情况你都不会遇到线程安全问题。

如果你引发事件的代码逻辑比较复杂,涉及到多次读取事件成员(例如前面例子中的 SomeEvent),那么依然会出现线程安全问题,因为你无法保证两次读取事件成员时,期间没有发生过事件的 =-=

关于 = -= 的额外说明

在上文写完之后,有小伙伴说,C# 里面 = -= 不是线程安全的,并举了以下例子:

1 2 3 4 5 6

private int _value; public void AddValue(int i) { _value = i; }

当并发调用 AddValue 时,可能导致部分调用的结果被另一部分覆盖,从而出现线程安全问题。

因为 _value = i 这个语法糖相当于以下句子:

1 2

var temp = _value i; _value = temp;

然而,事件没有这样的问题,因为事件的 = 语法糖相当于以下句子:

1 2 3

// demo.SomeEvent = DemoClass_SomeEvent; // 相当于: demo.add_SomeEvent(new EventHandler(DemoClass_SomeEvent));

注意这是一次函数调用,并没有像普通的数值运算一样执行两步计算;所以至少这一次方法调用不会有问题。

那么,add_SomeEvent 里面是线程安全的吗?如果只是单纯 Delegate.Combine 然后赋值当然不是线程安全,但它不是简单赋值,而是通过 Interlocked.CompareExchange 原子操作赋值,在保证线程安全的同时还确保了性能:

1

/* 0x000002B2 280100002B */ IL_001E: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0)

转换成容易理解的 C# 代码大约是这样:

1 2 3 4 5 6 7 8 9 10

while (true) { var originalValue = _value; var value = originalValue add; var resultValue = Interlocked.CompareExchange(ref _value, value, originalValue); if (resultValue == value) { break; } }

  1. CompareExchange 的返回值与第三个参数相同,说明本次原子操作成功完成,那么赋值有效,退出循环。
  2. CompareExchange 的返回值与第三个参数不同,说明本次原子操作冲突,在下一次循环中重试赋值。
  3. 因为赋值是很迅速的,所以即使大量并发,也只会有少数冲突,整体是非常快的。

完整的 IL 代码可以在本文前面看到。这里的 !!0 是引用第 0 号泛型类型,即找到 CompareExchange(!!T$, !!T, !!T):!!T 重载。

本文会经常更新,请阅读原文: https://blog.walterlv.com/post/thread-safety-of-csharp-event.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected]) 。

0 人点赞