ChatGPT解决这个技术问题 Extra ChatGPT

C# 事件和线程安全

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

在检查 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

更新:我从阅读有关优化的文章中想到,这可能还需要事件成员是易失的,但 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 开始,这个问题的 the answer 是:

SomeEvent?.Invoke(this, e);
这个问题是在不久前的一次内部讨论中提出的;我已经打算在博客上写这个已经有一段时间了。我关于这个主题的帖子在这里:Events and Races
Stephen Cleary 有一个 CodeProject article 来检查这个问题,他总结说不存在通用的“线程安全”解决方案。基本上,由事件调用者确保委托不为空,并由事件处理程序在取消订阅后处理被调用。
@rkagerer - 实际上第二个问题有时必须由事件处理程序处理,即使不涉及线程。如果一个事件处理程序告诉另一个处理程序取消订阅当前正在处理的事件,则可能会发生这种情况,但是第二个订阅者无论如何都会收到该事件(因为它在处理过程中取消订阅)。
添加订阅到零订阅者的事件、移除事件的唯一订阅、调用零订阅者的事件以及调用只有一个订阅者的事件,这些操作都比涉及其他数量的添加/删除/调用场景快得多订户。添加一个虚拟委托会减慢常见情况。 C# 的真正问题是它的创建者决定让 EventName(arguments) 无条件地调用事件的委托,而不是让它只在非空时调用委托(如果为空则不执行任何操作)。
我将答案从问题的顶部移到了底部。理想情况下,问题不应该包括答案恕我直言。

J
Jon Skeet

由于条件的原因,不允许 JIT 执行您在第一部分中谈论的优化。我知道这是不久前提出的一个幽灵,但它是无效的。 (我不久前曾与 Joe Duffy 或 Vance Morrison 核对过;我不记得是哪个了。)

如果没有 volatile 修饰符,获取的本地副本可能会过时,但仅此而已。它不会导致 NullReferenceException

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

TheEvent(this, EventArgs.Empty);

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

使用空委托当然可以避免无效性检查,但不能修复竞争条件。它也不能保证您总是“看到”变量的最新值。


Joe Duffy 的“Windows 上的并发编程”涵盖了问题的 JIT 优化和内存模型方面;见code.logos.com/blog/2008/11/events_and_threads_part_4.html
我已经接受了这一点,基于关于 C#2 之前的“标准”建议的评论,我没有听到任何人反驳这一点。除非实例化您的事件参数真的很昂贵,否则只需将“=委托{}”放在事件声明的末尾,然后直接调用您的事件,就好像它们是方法一样;永远不要将 null 分配给他们。 (我带来的关于确保在除名后不调用处理程序的其他内容,这都是无关紧要的,并且无法确保,即使对于单线程代码也是如此,例如,如果处理程序 1 要求处理程序 2 删除,处理程序 2 仍将被调用下一个。)
唯一的问题案例(一如既往)是结构,您无法确保它们将在其成员中使用空值以外的任何值进行实例化。但是结构很糟糕。
关于空委托,另请参阅此问题:stackoverflow.com/questions/170907/…
@Tony:订阅/取消订阅的东西和被执行的委托之间仍然存在根本的竞争条件。您的代码(刚刚浏览过它)通过允许订阅/取消订阅在引发时生效来减少竞争条件,但我怀疑在大多数情况下正常行为不够好,这也不是。
J
JP Alioto

我看到很多人都在朝着这样做的扩展方法...

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

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


我喜欢这种语法,但让我们明确一点……它并不能解决陈旧的处理程序被调用的问题,即使它已被取消注册。这只解决了空解引用问题。虽然我喜欢这种语法,但我怀疑它是否真的比: public event EventHandler MyEvent = delete {}; ... MyEvent (this, new MyEventArgs());这也是一个非常低摩擦的解决方案,我喜欢它的简单性。
@Simon 我看到不同的人对此有不同的看法。我已经对其进行了测试,并且我所做的向我表明这确实可以处理 null 处理程序问题。即使原始 Sink 在处理程序 != null 检查后从事件中注销,仍然会引发事件并且不会引发异常。
是的,参考这个问题:stackoverflow.com/questions/192980/…
+1。我自己写了这个方法,开始考虑线程安全,做了一些研究,偶然发现了这个问题。
如何从 VB.NET 调用它?或者“RaiseEvent”是否已经迎合了多线程场景?
D
Daniel Fortunov

“为什么显式空检查'标准模式'?”

我怀疑其原因可能是空检查性能更高。

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

构建空委托的成本。

构建委托链以包含它的成本。

每次引发事件时调用无意义委托的成本。

(请注意,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

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

有关更多信息和源代码,请访问我在提出此问题前一天发布的 .NET Event invocation thread safety 上的这篇博文(!)

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


我认为您在博客文章中提出了关键点:在成为瓶颈之前无需担心性能影响。为什么让丑陋的方式成为推荐的方式?如果我们想要过早的优化而不是清晰,我们会使用汇编程序 - 所以我的问题仍然存在,我认为可能的答案是建议只是早于匿名代表,人类文化需要很长时间才能改变旧的建议,比如在著名的“火锅故事”中。
您的数据很好地证明了这一点:每个引发的事件(pre-init vs. classic-null)的开销下降到只有两个半 NANOSECONDS(!!!)。这在几乎有实际工作要做的应用程序中是无法检测到的,但鉴于绝大多数事件使用是在 GUI 框架中,您必须将其与在 Winforms 等中重新绘制部分屏幕的成本进行比较,所以在大量实际 CPU 工作和等待资源的情况下,它变得更加不可见。无论如何,你会因为我的辛勤工作而得到 +1。 :)
@DanielEarwicker 说的对,你让我成为了公众事件 WrapperDoneHandler OnWrapperDone =(x,y)=> {}; 的信徒模型。
在事件有零个、一个或两个订阅者的情况下,也可以为 Delegate.Combine/Delegate.Remove 对计时;如果重复添加和删除相同的委托实例,则案例之间的成本差异将特别明显,因为当参数之一为 null(仅返回另一个)和 {5 } 当两个参数相等时非常快(只返回 null)。
C
Chuck Savage

我真的很喜欢这篇文章——不!即使我需要它来处理称为事件的 C# 功能!

为什么不在编译器中解决这个问题?我知道有 MS 人读过这些帖子,所以请不要喷这个!

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

老实说,我厌倦了必须对每个事件都这样做 - 也就是样板代码!

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(处理程序)在取消引用自身时应该处理,但不能将事件设为不可变/线程安全吗? IE,在其创建时设置一个锁定标志,以便每当它被调用时,它会在执行时锁定所有订阅和取消订阅它?

结论,

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


同意,编译器应该对此有更好的支持。在那之前,我created a PostSharp aspect which does this in a post-compile step。 :)
在等待任意外部代码完成时阻塞线程订阅/取消订阅请求比让订阅者在订阅被取消后接收事件要糟糕得多,特别是因为后一个“问题”可以通过简单地让事件处理程序检查一个标志来轻松解决他们仍然有兴趣接收他们的事件,但前一种设计导致的死锁可能难以解决。
@超级猫。海事组织,“更糟糕”的评论非常依赖于应用程序。谁不想在没有额外标志的情况下进行非常严格的锁定?仅当事件处理线程正在等待另一个线程(即订阅/取消订阅)时才会发生死锁,因为锁是同一线程可重入的,并且不会阻止原始事件处理程序中的订阅/取消订阅。如果有跨线程等待作为事件处理程序的一部分,这将是设计的一部分,我宁愿返工。我来自具有可预测模式的服务器端应用程序角度。
@crokusek:如果在有向图中没有循环将每个锁连接到持有锁时可能需要的所有锁,那么证明系统没有死锁所需的分析很容易[缺乏循环证明系统无死锁]。允许在持有锁时调用任意代码将在“可能需要”图中创建一条边,从该锁到任意代码可能获取的任何锁(不是系统中的每个锁,但离它不远)。随之而来的循环的存在并不意味着会发生死锁,但是......
...将大大提高证明它不可能的必要分析水平。
I
Izaak van Dongen

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

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

Here 是 MSDN 文档。


a
alyx

根据 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);

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


可能是我错过了 smth,但是如果 Interlocked.CompareExchange 的第一个参数为 null 则抛出 NullReferenceException,这正是我们想要避免的。 msdn.microsoft.com/en-us/library/bb297966.aspx
如果以某种方式将 Interlocked.CompareExchange 传递给 null ref,它会失败,但这与将 ref 传递到存在且最初 持有NewMail)不同i> 空引用。
A
Ash

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

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 发送到后台后尝试更新 View 时,Android 似乎不喜欢它。


实际上,我看到其他人在这里使用了非常相似的模式:stackoverflow.com/questions/3668953/…
d
dss539

这种做法并不是要强制执行特定的操作顺序。它实际上是关于避免空引用异常。

人们关心空引用异常而不是竞争条件的原因需要一些深入的心理学研究。我认为这与修复空引用问题要容易得多这一事实有关。一旦解决了这个问题,他们就会在代码上挂一个大大的“任务完成”横幅,然后解开飞行服的拉链。

注意:修复竞争条件可能涉及使用同步标志跟踪处理程序是否应该运行


我不是要求解决这个问题。我想知道为什么有广泛的建议在事件触发周围喷洒额外的代码混乱,当它只在存在难以检测的竞争条件时避免空异常,这种情况仍然存在。
那就是我的意思。他们不关心比赛条件。他们只关心空引用异常。我会将其编辑到我的答案中。
我的观点是:为什么关心空引用异常而不关心竞争条件是有意义的?
正确编写的事件处理程序应该准备好处理这样一个事实,即引发事件的任何特定请求可能会与添加或删除它的请求重叠,最终可能会或可能不会引发正在添加或删除的事件。程序员不关心竞争条件的原因是,在正确编写的代码中,谁赢并不重要。
@dss539:虽然可以设计一个事件框架来阻止取消订阅请求,直到挂起的事件调用完成,但这样的设计会使任何事件(甚至像 Unload 事件)都无法安全地取消对象的订阅其他事件。讨厌。更好的是简单地说事件取消订阅请求将导致事件“最终”被取消订阅,并且事件订阅者应该在调用它们时检查是否有任何对他们有用的事情要做。
L
Levi

所以我在这里聚会有点晚了。 :)

至于使用 null 而不是 null 对象模式来表示没有订阅者的事件,请考虑这种情况。您需要调用一个事件,但构造对象 (EventArgs) 并非易事,而且在通常情况下,您的事件没有订阅者。如果您可以优化您的代码以检查您是否有任何订阅者,然后再提交处理工作以构造参数和调用事件,这对您将是有益的。

考虑到这一点,一个解决方案是说“嗯,零订阅者由 null 表示”。然后在执行昂贵的操作之前简单地执行空检查。我想另一种方法是在 Delegate 类型上使用 Count 属性,因此只有在 myDelegate.Count > 0 时才执行昂贵的操作。使用 Count 属性是一种很好的模式,可以解决原始问题允许优化,并且它还具有能够在不导致 NullReferenceException 的情况下被调用的良好属性。

但是请记住,由于委托是引用类型,因此它们可以为空。也许根本没有什么好的方法可以隐藏这个事实并仅支持事件的空对象模式,因此替代方案可能会迫使开发人员同时检查空订阅者和零订阅者。那将比现在的情况更丑陋。

注意:这是纯粹的猜测。我不涉及 .NET 语言或 CLR。


我假设您的意思是“使用空委托而不是......”您已经可以按照您的建议进行操作,并将事件初始化为空委托。如果初始的空委托是列表中唯一的东西,则测试 (MyEvent.GetInvocationList().Length == 1) 将为真。仍然没有必要先复制。虽然我认为你描述的情况无论如何都会非常罕见。
我认为我们在这里将代表和活动的想法混为一谈。如果我的班级上有一个事件 Foo,那么当外部用户调用 MyType.Foo += / -= 时,他们实际上是在调用 add_Foo() 和 remove_Foo() 方法。但是,当我从定义它的类中引用 Foo 时,我实际上是直接引用底层委托,而不是 add_Foo() 和 remove_Foo() 方法。并且由于 EventHandlerList 等类型的存在,没有什么要求委托和事件甚至在同一个地方。这就是我在回复中所说的“记住”段落的意思。
(续)我承认这是一个令人困惑的设计,但替代方案可能更糟。因为最终你所拥有的只是一个委托——你可能直接引用底层委托,你可能从一个集合中获取它,你可能会即时实例化它——它可能在技术上不支持除了“检查空”模式。
当我们谈论触发事件时,我不明白为什么添加/删除访问器在这里很重要。
@Levi:我真的不喜欢 C# 处理事件的方式。如果我有我的 druthers,代表将被赋予与活动不同的名称。在类外部,对事件名称的唯一允许操作是 +=-=。在该类中,允许的操作还包括调用(使用内置的 null 检查)、针对 null 进行测试或设置为 null。对于其他任何事情,都必须使用名称为带有特定前缀或后缀的事件名称的委托。
J
Jason Coyne

对于单线程应用程序,您是正确的,这不是问题。

但是,如果您正在制作一个公开事件的组件,则无法保证您的组件的使用者不会使用多线程,在这种情况下,您需要为最坏的情况做好准备。

使用空委托确实可以解决问题,但也会导致每次调用事件时性能受到影响,并且可能会产生 GC 影响。

您是对的,消费者尝试取消订阅以使这种情况发生,但如果他们通过临时副本,则考虑该消息已经在传输中。

如果你不使用临时变量,也不使用空委托,并且有人取消订阅,你会得到一个空引用异常,这是致命的,所以我认为这个成本是值得的。


G
Greg D

我从来没有真正认为这是一个很大的问题,因为我通常只在我的可重用组件上的静态方法(等)中防止这种潜在的线程错误,并且我不制作静态事件。

我做错了吗?


如果您分配具有可变状态(更改其值的字段)的类的实例,然后让多个线程同时访问同一个实例,而不使用锁定来保护这些字段不被两个线程同时修改,您就是可能做错了。如果您的所有线程都有自己的单独实例(不共享任何内容)或所有对象都是不可变的(一旦分配,它们的字段值永远不会改变),那么您可能没问题。
我的一般方法是将同步留给调用者,静态方法除外。如果我是调用者,那么我将在更高级别进行同步。 (当然,唯一目的是处理同步访问的对象除外。:))
@GregD 这取决于方法的复杂程度以及它使用的数据。如果它影响内部成员,并且您决定在线程/任务状态下运行,那么您将受到很大伤害
T
Triynko

在施工中连接您的所有活动,不要管它们。 Delegate 类的设计不可能正确处理任何其他用法,我将在本文的最后一段中解释。

首先,当您的事件处理程序必须已经就是否/如何响应通知做出同步决定时,尝试拦截事件通知是没有意义的。

任何可能被通知的事情,都应该被通知。如果您的事件处理程序正确处理通知(即他们可以访问权威的应用程序状态并仅在适当时响应),那么随时通知他们并相信他们会正确响应就可以了。

唯一不应该通知处理程序事件发生的情况是事件实际上没有发生!因此,如果您不希望通知处理程序,请停止生成事件(即禁用控件或首先负责检测和使事件存在的任何东西)。

老实说,我认为 Delegate 类是无法挽救的。向 MulticastDelegate 的合并/转换是一个巨大的错误,因为它有效地将事件的(有用的)定义从发生在某个时刻的事情变成了在一段时间内发生的事情。这种变化需要一种同步机制,可以在逻辑上将其折叠回单个瞬间,但 MulticastDelegate 缺少任何此类机制。同步应该包括事件发生的整个时间跨度或瞬间,以便一旦应用程序做出开始处理事件的同步决定,它就会完全(事务性地)完成处理。使用 MulticastDelegate/Delegate 混合类的黑盒,这几乎是不可能的,因此请坚持使用单订阅者和/或实现您自己的 MulticastDelegate 类型,它具有可以在处理程序链运行时取出的同步句柄正在使用/修改。我推荐这个,因为替代方案是在所有处理程序中冗余地实现同步/事务完整性,这将是荒谬/不必要的复杂。


[1] 没有有用的事件处理程序会在“一个瞬间”发生。所有操作都有时间跨度。任何单个处理程序都可以执行一系列重要的步骤。支持处理程序列表不会改变任何事情。
[2] 在触发事件时持有锁是完全疯狂的。它不可避免地导致僵局。源取出锁 A,触发事件,接收器取出锁 B,现在持有两个锁。如果另一个线程中的某些操作导致锁以相反的顺序取出怎么办?当锁定的责任在单独设计/测试的组件(这是事件的全部重点)之间划分时,如何排除这种致命的组合?
[3] 这些问题都不会以任何方式降低普通多播委托/事件在组件的单线程组合中的普遍有用性,尤其是在 GUI 框架中。这个用例涵盖了事件的绝大多数用途。以自由线程方式使用事件的价值值得怀疑;这不会以任何方式使它们的设计或它们在有意义的上下文中的明显有用性失效。
[4] 线程+同步事件本质上是一条红鲱鱼。排队异步通信是要走的路。
[1] 我指的不是测量的时间……我指的是原子操作,它在逻辑上是瞬间发生的……我的意思是,在事件发生时,涉及他们使用的相同资源的其他任何事情都不会改变因为它是用锁序列化的。
o
ollo

请在此处查看:http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety 这是正确的解决方案,应始终使用,而不是所有其他解决方法。

“您可以通过使用无操作匿名方法对其进行初始化来确保内部调用列表始终具有至少一个成员。因为没有外部方可以引用匿名方法,所以没有外部方可以删除该方法,因此委托永远不会为空” — 编程 .NET 组件,第 2 版,作者: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);  
}  

C
Community

我不相信这个问题仅限于 c#“事件”类型。消除这个限制,为什么不重新发明轮子并按照这些思路做点什么?

Raise event thread safely - best practice

能够在提升期间从任何线程订阅/取消订阅(竞争条件已删除)

+= 和 -= 在类级别的运算符重载。

通用调用者定义的委托


T
Tony

感谢您的有用讨论。我最近正在解决这个问题,并制作了以下课程,它有点慢,但可以避免调用已处置的对象。

这里的要点是即使引发事件也可以修改调用列表。

/// <summary>
/// Thread safe event invoker
/// </summary>
public sealed class ThreadSafeEventInvoker
{
    /// <summary>
    /// Dictionary of delegates
    /// </summary>
    readonly ConcurrentDictionary<Delegate, DelegateHolder> delegates = new ConcurrentDictionary<Delegate, DelegateHolder>();

    /// <summary>
    /// List of delegates to be called, we need it because it is relatevely easy to implement a loop with list
    /// modification inside of it
    /// </summary>
    readonly LinkedList<DelegateHolder> delegatesList = new LinkedList<DelegateHolder>();

    /// <summary>
    /// locker for delegates list
    /// </summary>
    private readonly ReaderWriterLockSlim listLocker = new ReaderWriterLockSlim();

    /// <summary>
    /// Add delegate to list
    /// </summary>
    /// <param name="value"></param>
    public void Add(Delegate value)
    {
        var holder = new DelegateHolder(value);
        if (!delegates.TryAdd(value, holder)) return;

        listLocker.EnterWriteLock();
        delegatesList.AddLast(holder);
        listLocker.ExitWriteLock();
    }

    /// <summary>
    /// Remove delegate from list
    /// </summary>
    /// <param name="value"></param>
    public void Remove(Delegate value)
    {
        DelegateHolder holder;
        if (!delegates.TryRemove(value, out holder)) return;

        Monitor.Enter(holder);
        holder.IsDeleted = true;
        Monitor.Exit(holder);
    }

    /// <summary>
    /// Raise an event
    /// </summary>
    /// <param name="args"></param>
    public void Raise(params object[] args)
    {
        DelegateHolder holder = null;

        try
        {
            // get root element
            listLocker.EnterReadLock();
            var cursor = delegatesList.First;
            listLocker.ExitReadLock();

            while (cursor != null)
            {
                // get its value and a next node
                listLocker.EnterReadLock();
                holder = cursor.Value;
                var next = cursor.Next;
                listLocker.ExitReadLock();

                // lock holder and invoke if it is not removed
                Monitor.Enter(holder);
                if (!holder.IsDeleted)
                    holder.Action.DynamicInvoke(args);
                else if (!holder.IsDeletedFromList)
                {
                    listLocker.EnterWriteLock();
                    delegatesList.Remove(cursor);
                    holder.IsDeletedFromList = true;
                    listLocker.ExitWriteLock();
                }
                Monitor.Exit(holder);

                cursor = next;
            }
        }
        catch
        {
            // clean up
            if (listLocker.IsReadLockHeld)
                listLocker.ExitReadLock();
            if (listLocker.IsWriteLockHeld)
                listLocker.ExitWriteLock();
            if (holder != null && Monitor.IsEntered(holder))
                Monitor.Exit(holder);

            throw;
        }
    }

    /// <summary>
    /// helper class
    /// </summary>
    class DelegateHolder
    {
        /// <summary>
        /// delegate to call
        /// </summary>
        public Delegate Action { get; private set; }

        /// <summary>
        /// flag shows if this delegate removed from list of calls
        /// </summary>
        public bool IsDeleted { get; set; }

        /// <summary>
        /// flag shows if this instance was removed from all lists
        /// </summary>
        public bool IsDeletedFromList { get; set; }

        /// <summary>
        /// Constuctor
        /// </summary>
        /// <param name="d"></param>
        public DelegateHolder(Delegate d)
        {
            Action = d;
        }
    }
}

用法是:

    private readonly ThreadSafeEventInvoker someEventWrapper = new ThreadSafeEventInvoker();
    public event Action SomeEvent
    {
        add { someEventWrapper.Add(value); }
        remove { someEventWrapper.Remove(value); }
    }

    public void RaiseSomeEvent()
    {
        someEventWrapper.Raise();
    }

测试

我以以下方式对其进行了测试。我有一个线程可以创建和销毁这样的对象:

var objects = Enumerable.Range(0, 1000).Select(x => new Bar(foo)).ToList();
Thread.Sleep(10);
objects.ForEach(x => x.Dispose());

Bar(侦听器对象)构造函数中,我订阅了 SomeEvent(如上所示实现)并在 Dispose 中取消订阅:

    public Bar(Foo foo)
    {
        this.foo = foo;
        foo.SomeEvent += Handler;
    }

    public void Handler()
    {
        if (disposed)
            Console.WriteLine("Handler is called after object was disposed!");
    }

    public void Dispose()
    {
        foo.SomeEvent -= Handler;
        disposed = true;
    }

我也有几个线程在循环中引发事件。

所有这些操作都是同时执行的:创建和销毁许多侦听器,同时触发事件。

如果有竞争条件,我应该在控制台中看到一条消息,但它是空的。但是如果我像往常一样使用 clr 事件,我会看到它充满了警告消息。因此,我可以得出结论,可以在 c# 中实现线程安全事件。

你怎么看?


在我看来已经足够好了。尽管我认为在您的测试应用程序中,disposed = true 可能(理论上)发生在 foo.SomeEvent -= Handler 之前,从而产生误报。但除此之外,您可能还需要更改一些内容。您确实想使用 try ... finally 作为锁 - 这将帮助您使其不仅是线程安全的,而且也是中止安全的。更不用说你可以摆脱那个愚蠢的try catch。而且您没有检查在 Add/Remove 中传递的委托 - 它可能是 null(您应该立即在 Add/Remove 中丢弃)。