繁体   English   中英

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

[英]Is this a valid pattern for raising events in C#?

更新 :为了所有阅读本文的人的利益,自.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());

在阅读过Jon Skeet 关于多线程文章之后 ,我试图将他提倡的方法封装在像这样的扩展方法中引发事件(使用类似的通用版本):

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);
}

这样做有什么问题吗?

另外,我首先对锁的必要性感到有些困惑。 据我所知,委托被复制到文章的示例中,以避免在null检查和委托调用之间更改(并变为null)的可能性。 但是,我认为这种访问/分配是原子的,为什么锁是必要的呢?

更新:关于Mark Simpson在下面的评论,我总结了一个测试:

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 )的delegate参数不会镜像传递给它的参数( 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);
    }
}

锁定的目的是在覆盖默认事件连线时保持线程安全。 如果其中一些内容解释了你已经能够从Jon的文章中推断出的东西,那就道歉了; 我只是想确保我对一切都完全清楚。

如果你宣布你的事件是这样的:

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);
    }
}

UPDATE

事实证明,围绕评论的一些对话实际上是正确的,正如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);
    }
}

这个版本有效,因为我们实际上并没有传递EventHandler ,只是对它的引用( 请注意方法签名中的ref )。 不幸的是,你不能使用refthis在扩展方法,因此必须保持一个普通的静态方法。

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

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

public event Action foo = delegate { };

在c#中,新的最佳实践是:

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

你可以看到这篇文章。

lock (@lock)
{
    handlerCopy = handler;
}

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

“线程安全”事件可能变得非常复杂。 您可能会遇到几个不同的问题:

的NullReferenceException

最后一个订阅者可以取消订阅您的空检查和调用委托,从而导致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);
}

或者有一个总是订阅的Null代表。

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);
}

可能存在JITter内联和删除临时变量的问题。 我有限的理解是它是<.NET 2.0或ECMA标准的有效行为 - 但是.NET 2.0+加强了使其成为非问题的保证 - 在Mono上的YMMV。

陈旧数据

好的,所以我们通过获取处理程序的副本来解决NRE问题。 现在,我们有第二期陈旧数据。 如果订阅者取消订阅我们之间的副本并调用该委托,那么我们仍然会调用它们。 可以说,这是不正确的。 选项1(锁定调用点)解决了这个问题,但存在死锁的风险。 我们有点卡住了 - 我们有两个不同的问题,需要为同一段代码提供2种不同的解决方案。

由于死锁确实难以诊断和阻止,因此建议使用选项2.这要求被调用者必须在取消订阅后处理被调用。 它应该很容易让处理程序检查它是否仍然希望/能够被调用,如果没有,则干净地退出。

好吧,为什么Jon Skeet建议在OnEvent中锁定? 他阻止缓存读取成为陈旧数据的原因。 对锁的调用转换为Monitor.Enter / Exit,它们都会生成一个内存屏障,阻止读/写和缓存数据的重新排序。 出于我们的目的,它们实质上使委托变得易失 - 意味着它不能缓存在CPU寄存器中,并且必须每次都从主存储器中读取更新的值。 这可以防止订阅者取消订阅的问题,但是由永远不会注意到的线程缓存Event的值。

结论

那么,你的代码呢:

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);
}

这对我来说看起来不那么简单。

这里有多个问题,我会一次处理一个问题。

问题#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在他的代码中使用锁定?

那么为什么Jon在他的帖子中使用锁定:选择锁定什么 好吧,他的代码与你的代码不同,因为他没有在任何地方传递底层代表的副本。

在他的代码中,看起来像这样:

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);
}

然后一个不同的线程可以改变表达式评估之间的“处理程序”,以确定是否有任何订阅者,直到实际调用,换句话说:

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

如果他将handler传递给一个单独的方法,他的代码将与你的代码类似,因此不需要锁定。

基本上,将委托值作为参数传递的行为使得复制,这个“复制”代码是原子的。 如果你正确地计算了一个不同的线程,那么不同的线程将及时进行更改,以便通过调用获得新值。

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

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

问题#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

要解决此问题,您需要锁定。

我不相信采取副本以避免空值的有效性。 当所有订阅者告诉您的班级不与他们交谈时,该事件将为空 null表示没有人想听你的活动。 也许对象的听力刚刚被处理掉了。 在这种情况下,复制处理程序只会移动问题。 现在,您不是调用null,而是调用一个试图取消订阅该事件的事件处理程序。 调用复制的处理程序只会将问题从发布者移动到订阅者。

我的建议只是试一试;

    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);
    }
}

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM