简体   繁体   English

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

[英]Is this a valid pattern for raising events in C#?

Update : For the benefit of anyone reading this, since .NET 4, the lock is unnecessary due to changes in synchronization of auto-generated events, so I just use this now: 更新 :为了所有阅读本文的人的利益,自.NET 4起,由于自动生成事件同步的变化,锁定是不必要的,所以我现在就使用它:

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

And to raise it: 并提出它:

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

Having been reading one of Jon Skeet's articles on multithreading , I've tried to encapsulate the approach he advocates to raising an event in an extension method like so (with a similar generic version): 在阅读过Jon Skeet 关于多线程文章之后 ,我试图将他提倡的方法封装在像这样的扩展方法中引发事件(使用类似的通用版本):

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

This can then be called like so: 然后可以这样调用:

protected virtual void OnSomeEvent(EventArgs e)
{
    this.someEvent.Raise(this.eventLock, this, e);
}

Are there any problems with doing this? 这样做有什么问题吗?

Also, I'm a little confused about the necessity of the lock in the first place. 另外,我首先对锁的必要性感到有些困惑。 As I understand it, the delegate is copied in the example in the article to avoid the possibility of it changing (and becoming null) between the null check and the delegate call. 据我所知,委托被复制到文章的示例中,以避免在null检查和委托调用之间更改(并变为null)的可能性。 However, I was under the impression that access/assignment of this kind is atomic, so why is the lock necessary? 但是,我认为这种访问/分配是原子的,为什么锁是必要的呢?

Update: With regards to Mark Simpson's comment below, I threw together a test: 更新:关于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();
    }
}

This outputs: 这输出:

Foo
Bar

Foo
Bar

This illustrates that the delegate parameter to the method ( action ) does not mirror the argument that was passed into it ( test ), which is kind of expected, I guess. 这说明方法( action )的delegate参数不会镜像传递给它的参数( test ),我想这是预期的。 My question is will this affect the validity of the lock in the context of my Raise extension method? 我的问题是,这会影响我的Raise扩展方法中的锁的有效性吗?

Update: Here is the code I'm now using. 更新:这是我现在使用的代码。 It's not quite as elegant as I'd have liked, but it seems to work: 它并不像我喜欢的那么优雅,但似乎有效:

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

The purpose of the lock is to maintain thread-safety when you are overriding the default event wire-ups. 锁定的目的是在覆盖默认事件连线时保持线程安全。 Apologies if some of this is explaining things you were already able to infer from Jon's article; 如果其中一些内容解释了你已经能够从Jon的文章中推断出的东西,那就道歉了; I just want to make sure I'm being completely clear about everything. 我只是想确保我对一切都完全清楚。

If you declare your events like this: 如果你宣布你的事件是这样的:

public event EventHandler Click;

Then subscriptions to the event are automatically synchronized with a lock(this) . 然后,对事件的订阅将自动与lock(this) You do not need to write any special locking code to invoke the event handler. 不需要写任何特殊的锁定代码来调用事件处理程序。 It is perfectly acceptable to write: 写完是完全可以接受的:

var clickHandler = Click;
if (clickHandler != null)
{
    clickHandler(this, e);
}

However , if you decide to override the default events, ie: 但是 ,如果您决定覆盖默认事件,即:

public event EventHandler Click
{
    add { click += value; }
    remove { click -= value; }
}

Now you have a problem, because there's no implicit lock anymore. 现在你遇到了问题,因为不再有隐式锁定了。 Your event handler just lost its thread-safety. 您的事件处理程序刚刚失去了线程安全性。 That's why you need to use a lock: 这就是你需要使用锁的原因:

public event EventHandler Click
{
    add
    {
        lock (someLock)      // Normally generated as lock (this)
        {
            _click += value;
        }
    }
    remove
    {
        lock (someLock)
        {
            _click -= value;
        }
    }
}

Personally, I don't bother with this, but Jon's rationale is sound. 就我个人而言,我并不担心这一点,但乔恩的理由是合理的。 However, we do have a slight problem. 但是,我们确实有一个问题。 If you're using a private EventHandler field to store your event, you probably have code internal to the class that does this: 如果您使用私有EventHandler字段来存储您的事件,那么您可能拥有执行此操作的类的内部代码:

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

This is bad , because we are accessing the same private storage field without using the same lock that the property uses . 这很糟糕 ,因为我们访问相同的私有存储字段而不使用属性使用的相同锁

If some code external to the class goes: 如果该类外部的一些代码:

MyControl.Click += MyClickHandler;

That external code, going through the public property, is honouring the lock. 通过公共财产的外部代码正在兑现锁定。 But you aren't , because you're touching the private field instead. 你不是 ,因为你正在触摸私人领域。

The variable assignment part of clickHandler = _click is atomic, yes, but during that assignment, the _click field may be in a transient state, one that's been half-written by an external class. clickHandler = _click变量赋值部分是原子的,是的,但在该赋值期间, _click字段可能处于瞬态状态,这是由外部类写的一半。 When you synchronize access to a field, it's not enough to only synchronize write access, you have to synchronize read access as well: 当您同步对字段的访问时,仅仅同步写访问权限是不够的,您还必须同步读取访问权限:

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

UPDATE UPDATE

Turns out that some of the dialog flying around the comments was in fact correct, as proven by the OP's update. 事实证明,围绕评论的一些对话实际上是正确的,正如OP的更新所证明的那样。 This isn't an issue with extension methods per se, it is the fact that delegates have value-type semantics and get copied on assignment. 这不是扩展方法本身的问题,它是委托具有值类型语义并在赋值时被复制的事实。 Even if you took the this out of the extension method and just invoked it as a static method, you'd get the same behaviour. 即使你拿着this了扩展方法,只是调用它作为一个静态方法,你会得到相同的行为。

You can get around this limitation (or feature, depending on your perspective) with a static utility method, although I'm pretty sure you can't using an extension method. 可以使用静态实用程序方法绕过此限制(或功能,具体取决于您的观点),但我很确定您无法使用扩展方法。 Here's a static method that will work: 这是一个可行的静态方法:

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

This version works because we aren't actually passing the EventHandler , just a reference to it ( note the ref in the method signature ). 这个版本有效,因为我们实际上并没有传递EventHandler ,只是对它的引用( 请注意方法签名中的ref )。 Unfortunately you can't use ref with this in an extension method so it will have to remain a plain static method. 不幸的是,你不能使用refthis在扩展方法,因此必须保持一个普通的静态方法。

(And as stated before, you have to make sure that you pass the same lock object as the sync parameter that you use in your public events; if you pass any other object then the whole discussion is moot.) (如前所述,您必须确保传递的锁对象与您在公共事件中使用的sync参数相同;如果您传递任何其他对象,则整个讨论都没有实际意义。)

I realize I'm not answering your question, but a simpler approach to eliminating the possibility of a null reference exception when raising an event is to set all events equal to delegate { } at the site of their declaration. 我意识到我没有回答你的问题,但是在引发事件时消除引用异常可能性的简单方法是在声明的站点设置所有事件等于委托{}。 For example: 例如:

public event Action foo = delegate { };

In c# new best practice is: 在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. 你可以看到这篇文章。

lock (@lock)
{
    handlerCopy = handler;
}

Assignment of basic types like a references are atomic, so there is no point of using a lock here. 像引用这样的基本类型的赋值是原子的,所以这里没有使用锁的意义。

"Thread-safe" events can become pretty complicated. “线程安全”事件可能变得非常复杂。 There's a couple of different issues that you could potentially run into: 您可能会遇到几个不同的问题:

NullReferenceException 的NullReferenceException

The last subscriber can unsubscribe between your null check and calling the delegate, causing a NullReferenceException. 最后一个订阅者可以取消订阅您的空检查和调用委托,从而导致NullReferenceException。 This is a pretty easy solution, you can either lock around the callsite (not a great idea, since you're calling external code) 这是一个非常简单的解决方案,您可以锁定呼叫站点(不是一个好主意,因为您正在调用外部代码)

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

copy the handler (recommended) 复制处理程序(推荐)

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

or have a Null delegate that's always subscribed. 或者有一个总是订阅的Null代表。

EventHandler Event = (s, e) => { }; // This syntax may be off...no compiler handy

Note that option 2 (copy the handler) doesn't require a lock - as the copy is atomic, there's no chance for inconsistency there. 请注意,选项2(复制处理程序)不需要锁定 - 因为副本是原子的,因此不存在不一致的可能性。

To bring this back to your extension method, you're doing a slight variation on option 2. Your copy is happening on the call of the extension method, so you can get away with just: 要将此功能恢复到您的扩展方法,您在选项2上略有不同。您的副本是在调用扩展方法时发生的,因此您只需:

void Raise(this EventHandler handler, object sender, EventArgs e) {
    if (handler != null) handler(sender, e);
}

There is possibly an issue of the JITter inlining and removing the temporary variable. 可能存在JITter内联和删除临时变量的问题。 My limited understanding is that it's valid behavior for < .NET 2.0 or ECMA standards - but .NET 2.0+ strengthened the guarantees to make it a non-issue - YMMV on Mono. 我有限的理解是它是<.NET 2.0或ECMA标准的有效行为 - 但是.NET 2.0+加强了使其成为非问题的保证 - 在Mono上的YMMV。

Stale Data 陈旧数据

Okay, so we've solved the NRE by taking a copy of the handler. 好的,所以我们通过获取处理程序的副本来解决NRE问题。 Now, we have the second issue of stale data. 现在,我们有第二期陈旧数据。 If a subscriber unsubscribes between us taking a copy and invoking the delegate, then we will still call them. 如果订阅者取消订阅我们之间的副本并调用该委托,那么我们仍然会调用它们。 Arguably, that's incorrect. 可以说,这是不正确的。 Option 1 (locking the callsite) solves this problem, but at the risk of deadlock. 选项1(锁定调用点)解决了这个问题,但存在死锁的风险。 We're kind of stuck - we have 2 different problems requiring 2 different solutions for the same piece of code. 我们有点卡住了 - 我们有两个不同的问题,需要为同一段代码提供2种不同的解决方案。

Since deadlocks are really hard to diagnose and prevent, it's recommended to go with option 2. That requires that the callee must handle being called even after unsubscribing. 由于死锁确实难以诊断和阻止,因此建议使用选项2.这要求被调用者必须在取消订阅后处理被调用。 It should be easy enough for the handler to check that it still wants/is able to be called, and exit cleanly if not. 它应该很容易让处理程序检查它是否仍然希望/能够被调用,如果没有,则干净地退出。

Okay, so why does Jon Skeet suggest taking a lock in OnEvent? 好吧,为什么Jon Skeet建议在OnEvent中锁定? He's preventing a cached read from being the cause of stale data. 他阻止缓存读取成为陈旧数据的原因。 The call to lock translates to Monitor.Enter/Exit, which both generate a memory barrier that prevents re-ordering of reads/writes and cached data. 对锁的调用转换为Monitor.Enter / Exit,它们都会生成一个内存屏障,阻止读/写和缓存数据的重新排序。 For our purposes, they essentially make the delegate volatile - meaning it can't be cached in a CPU register and must be read from main memory for the updated value each time. 出于我们的目的,它们实质上使委托变得易失 - 意味着它不能缓存在CPU寄存器中,并且必须每次都从主存储器中读取更新的值。 This prevents the issue of a subscriber unsubscribing, but the value of Event being cached by a thread who never notices. 这可以防止订阅者取消订阅的问题,但是由永远不会注意到的线程缓存Event的值。

Conclusion 结论

So, what about your code: 那么,你的代码呢:

void Raise(this EventHandler handler, object @lock, object sender, EventArgs e) {
     EventHandler handlerCopy;
     lock (@lock) {
        handlerCopy = handler;
     }

     if (handlerCopy != null) handlerCopy(sender, e);
}

Well, you're taking a copy of the delegate (twice actually), and performing a lock that generates a memory barrier. 好吧,你正在获取代理的副本(实际上是两次),并执行一个生成内存屏障的锁。 Unfortunately, your lock is taken while copying your local copy - which won't do anything for the stale data problem Jon Skeet is attempting to solve. 不幸的是,在复制本地副本时会锁定您的锁定 - 这对Jon Skeet试图解决的陈旧数据问题无效。 You would need something like: 你需要这样的东西:

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

which doesn't really look like much less code to me. 这对我来说看起来不那么简单。

There's multiple issues at hand here, and I'll deal with them one at a time. 这里有多个问题,我会一次处理一个问题。

Issue #1: Your code, do you need to lock? 问题#1:你的代码,你需要锁定吗?

First of all, the code you have in your question, there is no need for a lock in that code. 首先,您在问题中拥有的代码,不需要锁定该代码。

In other words, the Raise method can be simply rewritten to this: 换句话说,可以简单地将Raise方法重写为:

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

The reason for this is that a delegate is an immutable construct, which means that the delegate you get into your method, once you're in that method, will not change for the lifetime of that method. 这样做的原因是委托是一个不可变的构造,这意味着一旦你进入该方法,你进入你的方法的委托将不会在该方法的生命周期中改变。

Even if a different thread is mucking around with the event simultaneously, that will produce a new delegate. 即使一个不同的线程同时发生事件,也会产生一个新的委托。 The delegate object you have in your object will not change. 您对象中的委托对象不会更改。

So that's issue #1, do you need to lock if you have code like you did. 那么问题#1,如果你有像你这样的代码,你需要锁定吗? The answer is no. 答案是不。

Issue #3: Why did the output from your last piece of code not change? 问题3:为什么最后一段代码的输出没有改变?

This goes back to the above code. 这可以追溯到上面的代码。 The extension method has already received its copy of the delegate, and this copy will never change. 扩展方法已经收到了委托的副本,并且此副本永远不会更改。 The only way to "make it change" is by not passing the method a copy, but instead, as shown in the other answers here, pass it an aliased name for the field/variable containing the delegate. “改变”的唯一方法是不将方法传递给副本,而是如此处的其他答案所示,为包含委托的字段/变量传递别名。 Only then will you observe changes. 只有这样你才能观察到变化。

You can look at this in this manner: 你可以这样看待这个:

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

Would you expect y to change to 5 in this case? 在这种情况下,你会期望y变为5吗? No, probably not, and it's the same with delegates. 不,可能不是,和代表们一样。

Issue #3: Why did Jon use locking in his code? 问题3:为什么Jon在他的代码中使用锁定?

So why did Jon use locking in his post: Choosing What To Lock On ? 那么为什么Jon在他的帖子中使用锁定:选择锁定什么 Well, his code differs from yours in the sense that he didn't pass a copy of the underlying delegate anywhere. 好吧,他的代码与你的代码不同,因为他没有在任何地方传递底层代表的副本。

In his code, which looks like this: 在他的代码中,看起来像这样:

protected virtual OnSomeEvent(EventArgs e)
{
    SomeEventHandler handler;
    lock (someEventLock)
    {
        handler = someEvent;
    }
    if (handler != null)
    {
        handler (this, e);
    }
}

there is a chance that if he didn't use locks, and instead wrote the code like this: 有可能如果他不使用锁,而是像这样编写代码:

protected virtual OnSomeEvent(EventArgs e)
{
    if (handler != null)
        handler (this, e);
}

then a different thread could change "handler" between the expression evaluation to figure out if there are any subscribers, and up to the actual call, in other words: 然后一个不同的线程可以改变表达式评估之间的“处理程序”,以确定是否有任何订阅者,直到实际调用,换句话说:

protected virtual OnSomeEvent(EventArgs e)
{
    if (handler != null)
                         <--- a different thread can change "handler" here
        handler (this, e);
}

If he had passed handler to a separate method, his code would've been similar to yours, and thus required no locking. 如果他将handler传递给一个单独的方法,他的代码将与你的代码类似,因此不需要锁定。

Basically, the act of passing the delegate value as a parameter makes the copy, this "copy" code is atomic. 基本上,将委托值作为参数传递的行为使得复制,这个“复制”代码是原子的。 If you time a different thread right, that different thread will either make its change in time to get the new value with the call, or not. 如果你正确地计算了一个不同的线程,那么不同的线程将及时进行更改,以便通过调用获得新值。

One reason to use a lock even when you call might be to introduce a memory barrier, but I doubt this will have any impact on this code. 即使在您调用时使用锁定的一个原因可能是引入内存屏障,但我怀疑这会对此代码产生任何影响。

So that's issue #3, why Jon's code actually needed the lock. 这就是问题#3,为什么Jon的代码实际上需要锁定。

Issue #4: What about changing the default event accessor methods? 问题#4:如何更改默认事件访问器方法?

Issue 4, which have been brought up in the other answers here revolve around the need for locking when rewriting the default add/remove accessors on an event, in order to control the logic, for whatever reason. 在其他答案中提出的问题4围绕着在重写事件上的默认添加/删除访问器时锁定的需要,以便出于任何原因控制逻辑。

Basically, instead of this: 基本上,而不是这个:

public event EventHandler EventName;

you want to write this, or some variation of it: 你想写这个,或者它的一些变化:

private EventHandler eventName;
public event EventHandler EventName
{
    add { eventName += value; }
    remove { eventName -= value; }
}

This code does need locking, because if you look at the original implementation, without overridden accessor methods, you'll notice that it employs locking by default, whereas the code we wrote does not. 这段代码确实需要锁定,因为如果你看一下原始实现,没有重写的访问器方法,你会注意到它默认使用锁定,而我们编写的代码却没有。

We might end up with an execution scenario that looks like this (remember that "a += b" really means "a = a + b"): 我们可能会得到一个看起来像这样的执行场景(记住“a + = b”实际上意味着“a = a + b”):

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

To fix this, you do need locking. 要解决此问题,您需要锁定。

I'm not convinced about the validity of taking a copy to avoid nulls. 我不相信采取副本以避免空值的有效性。 The event goes null when all subscribers tell your class not to talk to them. 当所有订阅者告诉您的班级不与他们交谈时,该事件将为空 null means no-one wants to hear your event. null表示没有人想听你的活动。 Maybe the object listening has just been disposed. 也许对象的听力刚刚被处理掉了。 In this case, copying the handler just moves the problem about. 在这种情况下,复制处理程序只会移动问题。 Now, instead of getting a call to a null, you get a call to an event handler that's tried to unsubscribe from the event. 现在,您不是调用null,而是调用一个试图取消订阅该事件的事件处理程序。 Calling the copied handler just moves the problem from the publisher to the subscriber. 调用复制的处理程序只会将问题从发布者移动到订阅者。

My suggestion is just to put a try/catch around it; 我的建议只是试一试;

    if (handler != null)
    {
        try
        {
            handler(this, e);
        }
        catch(NullReferenceException)
        {
        }
    }

I also thought I'd check out how microsoft raise the most important event of all; 我还以为我会查看微软如何筹集最重要的事件; clicking a button. 单击按钮。 They just do this, in the base Control.OnClick ; 他们只是在基础Control.OnClick ;

protected virtual void OnClick(EventArgs e)
{
    EventHandler handler = (EventHandler) base.Events[EventClick];
    if (handler != null)
    {
        handler(this, e);
    }
}

so, they copy the handler but don't lock it. 所以,他们复制处理程序但不锁定它。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM