前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C# 的事件,一般你不需要担心它的线程安全问题!

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

作者头像
walterlv
发布2023-10-22 11:42:35
2960
发布2023-10-22 11:42:35
举报

时不时会有小伙伴跟我提到在 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])

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-06-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 委托是不可变类型
  • += 和 -= 的本质
  • Delegate.Combine 和 Delegate.Remove
  • 线程安全的事件引发
  • 是否可能出现线程不安全的情况呢?
  • 关于 += -= 的额外说明
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档