65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的线程安全事件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (11投票s)

2022 年 3 月 9 日

MIT

6分钟阅读

viewsIcon

29533

downloadIcon

226

讨论在 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) 中,我们有一个从 EventDelegate 的隐式转换。但是,在 C# 中,EventDelegate 是相同还是不同的类型?

如果您查看 [3],您会发现作者 Jon Skeet 强烈认为 EventDelegate 不是相同的。引用他的话:

事件不是委托实例。从某些方面来说,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 的首选方法。

参考文献

历史

  • 2022 年 3 月 10 日:初始版本
© . All rights reserved.