【问题标题】:.NET: How does the EventHandler race-condition fix work?.NET:EventHandler 竞争条件修复如何工作?
【发布时间】:2015-02-03 20:26:14
【问题描述】:

以下模式用于在引发事件时避免竞争条件,以防另一个线程取消订阅 MyEvent,使其为空。

class MyClass
{
    public event EventHandler MyEvent;

    public void F()
    {
        EventHandler handler = MyEvent;
        if(handler != null)
            handler(this, EventArgs.Empty);
    }
}

与容易出现这种竞争条件的错误方式相反:

class MyClass
{
    public event EventHandler MyEvent;

    public void F()
    {
        if(MyEvent != null)
            MyEvent(this, EventArgs.Empty);
    }
}

我的问题是,鉴于System.Delegate 是一个引用类型:万一MyEvent 不为空,怎么会

EventHandler handler = MyEvent;

似乎复制它的调用列表,而不是获取引用。

我希望将 MyEvent 委托分配给“处理程序”变量,然后一旦有人更改 MyEvent,“处理程序”引用的对象也会更改。

显然,情况并非如此,否则这个漂亮的小模式将无法正常工作。

我查看了 .NET 源代码,但仍然找不到我的答案(它可能在那里,但我已经找了大约一个小时但找不到它,所以我在这里。) 我还阅读了 C# 语言规范对事件和委托的规定,但它没有解决这个问题。

感谢您的宝贵时间。

【问题讨论】:

    标签: c# .net delegates


    【解决方案1】:

    我希望一旦我得到 “处理程序”中的 MyEvent 委托 参考,一旦有人会改变 MyEvent 表示“处理程序”的对象 引用也将被更改。 [..] 注意 System.Delegate 是一个类而不是一个结构体。

    虽然委托类型是引用类型是正确的,但它们是不可变引用类型。来自System.Delegate

    "委托是不可变的;一次 创建的,一个调用列表 委托没有改变。[...] 合并操作,例如Combine 和删除,不要改变现有的 代表。相反,这样一个 操作返回一个新的委托 包含操作的结果, 未更改的委托,或 Nothing。


    另一方面,此模式解决的唯一问题是阻止尝试调用空委托引用。尽管有这个“修复”,但事件是 prone to races

    【讨论】:

    • 我会补充一点,当您将方法添加到调用列表时,您会执行+=,它实际上替换了变量的值。这就是为什么它看起来是可变的。
    • @Andrey:同意,我认为这种语法会让很多开发人员望而却步。
    【解决方案2】:

    更新

    这里有一些图表,希望能消除关于复制引用和赋值的困惑。

    首先:复制引用。

    在上图中,y 中包含的 reference 被复制到 x 中。没有人说 object 被复制了;请注意——它们指向同一个对象。

    第二:为变量分配新的引用。

    暂时忘记+= 运算符;我想在上面强调的是 y 被分配了一个 不同的 引用,指向一个 new 对象。这不会影响x,因为x 是它自己的变量。请记住,只有reference(图中的“地址”)被复制到y

    第三:同样的事情,只发给x

    上面的图描述了string 对象,只是因为它们很容易用图形表示。但委托也是如此(请记住,标准事件只是委托字段的包装器)。您可以看到通过将y 中的引用复制到上面的x 中,我们创建了一个变量,该变量不会受到后续分配给y 的影响。

    这就是我们都熟悉的标准 EventHandler 竞争条件“修复”背后的全部理念。


    原答案

    你可能被这个棘手的小语法弄糊涂了:

    someObject.SomeEvent += SomeEventHandler;
    

    重要的是要认识到,正如Ani points out in his answer,委托是不可变的引用类型(想想:就像string)。许多开发人员错误地认为它们是可变的,因为上面的代码看起来像是我正在将处理程序“添加”到某个可变列表中。事实并非如此; += 运算符是一个赋值运算符:它将+ 运算符的返回值赋值给左侧的变量。

    (想想:int 是不可变的,但我可以做到 int x = 0; x += 1; 对吗?是一样的。)


    编辑:好的,从技术上讲这不太正确。这是真正发生的事情。 event 实际上是一个包装器围绕 一个委托字段,该委托字段可由+=-= 运算符访问(外部代码),它们被编译为分别调用addremove。通过这种方式,它非常像一个属性,它(通常)是一个字段的包装器,其中访问属性和调用=被编译为调用getset .

    但重点仍然存在:当您编写+= 时,被调用的add 方法在内部将新对象 的引用分配给内部委托字段。我很抱歉在我最初的回答中过度简化了这个解释;但要理解的关键原则是一样的。

    顺便说一下,我涉及自定义事件,您可以将自己的逻辑放入addremove 方法中。此答案仅适用于“正常”情况。


    换句话说,当你这样做时......

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

    ...您确实将 reference 复制到变量中。现在该引用位于局部变量中,并且本身不会被修改。如果它在分配时指向一个实际的对象,那么它将继续指向下一行的同一个(不可变的)对象。如果它没有指向一个对象 (null),那么它仍然不会指向下一行的对象。

    因此,如果其他地方的代码使用+= 订阅或取消订阅该事件,它真正所做的就是将原始引用更改为指向一个全新的对象。旧的委托对象仍然存在,并且您已经获得了对它的引用:在您的局部变量中。

    【讨论】:

    • "它获取 + 运算符的返回值并将其分配给左侧的变量。" - 在订阅声明类之外的事件的上下文中,这是不正确的。它不会变异(永远不会)或重新分配:它只是调用一个方法:add 访问器。假设一个类似字段的事件,重新分配发生在访问器代码内部。
    • @Ani:好的,你找到我了。从技术上讲,你是对的。我会更新答案以提供更准确的解释。
    • @Yam:不完全——我可能过于简单化了事件的发生(正如 Ani 在他的评论中指出的那样),但事实仍然是 += 最终导致分配对新的引用对委托字段的对象。查看我的更新;希望它可以解决这个问题。
    • 跟进:我在 ILDASM 中编写了一些代码,似乎虽然 add_MyEvent 方法使用了 Delegate::Combine,但在声明“处理程序”以接收“MyEvent”的方法中,它只是放置了“ MyEvent' 在堆栈上。这使我得到了最终答案,发布在下面。
    【解决方案3】:

    我想指出,将此事件与“int”情况进行比较可能本质上是错误的,因为即使“int”是原子的,它也是一种值类型。

    但我认为我们已经解决了这个问题:

    组合操作,例如Combine 和删除,不要改变现有的 代表。相反,这样的操作 返回一个新的委托,其中包含 操作的结果,一个 不变的委托,或为空。一个 组合操作返回 null 时 操作的结果是 未引用 at 的委托 至少一种方法。一个结合 操作返回未更改的 请求操作时委托 没有效果。

    Delegate.CombineImpl Method 显示实现。

    我查看了 .NET 4 源代码中的 Delegate 和 MulticastDelegate 的实现。 它们都没有声明 += 或 -= 运算符。想一想,在 Visual Basic.NET 中你甚至没有它们,你使用 AddHandler 等等......

    这意味着 C# 编译器实现了此功能,并且类型与定义专用运算符没有任何关系。

    所以这让我得出一个合乎逻辑的结论,即当你这样做时:

    EventHandler handler = MyEvent;
    

    C# 编译器将其翻译成

    EventHandler handler = EventHandler.Combine(MyEvent)
    

    感谢您的帮助,我很惊讶这个问题的解决速度如此之快。

    真的非常感谢!

    【讨论】:

    • 这不正确,它只是将支持字段(引用)的值复制到本地。那里没有对 Delegate.Combine 的调用。
    • 为什么要复制它?我提醒您,我们谈论的是引用类型而不是值类型。
    • int 是值类型这一事实不会改变赋值的语义。
    • @Yam:这里似乎有些误解。 @Ani 说 reference 被复制了。这与如果您有两个对同一个 string 对象的引用然后在其中一个上使用 += 时发生的情况相同:它们将不再引用同一个对象。
    • 这里需要明确区分。 Int32 或 String 都没有定义这些运算符。此外,众所周知,C# 编译器在使用运算符连接字符串时具有特殊的行为(例如,它不会在“hello”+“world”中创建 2 个字符串,但因为它识别模式而只创建一个)。这意味着它不是类的行为,而是编译器的行为。这让我相信代表也会发生同样的情况。与整数相同。
    猜你喜欢
    • 2023-04-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-05-20
    • 1970-01-01
    • 2019-06-12
    • 1970-01-01
    相关资源
    最近更新 更多