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

时间:2010-01-23 15:17:01

标签: c# multithreading events locking thread-safety

更新:为了让读者阅读此内容的好处,从.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的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);
}

这样做有什么问题吗?

另外,我首先对锁的必要性感到有些困惑。据我所知,委托被复制到文章的示例中,以避免在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)的委托参数不会反映传递给它的参数(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 个答案:

答案 0 :(得分:7)

锁定的目的是在覆盖默认事件连线时保持线程安全。如果其中一些内容解释了你已经能够从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;
        }
    }
}

就我个人而言,我并不担心这一点,但Jon的理由是合理的。但是,我们确实存在轻微的问题。如果您使用私有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);
    }
}

这个版本有效,因为我们实际上并没有传递EventHandler,只是对它的引用(注意方法签名中的ref )。很遗憾,您无法在扩展方法中将refthis一起使用,因此必须保持纯静态方法。

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

答案 1 :(得分:2)

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

public event Action foo = delegate { };

答案 2 :(得分:2)

在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.

答案 3 :(得分:1)

lock (@lock)
{
    handlerCopy = handler;
}

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

答案 4 :(得分:1)

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

的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内联和删除临时变量的问题。我有限的理解是它是&lt;的有效行为。 .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);
}

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

答案 5 :(得分:0)

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

问题#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在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);
}

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

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

这段代码 需要锁定,因为如果你看一下原始实现,没有重写的访问器方法,你会注意到它默认使用锁定,而我们编写的代码却没有。 / p>

我们可能最终得到一个看起来像这样的执行场景(记住“a + = b”的确意味着“a = a + b”):

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

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

答案 6 :(得分:-2)

我不相信采取副本以避免空值的有效性。当所有订阅者告诉您的班级不与他们交谈时,该事件将为空。 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);
    }
}

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