C#事件和线程安全

Dan*_*ker 230 c# events multithreading

UPDATE

从C#6开始,这个问题的答案是:

SomeEvent?.Invoke(this, e);
Run Code Online (Sandbox Code Playgroud)

我经常听到/阅读以下建议:

在检查事件之前,请务必复制事件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
Run Code Online (Sandbox Code Playgroud)

更新:我从阅读中了解到这可能还需要事件成员的优化,但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...
Run Code Online (Sandbox Code Playgroud)

实际的顺序可能是这种混合物:

// 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
Run Code Online (Sandbox Code Playgroud)

关键是OnTheEvent在作者取消订阅之后运行,但他们只是取消订阅以避免发生这种情况.当然,真正需要的是一个自定义事件实现,并在addremove访问器中进行适当的同步.此外,如果在触发事件时保持锁定,则存在可能发生死锁的问题.

货物崇拜编程也是如此?这似乎是这样 - 许多人必须采取这一步骤来保护他们的代码免受多线程的攻击,而实际上在我看来事件需要更多的关注才能被用作多线程设计的一部分. .因此,没有采取额外关注的人可能会忽略这一建议 - 这对于单线程程序来说根本不是问题,事实上,鉴于volatile大多数在线示例代码缺少,建议可能没有效果.

(并且delegate { }在成员声明中分配空,以便您从不需要首先检查,这不是更简单null吗?)

更新:如果不清楚,我确实掌握了建议的意图 - 在所有情况下避免空引用异常.我的观点是,这个特定的空引用异常只有在另一个线程从事件中退出时才会发生,这样做的唯一原因是确保不会通过该事件接收到进一步的调用,这显然不是通过这种技术实现的. .你会隐瞒竞争条件 - 揭示它会更好!该null异常有助于检测组件的滥用情况.如果您希望保护组件免受滥用,可以按照WPF的示例 - 将线程ID存储在构造函数中,然后在另一个线程尝试直接与组件交互时抛出异常.或者实现真正的线程安全组件(不是一件容易的事).

所以我认为仅仅做这个复制/检查成语是货物崇拜编程,为你的代码添加混乱和噪音.要实际防范其他线程需要更多的工作.

更新以回应Eric Lippert的博文:

因此,对于事件处理程序我有一个重要的事情:"即使在事件被取消订阅后,事件处理程序也必须是强大的,"因此显然我们只需关心事件的可能性代表是null.对事件处理程序的要求是否记录在何处?

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

所以我的问题的另一个片段是,为什么显式 - 空 - 检查"标准模式"?另外,分配空委托只= delegate {}需要添加到事件声明中,这样就可以从事件发生的每个地方消除那些一堆臭味的仪式.很容易确保空委托实例化很便宜.还是我还缺少什么?

肯定一定是(正如Jon Skeet建议的那样)这只是.NET 1.x的建议,并没有像2005年应该做的那样消失吗?

Jon*_*eet 98

由于条件的原因,JIT不允许在第一部分中执行您正在讨论的优化.我知道这是作为一个幽灵提出的,但它无效.(我刚才和Joe Duffy或Vance Morrison一起检查过;我不记得是哪一个.)

如果没有volatile修饰符,那么本地副本可能会过时,但就是这样.它不会导致NullReferenceException.

是的,肯定存在竞争条件 - 但总会如此.假设我们只是将代码更改为:

TheEvent(this, EventArgs.Empty);
Run Code Online (Sandbox Code Playgroud)

现在假设该委托的调用列表有1000个条目.在另一个线程取消订阅列表末尾附近的处理程序之前,列表开头的操作完全可能已执行.但是,该处理程序仍将执行,因为它将是一个新列表.(代表是不可改变的.)据我所知,这是不可避免的.

使用空委托当然可以避免无效检查,但不能修复竞争条件.它也不能保证您始终"看到"变量的最新值.

  • Joe Duffy的"Windows上的并发编程"涵盖了问题的JIT优化和内存模型方面; 请参阅http://code.logos.com/blog/2008/11/events_and_threads_part_4.html (4认同)
  • 我已经根据关于"标准"建议的评论接受了这一点,这个建议是在C#2之前,我没有听到任何人反驳这一点.除非实例化事件args真的很昂贵,只需在事件声明结束时加上'= delegate {}',然后直接调用事件,就像它们是方法一样; 永远不会给它们分配null.(我带来了关于确保处理其他的东西退市后不叫,这是所有不相关的,并且无法保证,甚至对于单线程代码,例如,如果处理1请求处理2退市,操作机2仍然会被调用下一个.) (2认同)
  • 唯一的问题是(一如既往)是结构体,在这种情况下,您无法确保它们将在其成员中使用除null值之外的任何值进行实例化.结构糟透了. (2认同)
  • @Tony:订阅/取消订阅和正在执行的委托之间仍存在根本性的竞争条件.你的代码(刚刚浏览过它)通过允许订阅/取消订阅在提升时生效来减少竞争条件,但我怀疑在大多数情况下正常行为不够好,这也不是. (2认同)

JP *_*oto 50

我看到很多人都在采用这种扩展方法......

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);   
  }   
}
Run Code Online (Sandbox Code Playgroud)

这为您提供了更好的语法来提升事件......

MyEvent.Raise( this, new MyEventArgs() );
Run Code Online (Sandbox Code Playgroud)

并且还取消了本地副本,因为它是在方法调用时捕获的.

  • 我喜欢语法,但是我们要清楚......即使在取消注册之后,它也无法解决过时的处理程序被调用的问题.这***解决了空解除引用问题.虽然我喜欢这种语法,但我怀疑它是否真的好于:公共事件EventHandler <T> MyEvent = delete {}; ... MyEvent(这个,新的MyEventArgs()); 这也是一个非常低摩擦的解决方案,我喜欢它的简单性. (9认同)

Dan*_*nov 33

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

我怀疑这可能是因为空检查更具性能.

如果您在创建事件时始终为事件订阅空委托,则会产生一些开销:

  • 构建空委托的成本.
  • 构建委托链以包含它的成本.
  • 每次引发事件时调用无意义委托的成本.

(请注意,UI控件通常具有大量事件,其中大多数事件从未订阅过.必须为每个事件创建一个虚拟订阅者然后调用它可能会对性能造成重大影响.)

我做了一些粗略的性能测试,看看subscribe-empty-delegate方法的影响,这是我的结果:

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
Run Code Online (Sandbox Code Playgroud)

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

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

(我的测试设置可能存在缺陷,因此请随意下载源代码并自行检查.非常感谢任何反馈.)

  • 而你的数字证明了这一点非常好:每个事件引发的开销只有两个半NANOSECONDS(!!!)(pre-init vs. classic-null).在具有实际工作的几乎所有应用程序中都无法检测到这一点,但鉴于绝大多数事件使用都在GUI框架中,您必须将其与在Winforms等中重新绘制部分屏幕的成本进行比较,因此它在真正的CPU工作和等待资源的过程中变得更加隐形.无论如何,你从努力工作中获得+1.:) (13认同)
  • 我认为你在博客文章中提到了关键点:在它成为瓶颈之前,没有必要担心性​​能影响.为什么让丑陋的方式成为推荐的方式?如果我们想要过早优化而不是清晰,我们将使用汇编程序 - 所以我的问题仍然存在,我认为可能的答案是,建议只是在匿名代表之前,人类文化需要很长时间才能转移旧的建议,比如在着名的"火锅烤肉故事"中. (8认同)
  • 如果事件有零个、一个或两个订阅者,则最好对“Delegate.Combine”/“Delegate.Remove”对进行计时;如果重复添加和删除同一个委托实例,则情况之间的成本差异将特别明显,因为当参数之一为“null”(仅返回另一个)时,“Combine”具有快速的特殊情况行为,而“Remove”则具有快速的特殊情况行为。 ` 当两个参数相等时(仅返回 null),速度非常快。 (2认同)

Chu*_*age 11

我真的很喜欢这个读 - 不!即使我需要它来使用称为事件的C#功能!

为什么不在编译器中修复它?我知道有MS人阅读这些帖子,所以请不要点燃这个!

1 - Null问题)为什么不首先使事件成为.Empty而不是null?将多少行代码保存用于空检查或必须粘贴= delegate {}到声明上?让编译器处理Empty case,IE什么都不做!如果这对事件的创建者都很重要,他们可以检查.Empty并做任何他们关心的事情!否则所有null检查/委托添加都是围绕问题的黑客!

老实说,我已经厌倦了每次活动都必须这样做 - 也就是样板代码!

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);
}
Run Code Online (Sandbox Code Playgroud)

2 - 竞争条件问题)我读过Eric的博客文章,我同意H(处理程序)应该在它自我解除引用时处理,但是不能使事件变为不可变/线程安全吗?IE,在其创建时设置一个锁定标志,这样无论何时调用它,它都会在执行时锁定所有订阅和取消订阅?

结论,

是不是现代语言应该为我们解决这些问题?

  • 在等待任意外部代码完成时,启动线程订阅/取消订阅请求阻止*比订阅者在订阅被取消后接收事件更糟糕*,特别是因为后者"问题"可以通过简单地让事件处理程序检查来轻松解决*看看他们是否仍然有兴趣接收他们的活动,但是前设计导致的死锁可能是棘手的. (4认同)
  • @crokusek:如果在有向图中没有循环将每个锁连接到*可能*需要的所有锁定时[*缺乏],则需要*证明系统没有死锁的分析很容易循环证明系统无死锁].允许在保持锁定时调用任意代码将在"可能需要"的图形中创建一个边缘,从该锁到任意代码可能获取的任何锁(不是系统中的每个锁,但距离它不远) ).随之而来的循环存在并不意味着会发生僵局,但...... (2认同)

Phi*_*970 8

对于C# 6及更高版本,可以使用 new?.运算符简化代码,如下所示:

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

是 MSDN 文档。


aly*_*lyx 5

根据Jeffrey Richter在书中通过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);
Run Code Online (Sandbox Code Playgroud)

因为它强制引用副本.有关更多信息,请参阅本书中的"事件"部分.

  • 如果以某种方式传递了一个空的“ref”,“Interlocked.CompareExchange”将会失败,但这与将“ref”传递到存在且最初*保存*一个存储位置(例如“NewMail”)不同。空引用。 (2认同)