C# 中的线程安全事件
讨论在 C# 中正确检查 null 值和引发事件的方法
检查 null 值并引发事件的三种最常见方法
在互联网文章中,您会找到大量关于检查 null
值和在 C# 中引发 Event
的最佳且线程安全方法的讨论。通常会提到并讨论三种方法。
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)
我们立即给出答案:方法 A 不是线程安全的,而方法 B 和 C 是检查 null
值并引发 Event
的线程安全方法。我们来分析一下它们各自的优缺点。
分析方法 A
为了避免 NullReferenceException
,我们在 (A1) 中检查 null
,然后在 (A2) 中引发 Event
。问题在于,在 (A1) 和 (A2) 之间的时间里,另一个线程可以访问 Event MyEvent
并更改其状态。因此,这种方法不是线程安全的。我们在代码(如下)中演示了这一点,成功地对这种方法发起了竞态线程攻击。
分析方法 B
理解这种方法关键在于真正理解 (B1) 中发生的事情。在那里,我们有对象以及它们之间的赋值。
起初,您可能会认为我们有两个 C# 对象引用,并且它们之间存在赋值。但事实并非如此,否则赋值就没有意义了。事件是 C# 对象(您可以将 Object obj=MyEvent
赋值,这是合法的),但 (B1
) 中的赋值不同。
编译器生成的 TmpEvent
的实际类型是 EventHandler<EventArgs>
。所以,我们基本上是将一个 Event
赋值给一个委托。如果我们假设事件和委托是不同类型(见下文),那么编译器在概念上会进行隐式转换,这与我们写成
//not needed, just a concept of what compiler it is implicitly doing
EventHandler<EventArgs> TmpEvent = EventA as EventHandler<EventArgs>; //(**)
正如 [1] 中所述,委托是不可变引用类型。这意味着对于此类类型的引用赋值操作会创建实例的副本,这与普通引用类型的赋值不同,后者仅复制引用的值。关键在于 InvocationList
(类型为 Delegate[]
)中发生了什么,它包含了所有已添加委托的列表。看起来这个列表在赋值时被克隆了。这就是方法 B 有效的关键原因,因为没有人可以访问新创建的变量 TmpEvent
及其内部类型为 Delegate[]
的 InvocationList
。
我们在代码(如下)中演示了这种方法是线程安全的,并对这种方法发起了竞态线程攻击。
分析方法 C
此方法基于 C# 6 中提供的 null
条件运算符。为了线程安全,我们需要信任 Microsoft 及其文档。在 [2] 中,他们说道:
“‘?.’ 运算符最多对其左侧运算数求值一次,从而保证在验证其非 null 后不会将其更改为 null
……使用 ?.
运算符可以检查委托是否非 null 并以线程安全的方式调用它(例如,在引发事件时)。”
我们在代码(如下)中演示了这种方法是线程安全的,并对这种方法发起了竞态线程攻击。
事件和委托是相同的吗?
在上面(**)的文本中,我们争论过在 (B1) 中,我们有一个从 Event
到 Delegate
的隐式转换。但是,在 C# 中,Event
和 Delegate
是相同还是不同的类型?
如果您查看 [3],您会发现作者 Jon Skeet 强烈认为 Event
和 Delegate
不是相同的。引用他的话:
“事件不是委托实例。从某些方面来说,C# 在某些情况下允许您像使用委托一样使用事件,这有些不幸,但您必须理解它们之间的区别。我认为理解事件的最简单方法是将它们看作类似于属性。虽然属性看起来像字段,但它们肯定不是……事件是方法对,在 IL 中进行适当的修饰以将它们关联起来……”
因此,基于 Jon Skeet 的上述文本以及本文下方 Paulo Zemek 的评论,我们可以接受“事件类似于特殊类型的属性”的解释。遵循这个类比,我们可以在下面的演示程序中替换
public static event EventHandler<EventArgs> EventA;
public static event EventHandler<EventArgs> EventB;
public static event EventHandler<EventArgs> EventC;
用
public static EventHandler<EventArgs> EventA { get; set; } = null;
public static EventHandler<EventArgs> EventB { get; set; } = null;
public static EventHandler<EventArgs> EventC { get; set; } = null;
并且一切仍然有效。另外,尝试运行这段代码也很有趣
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);
您将收到一个响应
Type of EventD1: EventHandler`1
Type of EventD2: EventHandler`1
Type of EventD3: EventHandler`1
但回到现实,事件是由“event
”关键字创建的,因此它们是 C# 语言中独立的结构,而不是属性或委托。我们可以“解释”它们“类似于”属性或委托,但它们并不相同。事实是,事件就是编译器使用“event
”关键字所做的一切,而且它似乎使它们看起来像 C# 委托。
我倾向于这样认为:严格来说,事件和委托不相同,但在 C# 语言中,它们似乎以非常相似的方式被互换处理,以至于行业习惯将它们视为相同并互换使用。即使在 Microsoft 的文档 [2] 中,作者在讨论 null 条件运算符“?.” 时,也互换使用了 Event 和 Delegate 这两个术语。有时,作者谈论“……引发事件”,下一句话就说“……委托实例是不可变的……”等等。
对三种提议方法的竞态线程攻击
为了验证这三种提议方法的线程安全性,我们创建了一个小型演示程序。这个程序不是对所有情况的确定性答案,也不能被视为“证明”,但仍然可以展示/演示一些有趣的点。为了设置竞态情况,我们使用一些 Thread.Sleep()
调用来减缓线程的速度。
这是演示代码
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();
}
}
这是执行结果
A) 为了攻击方法 A,我们在 (A11) 处启动一个新的竞态线程,它将造成一些破坏。我们将看到它成功地在 (A12) 处创建了 NullReferenceException
。
B) 为了攻击方法 B,我们在 (B11) 处启动一个新的竞态线程,它将造成一些破坏。我们将看到在 (B12) 处没有任何异常发生,并且这种方法将经受住这次攻击。关键在于 (B13) 和 (B14) 的输出,它们将显示 TmpEvent
不受 EventB
更改的影响。
C) 我们将以不同的方式攻击方法 C。我们知道 EventHandler
是同步调用的。我们将创建 2 个 EventHandler
(C11),并在第一个执行期间,使用竞态线程 (C12) 发起攻击,并尝试删除第二个处理程序。我们将从输出中看到攻击失败了,并且两个 EventHandler
都得到了执行。查看 (C13) 的输出很有意思,它显示在 EventC
之后,报告的处理程序数量减少了。
结论
最佳解决方案是避免线程竞态情况,并从单个线程访问事件。但是,如果需要,基于 null
条件运算符的方法 C 是检查 null
值并引发 Event
的首选方法。
参考文献
- [1] https://stackoverflow.com/questions/55322255/what-if-i-will-copy-a-reference-to-an-event-object-to-another-object-and-will-ch
- [2] https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#null-conditional-operators--and-
- [3] https://jonskeet.uk/csharp/events.html
历史
- 2022 年 3 月 10 日:初始版本