簡體   English   中英

這是在C#中引發事件的有效模式嗎?

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

更新 :為了所有閱讀本文的人的利益,自.NET 4起,由於自動生成事件同步的變化,鎖定是不必要的,所以我現在就使用它:

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

並提出它:

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

在閱讀過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);
    }
}

然后可以這樣調用:

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

這樣做有什么問題嗎?

另外,我首先對鎖的必要性感到有些困惑。 據我所知,委托被復制到文章的示例中,以避免在null檢查和委托調用之間更改(並變為null)的可能性。 但是,我認為這種訪問/分配是原子的,為什么鎖是必要的呢?

更新:關於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<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);
    }
}

鎖定的目的是在覆蓋默認事件連線時保持線程安全。 如果其中一些內容解釋了你已經能夠從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;
        }
    }
}

就我個人而言,我並不擔心這一點,但喬恩的理由是合理的。 但是,我們確實有一個問題。 如果您使用私有EventHandler字段來存儲您的事件,那么您可能擁有執行此操作的類的內部代碼:

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

這很糟糕 ,因為我們訪問相同的私有存儲字段而不使用屬性使用的相同鎖

如果該類外部的一些代碼:

MyControl.Click += MyClickHandler;

通過公共財產的外部代碼正在兌現鎖定。 你不是 ,因為你正在觸摸私人領域。

clickHandler = _click變量賦值部分是原子的,是的,但在該賦值期間, _click字段可能處於瞬態狀態,這是由外部類寫的一半。 當您同步對字段的訪問時,僅僅同步寫訪問權限是不夠的,您還必須同步讀取訪問權限:

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

UPDATE

事實證明,圍繞評論的一些對話實際上是正確的,正如OP的更新所證明的那樣。 這不是擴展方法本身的問題,它是委托具有值類型語義並在賦值時被復制的事實。 即使你拿着this了擴展方法,只是調用它作為一個靜態方法,你會得到相同的行為。

可以使用靜態實用程序方法繞過此限制(或功能,具體取決於您的觀點),但我很確定您無法使用擴展方法。 這是一個可行的靜態方法:

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 )。 不幸的是,你不能使用refthis在擴展方法,因此必須保持一個普通的靜態方法。

(如前所述,您必須確保傳遞的鎖對象與您在公共事件中使用的sync參數相同;如果您傳遞任何其他對象,則整個討論都沒有實際意義。)

我意識到我沒有回答你的問題,但是在引發事件時消除引用異常可能性的簡單方法是在聲明的站點設置所有事件等於委托{}。 例如:

public event Action foo = delegate { };

在c#中,新的最佳實踐是:

  public static void Raise<T>(this EventHandler<T> handler,
  object sender, T e) where T : EventArgs
  {
     handler?.Invoke(sender, e);
  }

你可以看到這篇文章。

lock (@lock)
{
    handlerCopy = handler;
}

像引用這樣的基本類型的賦值是原子的,所以這里沒有使用鎖的意義。

“線程安全”事件可能變得非常復雜。 您可能會遇到幾個不同的問題:

的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(復制處理程序)不需要鎖定 - 因為副本是原子的,因此不存在不一致的可能性。

要將此功能恢復到您的擴展方法,您在選項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中鎖定? 他阻止緩存讀取成為陳舊數據的原因。 對鎖的調用轉換為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);
}

這對我來說看起來不那么簡單。

這里有多個問題,我會一次處理一個問題。

問題#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在他的代碼中使用鎖定?

那么為什么Jon在他的帖子中使用鎖定:選擇鎖定什么 好吧,他的代碼與你的代碼不同,因為他沒有在任何地方傳遞底層代表的副本。

在他的代碼中,看起來像這樣:

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傳遞給一個單獨的方法,他的代碼將與你的代碼類似,因此不需要鎖定。

基本上,將委托值作為參數傳遞的行為使得復制,這個“復制”代碼是原子的。 如果你正確地計算了一個不同的線程,那么不同的線程將及時進行更改,以便通過調用獲得新值。

即使在您調用時使用鎖定的一個原因可能是引入內存屏障,但我懷疑這會對此代碼產生任何影響。

這就是問題#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

要解決此問題,您需要鎖定。

我不相信采取副本以避免空值的有效性。 當所有訂閱者告訴您的班級不與他們交談時,該事件將為空 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);
    }
}

所以,他們復制處理程序但不鎖定它。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM