代码之家  ›  专栏  ›  技术社区  ›  Will Vousden

这是在C中引发事件的有效模式吗?

  •  10
  • Will Vousden  · 技术社区  · 16 年前

    更新 :对于任何阅读此内容的人来说,由于.NET 4,由于自动生成事件同步中的更改,因此不需要锁,因此我现在只使用它:

    public static void Raise<T>(this EventHandler<T> handler, object sender, T e) where T : EventArgs
    {
        if (handler != null)
        {
            handler(sender, e);
        }
    }
    

    提高它:

    SomeEvent.Raise(this, new FooEventArgs());
    

    读过乔恩·斯基特的一本书 articles on multithreading ,我试图封装他提倡的在这样的扩展方法中引发事件的方法(使用类似的通用版本):

    public static void Raise(this EventHandler handler, object @lock, object sender, EventArgs e)
    {
        EventHandler handlerCopy;
        lock (@lock)
        {
            handlerCopy = handler;
        }
    
        if (handlerCopy != null)
        {
            handlerCopy(sender, e);
        }
    }
    

    然后可以这样称呼:

    protected virtual void OnSomeEvent(EventArgs e)
    {
        this.someEvent.Raise(this.eventLock, this, e);
    }
    

    这样做有什么问题吗?

    另外,我对锁的必要性有点困惑。据我所知,在本文的示例中复制了委托,以避免它在空检查和委托调用之间发生更改(并变为空)。但是,我觉得这种访问/分配是原子的,那么为什么需要锁呢?

    更新: 关于下面马克·辛普森的评论,我提出了一个测试:

    static class Program
    {
        private static Action foo;
        private static Action bar;
        private static Action test;
    
        static void Main(string[] args)
        {
            foo = () => Console.WriteLine("Foo");
            bar = () => Console.WriteLine("Bar");
    
            test += foo;
            test += bar;
    
            test.Test();
    
            Console.ReadKey(true);
        }
    
        public static void Test(this Action action)
        {
            action();
    
            test -= foo;
            Console.WriteLine();
    
            action();
        }
    }
    

    此输出:

    Foo
    Bar
    
    Foo
    Bar
    

    这说明方法的委托参数( action )不镜像传递给它的参数( test 我想这是意料之中的。我的问题是,这会影响锁在 Raise 扩展方法?

    更新: 这是我现在使用的代码。它不像我喜欢的那么优雅,但它似乎很管用:

    public static void Raise<T>(this object sender, ref EventHandler<T> handler, object eventLock, T e) where T : EventArgs
    {
        EventHandler<T> copy;
        lock (eventLock)
        {
            copy = handler;
        }
    
        if (copy != null)
        {
            copy(sender, e);
        }
    }
    
    7 回复  |  直到 9 年前
        1
  •  7
  •   Aaronaught    16 年前

    锁定的目的是在覆盖默认事件连接时维护线程安全。如果其中一些解释了你已经能够从乔恩的文章中推断出来的事情,我道歉;我只是想确保我对所有事情都完全清楚。

    如果您像这样声明您的事件:

    public event EventHandler Click;
    

    然后,对事件的订阅将自动与 lock(this) . 你做 需要编写任何特殊的锁定代码来调用事件处理程序。写下以下内容是完全可以接受的:

    var clickHandler = Click;
    if (clickHandler != null)
    {
        clickHandler(this, e);
    }
    

    然而 ,如果您决定覆盖默认事件,即:

    public event EventHandler Click
    {
        add { click += value; }
        remove { click -= value; }
    }
    

    现在你有了一个问题,因为已经没有隐锁了。您的事件处理程序刚刚失去线程安全。这就是你需要使用锁的原因:

    public event EventHandler Click
    {
        add
        {
            lock (someLock)      // Normally generated as lock (this)
            {
                _click += value;
            }
        }
        remove
        {
            lock (someLock)
            {
                _click -= value;
            }
        }
    }
    

    就我个人而言,我不在乎这个,但乔恩的理论是合理的。但是,我们确实有 轻微的 问题。如果你用的是私人的 EventHandler 字段存储您的事件,您可能具有执行此操作的类的内部代码:

    protected virtual void OnClick(EventArgs e)
    {
        EventHandler handler = _click;
        if (handler != null)
        {
            handler(this, e);
        }
    }
    

    这是坏的 ,因为 我们正在访问同一个私有存储字段,而不使用属性使用的同一个锁 .

    如果类外部的一些代码变为:

    MyControl.Click += MyClickHandler;
    

    这个通过公共财产的外部代码是为了维护这个锁。但是 你不是 因为你是在接触私人领域。

    这个 变量赋值 的一部分 clickHandler = _click 是原子的,是的,但是在任务期间, _click 字段可能处于瞬态状态,由外部类编写了一半。同步对字段的访问时,只同步写访问权限是不够的,还必须同步读访问权限:

    protected virtual void OnClick(EventArgs e)
    {
        EventHandler handler;
        lock (someLock)
        {
            handler = _click;
        }
        if (handler != null)
        {
            handler(this, e);
        }
    }
    

    更新

    事实证明,一些围绕评论的对话是正确的,正如OP的更新所证明的那样。这本身不是扩展方法的问题,而是委托具有值类型语义并在分配时得到复制。即使你拿了 this 从扩展方法中取出并作为静态方法调用它,您将得到相同的行为。

    可以 用一个静态实用方法来绕过这个限制(或者特性,取决于您的视角),尽管我很肯定您 不能 使用扩展方法。下面是一个可以工作的静态方法:

    public static void RaiseEvent(ref EventHandler handler, object sync,
        object sender, EventArgs e)
    {
        EventHandler handlerCopy;
        lock (sync)
        {
            handlerCopy = handler;
        }
        if (handlerCopy != null)
        {
            handlerCopy(sender, e);
        }
    }
    

    这个版本是有效的,因为我们实际上没有通过 埃文森德勒 只是一个参考( 注意 ref 在方法签名中 )很遗憾你不能使用 裁判 具有 在一个扩展方法中,因此它必须保持一个普通的静态方法。

    (如前所述,您必须确保通过与 sync 在公共事件中使用的参数;如果传递任何其他对象,则整个讨论都是无意义的。)

        2
  •  2
  •   Shea    16 年前

    我意识到我不是在回答你的问题,但是在引发事件时消除空引用异常可能性的一个简单方法是在声明的站点将所有事件设置为委托。例如:

    public event Action foo = delegate { };
    
        3
  •  2
  •   S.Cheginy    9 年前

    在C中,新的最佳实践是:

      public static void Raise<T>(this EventHandler<T> handler,
      object sender, T e) where T : EventArgs
      {
         handler?.Invoke(sender, e);
      }
    

    You can see this article.

        4
  •  1
  •   codymanix    16 年前
    lock (@lock)
    {
        handlerCopy = handler;
    }
    

    像引用这样的基本类型的赋值是原子的,所以这里没有使用锁的意义。

        5
  •  1
  •   Mark Brackett    16 年前

    “线程安全”事件可能变得相当复杂。您可能会遇到一些不同的问题:

    空引用异常

    最后一个订户可以取消订阅空检查和调用委托之间的订阅,从而导致NullReferenceException。这是一个非常简单的解决方案,您可以锁定调用站点(这不是一个好主意,因为您正在调用外部代码)

    // DO NOT USE - this can cause deadlocks
    void OnEvent(EventArgs e) {
        // lock on this, since that's what the compiler uses. 
        // Otherwise, use custom add/remove handlers and lock on something else.
        // You still have the possibility of deadlocks, though, because your subscriber
        // may add/remove handlers in their event handler.
        //
        // lock(this) makes sure that our check and call are atomic, and synchronized with the
        // add/remove handlers. No possibility of Event changing between our check and call.
        // 
        lock(this) { 
           if (Event != null) Event(this, e);
        }
    }
    

    复制处理程序(推荐)

    void OnEvent(EventArgs e) {
        // Copy the handler to a private local variable. This prevents our copy from
        // being changed. A subscriber may be added/removed between our copy and call, though.
        var h = Event;
        if (h != null) h(this, e);
    }
    

    或者拥有一个始终订阅的空委托。

    EventHandler Event = (s, e) => { }; // This syntax may be off...no compiler handy
    

    注意,选项2(复制处理程序)不需要锁——因为副本是原子的,所以不可能存在不一致性。

    要将其恢复到扩展方法中,您需要对选项2做一些细微的更改。您的副本是在调用扩展方法时发生的,因此您可以只使用以下方法:

    void Raise(this EventHandler handler, object sender, EventArgs e) {
        if (handler != null) handler(sender, e);
    }
    

    可能存在一个抖动问题,即内联和删除临时变量。我有限的理解是它对于<.NET 2.0或ECMA标准是有效的行为,.NET 2.0+增强了保证,使其成为无问题的单声道YMMV。

    陈旧数据

    好吧,我们已经通过复制处理程序解决了nre问题。现在,我们有了第二个过时数据问题。如果订户在我们之间取消订阅,获取副本并调用委托,那么我们仍然会调用它们。可以说,这是不正确的。选项1(锁定调用站点)解决了这个问题,但存在死锁的风险。我们有点陷入困境——我们有两个不同的问题,需要为同一段代码提供两个不同的解决方案。

    由于死锁很难诊断和预防,建议使用选项2。这就要求 被叫者 必须处理被调用,即使在取消订阅之后。它应该足够简单,以便处理程序检查它是否仍然希望/能够被调用,如果不希望/能够被调用,则可以完全退出。

    好吧,那为什么乔恩·斯基特建议锁在一个通风口里?他正在阻止缓存读取成为过时数据的原因。对lock的调用转换为monitor.enter/exit,这两个函数都会生成一个内存屏障,防止对读/写和缓存数据重新排序。出于我们的目的,它们本质上使委托易变——这意味着它不能缓存在CPU寄存器中,每次都必须从主内存中读取更新后的值。这可以防止订阅服务器取消订阅的问题,但事件的值被从不注意的线程缓存。

    结论

    那么,你的代码呢:

    void Raise(this EventHandler handler, object @lock, object sender, EventArgs e) {
         EventHandler handlerCopy;
         lock (@lock) {
            handlerCopy = handler;
         }
    
         if (handlerCopy != null) handlerCopy(sender, e);
    }
    

    好吧,您要复制一个委托(实际上是两次),然后执行一个产生内存屏障的锁。不幸的是,您的锁是在复制本地副本时被占用的,这对于jon skeet试图解决的过时数据问题没有任何作用。您需要这样的东西:

    void Raise(this EventHandler handler, object sender, EventArgs e) {
       if (handler != null) handler(sender, e);
    }
    
    void OnEvent(EventArgs e) {
       // turns out, we don't really care what we lock on since
       // we're using it for the implicit memory barrier, 
       // not synchronization     
       EventHandler h;  
       lock(new object()) { 
          h = this.SomeEvent;
       }
       h.Raise(this, e);
    }
    

    在我看来,这并不是真正的代码。

        6
  •  0
  •   Lasse V. Karlsen    16 年前

    这里有很多问题,我会一次解决一个。

    问题1:您的代码,是否需要锁定?

    首先,您所讨论的代码,不需要锁定该代码。

    换句话说,RAISE方法可以简单地重写为:

    public static void Raise(this EventHandler handler, object sender, EventArgs e)
    {
        if (handler != null)
            handler(sender, e);
    }
    

    这样做的原因是委托是一个不可变的构造,这意味着一旦进入该方法,进入该方法的委托在该方法的生命周期内不会改变。

    即使不同的线程与事件同时发生干扰,也会产生新的委托。对象中的委托对象将不会更改。

    这就是问题1,如果您像以前那样拥有代码,是否需要锁定?答案是否定的。

    问题3:为什么最后一段代码的输出没有改变?

    这可以回到上面的代码。扩展方法已收到其委托的副本,此副本将永远不会更改。“使其更改”的唯一方法是不向方法传递副本,而是像这里的其他答案中所示,为包含委托的字段/变量传递别名。只有这样你才能观察到变化。

    你可以这样看待这个问题:

    private int x;
    
    public void Test1()
    {
        x = 10;
        Test2(x);
    }
    
    public void Test2(int y)
    {
        Console.WriteLine("y = " + y);
        x = 5;
        Console.WriteLine("y = " + y);
    }
    

    在这种情况下,您希望Y变为5吗?不,可能不是,代表也是这样。

    问题3:为什么jon在他的代码中使用了锁?

    为什么乔恩用锁 post: Choosing What To Lock On ?好吧,他的代码不同于您的代码,因为他没有在任何地方传递底层委托的副本。

    在他的代码中,如下所示:

    protected virtual OnSomeEvent(EventArgs e)
    {
        SomeEventHandler handler;
        lock (someEventLock)
        {
            handler = someEvent;
        }
        if (handler != null)
        {
            handler (this, e);
        }
    }
    

    如果他不使用锁,而是这样编写代码:

    protected virtual OnSomeEvent(EventArgs e)
    {
        if (handler != null)
            handler (this, e);
    }
    

    然后,不同的线程可以在表达式计算之间更改“handler”,以确定是否有任何订阅服务器,以及实际调用,换句话说:

    protected virtual OnSomeEvent(EventArgs e)
    {
        if (handler != null)
                             <--- a different thread can change "handler" here
            handler (this, e);
    }
    

    如果他通过了 handler 对于单独的方法,他的代码应该与您的代码类似,因此不需要锁定。

    基本上,将委托值作为参数传递的行为会生成副本,这个“副本”代码是原子代码。如果您对一个不同的线程计时正确,那么该不同的线程要么及时进行更改,以便通过调用获得新的值,要么不进行更改。

    即使在调用时使用锁的一个原因可能是引入了内存屏障,但我怀疑这会对代码产生任何影响。

    这就是问题3,为什么乔恩的代码实际上需要锁。

    问题4:更改默认事件访问器方法怎么样?

    问题4在这里的其他答案中提到,它围绕着在事件上重写默认的添加/删除访问器时需要锁定的问题,以便出于任何原因控制逻辑。

    基本上,不是这样:

    public event EventHandler EventName;
    

    你想写下这个,或者它的一些变体:

    private EventHandler eventName;
    public event EventHandler EventName
    {
        add { eventName += value; }
        remove { eventName -= value; }
    }
    

    本代码 需要锁定,因为如果您查看原始实现,而不使用重写的访问器方法,您会注意到它默认使用锁定,而我们编写的代码则不使用锁定。

    我们可能会以这样的执行场景结束(请记住,“a+=b”实际上意味着“a=a+b”):

    Thread 1              Thread 2
    read eventName
                          read eventName
    add handler1
                          add handler2
    write eventName
                          write eventName  <-- oops, handler1 disappeared
    

    要解决这个问题,您需要锁定。

        7
  •  -2
  •   Steve Cooper    16 年前

    我不相信为了避免空值而复制副本的有效性。当所有订阅服务器 告诉你的学生不要和他们说话。 零均值 没人想听你的活动。 也许监听对象已经被释放了。在这种情况下,复制处理程序只是将问题转移到。现在,您可以调用一个试图取消订阅事件的事件处理程序,而不是调用一个空值。调用复制的处理程序只会将问题从发布服务器移动到订阅服务器。

    我的建议就是试着抓住它;

        if (handler != null)
        {
            try
            {
                handler(this, e);
            }
            catch(NullReferenceException)
            {
            }
        }
    

    我还想看看微软是如何提出最重要的事件的:点击一个按钮。他们只是在基地做这个 Control.OnClick (二)

    protected virtual void OnClick(EventArgs e)
    {
        EventHandler handler = (EventHandler) base.Events[EventClick];
        if (handler != null)
        {
            handler(this, e);
        }
    }
    

    所以,他们复制处理程序,但不锁定它。