C#事件和线程安全性

244
我经常听到/读到以下建议:
在检查事件是否为null并触发事件之前,始终复制一份事件。这将消除线程中可能出现的问题,即事件在检查为null和触发事件之间的位置变为null:
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list
< p > 更新:我从阅读有关优化的文章中认为,这也可能需要将事件成员设置为volatile,但Jon Skeet在他的答案中指出,CLR不会优化掉副本。

但与此同时,为了发生这种情况,另一个线程必须执行类似以下操作的内容:

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...

实际的顺序可能是这样的混合物:
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;

// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;    
// Good, now we can be certain that OnTheEvent will not run...

if (copy != null)
    copy(this, EventArgs.Empty); // Call any handlers on the copied list

重点在于OnTheEvent在作者取消订阅后运行,然而他们刚刚取消订阅是为了避免这种情况发生。真正需要的是一个自定义事件实现,在addremove访问器中进行适当的同步。此外,如果在触发事件时保持锁定,则可能会出现死锁问题。

那么这是Cargo Cult Programming吗?看起来是这样 - 很多人必须采取这一步骤来保护他们的代码免受多个线程的影响,但实际上,在将事件用作多线程设计的一部分之前,它们需要更加小心谨慎。因此,不注意这些额外细节的人可以忽略这个建议 - 这对于单线程程序根本不是问题,事实上,考虑到大多数在线示例代码中缺少volatile,该建议可能根本没有任何效果。

(而且,只需在成员声明上分配空的delegate { }就可以简单地避免在第一次检查null的情况下检查)。

更新:如果不清楚,我已经理解了建议的意图——在任何情况下都要避免空引用异常。我的观点是这种特定的空引用异常只有在另一个线程从事件中注销时才会发生,而这样做的唯一原因是确保不会通过该事件接收到进一步的调用,而显然这种技术并没有实现这一点。你将掩盖竞态条件——揭示它会更好!那个空异常有助于检测组件的滥用。如果你想保护你的组件免受滥用,你可以遵循WPF的例子——在构造函数中存储线程ID,然后如果另一个线程试图直接与你的组件交互,抛出异常。或者实现一个真正的线程安全组件(这不是一项容易的任务)。

因此,我认为仅仅做这个复制/检查习惯是盲目模仿编程,会给你的代码增加混乱和噪音。要真正保护其他线程免受影响,需要更多的工作。

回应Eric Lippert博客文章的更新:

所以关于事件处理程序我错过了一个重要的事情:"即使在事件取消订阅后被调用,事件处理程序也必须具有强大的鲁棒性",因此我们只需要关注事件委托可能为null的情况。 这个事件处理程序的要求在哪里记录?

所以:"解决这个问题还有其他方法;例如,将处理程序初始化为具有永远不会被删除的空操作。但进行空检查是标准模式。"

所以我问题的唯一剩余部分是,为什么显式空检查是“标准模式”?另一种选择是分配空委托,只需要在事件声明中添加= delegate {},就可以消除在每个引发事件的地方都存在的那些小臭堆。很容易确保空委托的实例化成本很低。或者我还是遗漏了什么吗?肯定是(正如Jon Skeet所建议的那样),这只是.NET 1.x的建议,它应该在2005年就已经消失了,对吧?

更新

截至C# 6,这个问题的答案是:

SomeEvent?.Invoke(this, e);

4
这个问题是之前内部讨论中提出的,我一直打算写博客来阐述这个问题。关于此主题的我的文章在这里:Events and Races - Eric Lippert
4
Stephen Cleary在**CodeProject文章**中探讨了这个问题,并得出结论,没有一种通用的“线程安全”解决方案。基本上,事件调用者需要确保委托不为空,事件处理程序需要能够处理在其取消订阅后被调用的情况。 - rkagerer
3
@rkagerer - 实际上,即使没有涉及线程,第二个问题有时也必须由事件处理程序处理。如果一个事件处理程序告诉另一个处理程序取消订阅当前正在处理的事件,但是那个第二个订阅者仍然会收到该事件(因为它在处理过程中取消了订阅),这种情况就会发生。 - Daniel Earwicker
3
将订阅添加到没有订阅者的事件、删除该事件仅有的一个订阅、调用没有订阅者的事件以及调用仅有一个订阅者的事件,这些操作都比涉及其他数量订阅者的添加/删除/调用场景要快得多。添加虚拟委托会降低常见情况的速度。C#真正的问题在于其创建者决定使EventName(arguments)无条件地调用事件的委托,而不是只在委托非空时调用它(如果为空则不执行任何操作)。 - supercat
我把答案从问题的顶部移到了底部。理想情况下,问题不应该包含答案,在我看来。 - Theodor Zoulias
15个回答

103

因为存在条件限制,所以JIT不能执行你在第一部分提到的优化。我知道这是一段时间前的一个问题,但现在已经无效了。(我之前和Joe Duffy或Vance Morrison其中之一核实过,但我不记得是哪个人了。)

如果没有加上volatile修饰符,可能会导致本地副本过期,但仅此而已。它不会导致NullReferenceException

是的,肯定存在竞态条件 - 但总是会有的。假设我们只是将代码更改为:

TheEvent(this, EventArgs.Empty);
现在假设该委托的调用列表有1000个条目。很可能在该列表开头的动作已经执行,而另一个线程却取消了接近列表末尾的处理程序。但是,由于它将成为一个新列表,因此仍将执行该处理程序。 (委托是不可变的。)就我所知,这是不可避免的。
使用空委托确实避免了nullity检查,但无法解决竞争条件问题。它也不能保证您始终“看到”变量的最新值。

5
乔·达菲的《Windows并发编程》涵盖了有关JIT优化和内存模型方面的问题;请参阅http://code.logos.com/blog/2008/11/events_and_threads_part_4.html。 - Bradley Grainger
2
我接受了有关“标准”建议是基于C#2之前的评论,并且我没有听到任何人对此提出异议。除非实例化事件参数真的很昂贵,否则只需在事件声明的末尾放置“= delegate {}”,然后直接调用事件,就像它们是方法一样;永远不要将它们赋值为null。(我提到确保处理程序在取消注册后不会被调用的其他内容都是无关紧要的,即使对于单线程代码也是如此,例如,如果处理程序1请求处理程序2取消注册,处理程序2仍将被下一次调用。) - Daniel Earwicker
2
唯一的问题情况(一如既往)是结构体,你无法确保它们将被实例化为成员中的任何值而不是空值。但结构体很糟糕。 - Daniel Earwicker
1
关于空委托,请参见此问题:https://dev59.com/VXVC5IYBdhLWcg3w1E1q。 - Vladimir
2
@Tony:在订阅/取消订阅某个事件和委托被执行之间仍然存在基本的竞争条件。浏览了您的代码后,我发现它通过允许订阅/取消订阅在被引发时生效来减少这种竞争条件,但我怀疑在大多数情况下,如果正常行为不够好,那么这也不够好。 - Jon Skeet
显示剩余9条评论

52

我看到很多人选择使用扩展方法来完成这个任务...

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

这使得您可以更好地语法来触发事件...

MyEvent.Raise( this, new MyEventArgs() );

同时,它在方法调用时捕获本地副本并将其删除。

10
我喜欢这个语法,但需要明确一点...它不能解决即使已注销仍会被调用的陈旧处理程序问题。这仅仅解决了空引用问题。虽然我喜欢这个语法,但我怀疑它是否真的比以下代码更好:public event EventHandler<T> MyEvent = delete {}; ... MyEvent (this, new MyEventArgs());这也是一个非常简单易用的低摩擦力解决方案,我也很喜欢。 - Simon Gillbee
@Simon 我看到不同的人在这方面有不同的说法。我已经测试过了,我的测试结果表明它确实处理了 null 处理程序问题。即使原始 Sink 在处理程序!= null 检查后取消注册事件,事件仍然会被触发,且不会引发任何异常。 - JP Alioto
是的,参考这个问题:https://dev59.com/pXVC5IYBdhLWcg3ww0Dr - Benjol
1
+1. 我刚刚自己编写了这个方法,开始考虑线程安全性,做了一些研究,然后偶然发现了这个问题。 - Niels van der Rest
这个怎么从VB.NET中调用?或者说“RaiseEvent”已经适用于多线程场景了吗? - user11937
@SimonGillbee 处理程序应该优雅地处理即使在注销后被调用的情况。 - Monstieur

36

"为什么显式空值检查是'标准模式'?"

我怀疑这个原因可能是空值检查更加高效。

如果你总是在创建事件时订阅一个空委托,那么会有一些开销:

  • 构造空委托的成本。
  • 构造包含它的委托链的成本。
  • 每次触发事件时都调用无意义委托的成本。

(请注意,UI控件通常具有大量事件,其中大多数从未被订阅。必须为每个事件创建虚拟订阅器并调用它可能会对性能产生重大影响。)

我进行了一些初步的性能测试,以查看订阅空委托方法的影响,并得出以下结果:

Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      432ms
OnClassicNullCheckedEvent took: 490ms
OnPreInitializedEvent took:     614ms <--
Subscribing an empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      674ms
OnClassicNullCheckedEvent took: 674ms
OnPreInitializedEvent took:     2041ms <--
Subscribing another empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took:      2011ms
OnClassicNullCheckedEvent took: 2061ms
OnPreInitializedEvent took:     2246ms <--
Done

需要注意的是,对于订阅者数为零或一个的情况(对于 UI 控件很常见,因为事件很多),预先初始化为空委托的事件明显较慢(在超过5000万次迭代时...)

有关更多信息和源代码,请访问我在此问题发布前一天发布的博客文章.NET事件调用线程安全性

(我的测试设置可能有缺陷,所以请随意下载源代码并自行检查。非常感谢您的任何反馈。)


8
我认为你在博客文章中提出了关键观点:在没有成为瓶颈之前,没有必要担心性能影响。为什么要让丑陋的方式成为推荐的方式?如果我们想要过早优化而不是清晰易懂,我们就会使用汇编语言 - 因此我的问题仍然存在,我认为可能的答案只是该建议还不包括匿名委托,并且人类文化需要很长时间才能转变老旧的建议,就像著名的“烤牛肉故事”一样。 - Daniel Earwicker
13
你的数据很好地证明了这一点:与传统的null值相比,每个事件引发的开销只有两个半纳秒(!!!)。这在几乎所有具有实际工作负载的应用程序中都是无法检测到的,但考虑到绝大部分事件的使用都集中在GUI框架中,你需要将其与Winforms等屏幕重绘成本进行比较,因此在实际的CPU工作和等待资源的汹涌中,这种差异更加微不足道。无论如何,你为辛勤工作获得了我的赞赏。 :) - Daniel Earwicker
1
@DanielEarwicker说得对,你让我相信了使用公共事件WrapperDoneHandler OnWrapperDone =(x,y)=> {};的模式。 - Mickey Perlstein
2
在事件订阅者为零、一个或两个的情况下,对Delegate.Combine/Delegate.Remove进行计时也是一个好主意;如果重复添加和删除相同的委托实例,则各种情况之间的成本差异将特别明显,因为当其中一个参数为null时,Combine具有快速的特殊行为(只返回另一个),而当两个参数相等时,Remove非常快(只返回null)。 - supercat

12

我读了这篇文章,真的是不怎么喜欢它!尽管我需要使用C#中的事件特性,但为什么不在编译器中修复这个问题呢?我知道微软的人员会阅读这些帖子,所以请不要攻击我!

1 - 空值问题) 为什么不将事件初始化为.Empty而不是null?这样可以省去多少行代码进行空值检查或者在声明时添加= delegate {}? 让编译器处理空值情况,即什么也不做!如果事件的创建者认为这很重要,他们可以检查.Empty并对其进行相关处理!否则,所有的空值检查 / 委托添加都是解决这个问题的Hack。

老实说,我已经厌倦了每个事件都要这样做——也就是样板代码!

public event Action<thisClass, string> Some;
protected virtual void DoSomeEvent(string someValue)
{
  var e = Some; // avoid race condition here! 
  if(null != e) // avoid null condition here! 
     e(this, someValue);
}

2-竞态条件问题)我阅读了Eric的博客文章,我同意H(处理程序)应该在解除引用时进行处理,但事件不能被设置为不可变/线程安全吗?例如,在其创建时设置锁定标志,以便每次调用它时,在其执行期间锁定所有订阅和取消订阅它的操作?

结论,

难道现代语言不应该为我们解决这类问题吗?


同意,编译器应该有更好的支持。在那之前,我创建了一个PostSharp方面,在后编译步骤中执行此操作。 :) - Steven Jeuris
4
在等待任意外部代码完成时,发送和取消订阅请求会导致线程被堵塞,这比让订阅者在取消订阅后仍然收到事件要糟糕得多,因为后者的“问题”可以通过让事件处理器检查一个标志来解决,以确定它们是否仍然对接收其事件感兴趣,但是前一种设计可能导致死锁且难以解决。 - supercat
@supercat。在我看来,“更糟”的评论相当于应用程序相关的。如果有这个选项,谁不想要非常严格的锁定而无需额外的标志呢?如果事件处理线程正在等待另一个线程(即订阅/取消订阅),则只有在发生死锁时才会发生,因为锁是同一线程可重入的,原始事件处理程序中的订阅/取消订阅不会被阻塞。如果作为事件处理程序的一部分存在跨线程等待,则这将是我更喜欢重新设计的设计的一部分。我从具有可预测模式的服务器端应用程序角度出发。 - crokusek
2
@crokusek: 如果在连接每个锁到所有可能需要的锁的有向图中没有循环(缺少循环证明系统无死锁),那么证明一个系统没有死锁所需的分析是容易的。允许在持有锁时调用任意代码将为 "可能需要" 图中创建从该锁到任何可能获取的锁的边缘(不是系统中的每个锁,但离这不远)。因此,存在循环并不意味着会发生死锁,但是... - supercat
1
这将大大增加必要的分析水平,以证明它是不可能的。 - supercat

9

C# 6及以上版本中,可以使用新的?.运算符简化代码,例如:

TheEvent?.Invoke(this, EventArgs.Empty);

这里是MSDN文档链接。


6
根据Jeffrey Richter在书籍CLR via C#中的说法,正确的方法是:
// Copy a reference to the delegate field now into a temporary field for thread safety
EventHandler<EventArgs> temp =
Interlocked.CompareExchange(ref NewMail, null, null);
// If any methods registered interest with our event, notify them
if (temp != null) temp(this, e);

因为它强制进行引用复制。 更多信息,请参阅书中的事件部分。

也许我错过了什么,但是如果Interlocked.CompareExchange的第一个参数为空,它会抛出NullReferenceException,而这正是我们想要避免的。http://msdn.microsoft.com/en-us/library/bb297966.aspx - Kniganapolke
2
如果以某种方式传递了一个空的ref参数,Interlocked.CompareExchange将会失败,但这与传递一个对存储位置(例如NewMail)的ref参数并不相同,该位置存在并且最初保存一个空引用。 - supercat

4

我一直在使用这种设计模式来确保在取消订阅之后不会执行事件处理程序。目前为止效果还不错,尽管我还没有尝试过任何性能分析。

private readonly object eventMutex = new object();

private event EventHandler _onEvent = null;

public event EventHandler OnEvent
{
  add
  {
    lock(eventMutex)
    {
      _onEvent += value;
    }
  }

  remove
  {
    lock(eventMutex)
    {
      _onEvent -= value;
    }
  }

}

private void HandleEvent(EventArgs args)
{
  lock(eventMutex)
  {
    if (_onEvent != null)
      _onEvent(args);
  }
}

我最近大多数时间都在使用Mono for Android,但是当一个Activity被发送到后台后,Android似乎不喜欢你尝试更新View。


实际上,我看到有人在这里使用非常相似的模式:https://dev59.com/g3A65IYBdhLWcg3wuRF8 - Ash

3
这个实践不是为了执行某种操作顺序,而是为了避免空引用异常。
人们关注空引用异常而不是竞态条件背后的理由可能需要进行一些深入的心理研究。我认为这与解决空引用问题更容易有关。一旦解决了这个问题,他们就会在代码上挂上一个大大的“使命完成”的横幅并解开他们的飞行服。
注意: 解决竞态条件可能涉及使用同步标志来跟踪处理程序是否应该运行。

1
那就是我的观点。他们不关心竞态条件,只关心空引用异常。我会在我的答案中进行编辑。 - dss539
4
一个正确编写的事件处理程序应该准备好处理这样一个事实:任何特定的请求引发事件,它的处理可能与添加或删除它的请求重叠,可能会或可能不会引发正在被添加或删除的事件。程序员不关心竞争条件的原因是,在正确编写的代码中,“谁赢了”并不重要。 - supercat
@dss539:不仅在处理程序中通常很容易解决问题,而且通常不可能在其他任何地方强有力地解决问题。对于事件处理程序来说,处理它被取消订阅后几分钟才被调用的事实并不比处理它在事件被订阅但在事件被取消订阅之前无法完成任何操作更难。事件分派代码唯一可以防范后一种情况的方法是不允许在分派事件后取消订阅事件。 - supercat
2
@dss539:虽然可以设计一个事件框架,阻止取消订阅请求直到挂起的事件调用完成,但这样的设计将使任何事件(甚至像“卸载”事件这样的事件)无法安全地取消对象对其他事件的订阅。很糟糕。更好的方法是简单地说,事件取消订阅请求将导致事件最终被取消订阅,并且事件订阅者应该在调用时检查是否有任何有用的事情要做。 - supercat
在许多情况下,事件订阅者的作者将比事件发布者更了解引发事件的线程情况。例如,订阅集合更改事件的代码通常会比更改集合的代码的作者更了解线程方案。集合的作者通常不应该试图强制其消费者实现线程安全,但应避免对希望自行实现线程安全的消费者造成任何不必要的障碍。 - supercat
显示剩余7条评论

1

我来这里有些晚了。 :)

关于使用 null 而不是 null 对象模式表示没有订阅者的事件,考虑以下情况。您需要调用一个事件,但构造对象(EventArgs)是棘手的,在常见情况下,您的事件没有订阅者。如果您能够优化代码以在处理努力构建参数并调用事件之前检查是否有任何订阅者,那对您会很有好处。

有鉴于此,一种解决方案是说“嗯,零个订阅者表示为 null。” 然后只需在执行昂贵的操作之前执行空值检查即可。我想另一种方法是在 Delegate 类型上拥有 Count 属性,因此只有当 myDelegate.Count > 0 时才执行昂贵的操作。使用 Count 属性是一种相当不错的模式,它解决了允许优化的原始问题,并且还具有能够在不引发 NullReferenceException 的情况下调用的好属性。

请注意,委托是引用类型,因此允许为空。也许在支持事件的空对象模式下,没有好的方法来隐藏这个事实,所以另一种选择可能是强制开发人员检查空和零订阅者。那将比当前情况更丑陋。
注意:这只是纯粹的猜测。我与.NET语言或CLR无关。

我猜你的意思是“使用空委托而不是...”你已经可以按照你所建议的方式做了,使用初始化为空委托的事件。如果初始空委托是列表中唯一的内容,则测试(MyEvent.GetInvocationList().Length == 1)将为真。仍然没有必要先复制。虽然我认为你描述的情况非常罕见。 - Daniel Earwicker
我认为我们在混淆委托和事件的概念。如果我的类上有一个事件Foo,那么当外部用户调用MyType.Foo += / -=时,实际上他们正在调用add_Foo()和remove_Foo()方法。然而,当我在定义Foo的类内部引用Foo时,我实际上是直接引用底层委托,而不是add_Foo()和remove_Foo()方法。并且随着EventHandlerList等类型的存在,没有什么强制要求委托和事件甚至在同一个地方。这就是我在我的回答中所说的“请记住”的段落的意思。 - Levi
我承认这是一个令人困惑的设计,但另一种选择可能会更糟。因为最终你只有一个委托 - 你可以直接引用底层的委托,也可以从集合中获取它,或者在运行时实例化它 - 技术上可能不可行支持除“检查 null”模式以外的任何其他模式。 - Levi
既然我们正在谈论触发事件,我不明白为什么在这里add/remove访问器很重要。 - Daniel Earwicker
@Levi:我真的不喜欢C#处理事件的方式。如果我有选择权,我会给委托一个与事件不同的名称。在类外部,对事件名称唯一允许的操作是+=-=。在类内部,允许的操作还包括调用(带有内置的空值检查)、测试是否为null或设置为null。对于其他任何操作,都必须使用名称为事件名称加上某个特定前缀或后缀的委托。 - supercat

0
请看这里:http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety 这是正确的解决方案,应该始终使用,而不是其他所有的解决方法。
“您可以通过使用一个无操作的匿名方法来初始化内部调用列表,以确保它始终至少有一个成员。由于没有外部方可以引用匿名方法,因此也没有外部方可以删除该方法,因此委托永远不会为 null。” ——《Programming .NET Components, 2nd Edition》作者Juval Löwy
public static event EventHandler<EventArgs> PreInitializedEvent = delegate { };  

public static void OnPreInitializedEvent(EventArgs e)  
{  
    // No check required - event will never be null because  
    // we have subscribed an empty anonymous delegate which  
    // can never be unsubscribed. (But causes some overhead.)  
    PreInitializedEvent(null, e);  
}  

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接