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

更新 :为了让读者阅读此内容的好处,自.NET 4以来,由于自动生成事件的同步更改,锁定是不必要的,所以我现在就使用它:

public static void Raise(this EventHandler handler, object sender, T e) where T : EventArgs { if (handler != null) { handler(sender, e); } } 

并提出它:

 SomeEvent.Raise(this, new FooEventArgs()); 

在阅读了Jon Skeet 关于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)的可能性。 但是,我认为这种访问/分配是primefaces的,为什么锁是必要的呢?

更新:关于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(this object sender, ref EventHandler handler, object eventLock, T e) where T : EventArgs { EventHandler 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; } } } 

就个人而言,我不打扰这个,但Jon的理由是合理的。 但是,我们确实有一个问题。 如果您使用私有EventHandler字段来存储您的事件,那么您可能拥有执行此操作的类的内部代码:

 protected virtual void OnClick(EventArgs e) { EventHandler handler = _click; if (handler != null) { handler(this, e); } } 

这很糟糕 ,因为我们访问相同的私有存储字段而不使用属性使用的相同锁

如果该类外部的某些代码如下:

 MyControl.Click += MyClickHandler; 

通过公共财产的外部代码正在兑现锁定。 但你不是 ,因为你正在触摸私人领域。

clickHandler = _click变量赋值部分是primefaces的,是的,但是在该赋值期间, _click字段可能处于瞬态状态,这是由外部类写的一半。 当您同步对字段的访问时,仅仅同步写访问权限是不够的,您还必须同步读取访问权限:

 protected virtual void OnClick(EventArgs e) { EventHandler handler; lock (someLock) { handler = _click; } if (handler != null) { handler(this, e); } } 

UPDATE

事实certificate,围绕评论的一些对话实际上是正确的,正如OP的更新所certificate的那样。 这不是扩展方法本身的问题,它是委托具有值类型语义并在赋值时被复制的事实。 即使您从扩展方法中取出它并仅将其作为静态方法调用,您也会得到相同的行为。

可以使用静态实用程序方法绕过此限制(或function,具体取决于您的观点),但我很确定您无法使用扩展方法。 这是一个可行的静态方法:

 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 )。 遗憾的是,您不能在扩展方法中使用ref ,因此它必须保持纯静态方法。

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

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

 public event Action foo = delegate { }; 
 lock (@lock) { handlerCopy = handler; } 

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

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

的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(复制处理程序)不需要锁定 – 因为副本是primefaces的,因此不存在不一致的可能性。

要将此function恢复到您的扩展方法,您在选项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? 他阻止缓存读取成为陈旧数据的原因。 对lock的调用转换为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); } 

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

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

  public static void Raise(this EventHandler handler, object sender, T e) where T : EventArgs { handler?.Invoke(sender, 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在他的代码中使用锁定?

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

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

 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传递给一个单独的方法,他的代码将与你的代码类似,因此不需要锁定。

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

即使在你打电话时使用锁的一个原因可能是引入内存障碍,但我怀疑这会对这段代码产生任何影响。

所以这就是问题#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 

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

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

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