Thread-sichere Ereignisse in C # oder John Skeet gegen Jeffrey Richter



    Ich habe mich irgendwie auf ein Interview auf C # vorbereitet und unter anderem eine Frage zu folgenden Inhalten gefunden:
    Msgstr "Wie organisiere ich einen thread-sicheren Ereignisaufruf in C #, da eine große Anzahl von Threads ein Ereignis ständig abonniert und abbestellt?"


    Die Frage ist ganz konkret und klar gestellt, so dass ich nicht einmal daran gezweifelt habe, dass die Antwort darauf auch klar und eindeutig gegeben werden kann. Aber ich habe mich sehr geirrt. Es stellte sich heraus, dass dies ein äußerst beliebtes, ramponiertes, aber immer noch offenes Thema ist. Ich bemerkte auch ein nicht sehr angenehmes Feature - in russischsprachigen Ressourcen wird diesem Thema nur sehr wenig Aufmerksamkeit geschenkt (und Habr ist da keine Ausnahme), sodass ich mich entschied, alle Informationen zu sammeln, die ich zu diesem Thema gefunden und verarbeitet habe.
    Wir kommen auch zu John Skeet und Jeffrey Richter, die tatsächlich eine Schlüsselrolle für mein gemeinsames Verständnis des Problems der Funktionsweise von Ereignissen in einer Umgebung mit mehreren Threads gespielt haben.

    Ein besonders aufmerksamer Leser findet im Artikel zwei Comics im xkcd-Stil.
    (Achtung, innerhalb der beiden Bilder sind ca. 300-400 kb)


    Dupliziere die Frage, die beantwortet werden muss:

    Msgstr "Wie organisiere ich einen thread-sicheren Ereignisaufruf in C #, da eine große Anzahl von Threads ein Ereignis ständig abonniert und abbestellt?"


    Ich ging davon aus, dass einige der Fragen auf der CLR über C # basieren, zumal mein Lieblings- C # 5.0 in aller Kürze eine solche Frage überhaupt nicht ansprach. Beginnen wir also mit Jeffrey Richter (CLR über C #).

    Der Weg von Jeffrey Richter


    Ein kleiner Auszug aus dem Geschriebenen:

    Die empfohlene Methode zum Auslösen von Ereignissen war lange Zeit ungefähr die folgende Konstruktion:

    Option 1:
    public event Action MyLittleEvent;
    ...
    protected virtual void OnMyLittleEvent()
    {
        if (MyLittleEvent != null) MyLittleEvent();
    }
    


    Проблема подобного подхода в том, что в методе OnMyLittleEvent один поток может увидеть, что наше событие MyLittleEvent не равно null, а другой поток, сразу после этой проверки, но перед вызовом события, может убрать свой делегат из списка подписчиков, и таким образом сделать из нашего события MyLittleEvent null, что приведет к выбросу NullReferenceException в месте вызова события.

    Вот небольшой комикс в стиле xkcd, который наглядно иллюстрирует эту ситуацию (два потока работают параллельно, время идет сверху вниз):
    Развернуть



    Im Allgemeinen ist alles logisch, wir haben die üblichen Rennbedingungen (im Folgenden - Rennbedingungen). Und so löst Richter dieses Problem (und diese Option tritt am häufigsten auf):

    Fügen Sie unserer Methode zum Aufrufen des Ereignisses eine lokale Variable hinzu, in die wir unser Ereignis zum Zeitpunkt des „Aufrufs“ der Methode kopieren. Da es sich bei den Delegierten um unveränderliche Objekte handelt (im Folgenden: unveränderliche Objekte), erhalten wir eine "eingefrorene" Kopie der Veranstaltung, von der sich niemand abmelden kann. Wenn Sie sich von einem Ereignis abmelden, wird ein neues Stellvertreterobjekt erstellt, das das Objekt im Feld ersetzt MyLittleEvent, während wir immer noch einen lokalen Verweis auf das alte Stellvertreterobjekt haben.

    Option 2:
    protected virtual void OnMyLittleEvent()
    {
        Action tempAction = MyLittleEvent; // "Заморозили" наше событие для текущего метода
        // На наш tempAction теперь никто не может повлиять, он никогда не станет равен null ни при каких условиях
        if (tempAction != null) tempAction (); 
    }
    


    Richter beschreibt weiter, dass der JIT-Compiler zur Optimierung einfach die Erstellung einer lokalen Variablen weglassen und die erste aus der zweiten Option auswählen kann, dh das "Einfrieren" -Ereignis überspringen kann. Am Ende wird empfohlen, dass Sie durch kopieren Volatile.Read(ref MyLittleEvent), das heißt:

    Option 3:
    protected virtual void OnMyLittleEvent()
    {
        // Ну теперь уж точно заморозили
        Action tempAction = Volatile.Read(ref MyLittleEvent); 
        if (tempAction != null) tempAction (); 
    }
    


    Sie Volatilekönnen lange Zeit separat darüber sprechen, aber im Allgemeinen können Sie "nur unerwünschte JIT-Compiler-Optimierung entfernen". Es wird weitere Klarstellungen und Details zu diesem Thema geben, aber wir werden uns vorerst auf die allgemeine Idee der aktuellen Entscheidung von Jeffrey Richter konzentrieren:

    Um einen thread-sicheren Ereignisaufruf zu gewährleisten, müssen Sie die aktuelle Liste der Abonnenten "einfrieren", indem Sie das Ereignis in eine lokale Variable kopieren. Wenn die empfangene Liste nicht leer ist, rufen Sie alle Handler aus der "eingefrorenen" Liste auf. So werden wir das Mögliche losNullReferenceException.


    Ich war sofort verwirrt von der Tatsache, dass wir Ereignisse für nicht abonnierte Objekte / Threads auslösen . Es ist unwahrscheinlich, dass sich jemand einfach so abmeldet - es ist wahrscheinlich, dass jemand dies während der allgemeinen "Bereinigung" von Traces getan hat -, zusammen mit dem Schließen der Schreib- / Leseströme (zum Beispiel ein Logger, der Daten in eine Datei auf ein Ereignis schreiben sollte) und dem Schließen von Verbindungen usw., das heißt, der interne Status des abonnierenden Objekts zum Zeitpunkt des Aufrufs seines Handlers ist möglicherweise nicht für die weitere Arbeit geeignet.
    Angenommen, unser Abonnent implementiert eine Methode IDisposableund folgt einer Konvention, die beim Versuch, eine Methode für ein freigegebenes (nachfolgend entsorgtes) Objekt aufzurufen, diese wegwirft ObjectDisposedException. Wir stimmen auch zu, dass wir uns von allen Ereignissen in der Methode abmelden Dispose.
    Stellen Sie sich nun ein solches Szenario vor - wir rufen die Methode für dieses Objekt Disposegenau nach dem Moment auf, in dem ein anderer Thread die Liste seiner Abonnenten "eingefroren" hat. Der Thread ruft den Handler des nicht abonnierten Objekts erfolgreich auf, und dieser erkennt bei dem Versuch, das Ereignis zu verarbeiten, früher oder später, dass es bereits freigegeben wurde, und wirft es weg ObjectDisposedException. Diese Ausnahme wird höchstwahrscheinlich nicht vom Handler selbst abgefangen, da es logisch ist anzunehmen: "Wenn unser Abonnent abgemeldet und freigegeben wurde, wird sein Handler niemals angerufen." Entweder kommt es zu einem Absturz der Anwendung oder zu einem Verlust nicht verwalteter Ressourcen, oder ein Ereignisaufruf wird unterbrochen, wenn er zum ersten Mal angezeigt wird ObjectDisposedException(wenn beim Aufrufen eine Ausnahme auftritt ), das Ereignis wird jedoch nicht zu normalen "Live" -Handlern weitergeleitet.

    Zurück zum Comic. Die Geschichte ist die gleiche - zwei Ströme, die Zeit vergeht von oben nach unten. Folgendes passiert tatsächlich:
    Erweitern Sie



    Diese Situation ist meiner Meinung nach viel ernster als NullReferenceExceptionmit einem Ereignisaufruf möglich .
    Interessanterweise gibt es an den Seiten des beobachteten Objekts Tipps zum Implementieren des Auslösens von thread-sicheren Ereignissen, jedoch keine Tipps zum Implementieren thread-sicherer Handler.

    Worüber spricht StackOverflow?


    Auf SO finden Sie einen detaillierten „Artikel“ (ja, diese Frage zeichnet einen ganzen kleinen Artikel), der sich mit diesem Thema befasst.

    Im Allgemeinen wird mein Standpunkt dort geteilt, aber hier ist, was dieser Kamerad hinzufügt:

    Es scheint mir, dass all dieser Hype mit lokalen Variablen nichts anderes als Cargo Cult Programming ist . Eine große Anzahl von Personen löst das Problem der Thread-sicheren Ereignisse auf diese Weise, während für die vollständige Thread-Sicherheit viel mehr getan werden muss. Ich kann mit Zuversicht sagen, dass diejenigen, die ihrem Code keine solchen Prüfungen hinzufügen, auf sie verzichten können. Dieses Problem tritt in einer Umgebung mit nur einem Thread einfach nicht auf, und da es in Online-Codebeispielen selten möglich ist, ein Schlüsselwort zu finden volatile, ist diese zusätzliche Prüfung möglicherweise sinnlos. Wenn unser Ziel die Verfolgung ist NullReferenceException, ist es möglich, auf eine Überprüfung zu verzichten nullund ein Leerzeichen zuzuweisendelegate { } zu unserer Veranstaltung während der Klassenobjektinitialisierung?


    Dies bringt uns zu einer anderen Lösung des Problems.

    public event Action MyLittleEvent = delegate {};
    


    MyLittleEventEs wird niemals gleich sein null, und Sie können die zusätzliche Prüfung einfach nicht durchführen. In einer Umgebung mit mehreren Threads müssen Sie nur das Hinzufügen und Entfernen von Ereignisabonnenten synchronisieren, aber Sie können es aufrufen, ohne befürchten zu müssen, Folgendes zu erhalten NullReferenceException:

    Option 4:
    public event Action MyLittleEvent = delegate {};
    protected virtual void OnMyLittleEvent()
    {
        // Собственно, это все
        MyLittleEvent();
    }
    


    Der einzige Nachteil dieses Ansatzes im Vergleich zum vorherigen ist ein geringer Overhead für das Aufrufen eines leeren Ereignisses (der Overhead betrug ungefähr 5 Nanosekunden pro Aufruf). Sie könnten auch denken, dass bei einer großen Anzahl unterschiedlicher Klassen mit unterschiedlichen Ereignissen diese leeren "Gags" für Ereignisse viel RAM beanspruchen. Wenn Sie jedoch John Skeet in der Antwort auf SO glauben , beginnend mit C # 3.0, verwendet der Compiler dasselbe Das gleiche Objekt ist ein leerer Delegat für alle "Gags". Ich füge selbst hinzu, dass beim Überprüfen des resultierenden IL-Codes diese Anweisung nicht bestätigt wird. Leere Delegaten werden ereignisweise erstellt (überprüft mit LINQPad und ILSpy). In extremen Fällen können Sie mit einem leeren Delegaten ein statisches Feld für das Projekt festlegen, auf das von allen Teilen des Programms aus zugegriffen werden kann.

    John Skeets Weg



    Sobald wir zu Jon Skeet bekam, ist es wert , dessen Umsetzung die Threadsicherheit Ereignis zu notieren, die er in beschrieben C # in Depth Abschnitt Delegierten und Events lesen ( Online - Artikel und Übersetzung Begleiter Klotos )

    Die Idee zu schließen ist add, removeund die lokale „Einfrieren“ in lockdas So können Sie mögliche Unsicherheiten beseitigen und gleichzeitig das Ereignis mehrerer Threads abonnieren:

    Ein bisschen Code
    SomeEventHandler someEvent;
    readonly object someEventLock = new object();
    public event SomeEventHandler SomeEvent
    {
        add
        {
            lock (someEventLock)
            {
                someEvent += value;
            }
        }
        remove
        {
            lock (someEventLock)
            {
                someEvent -= value;
            }
        }
    }
    protected virtual void OnSomeEvent(EventArgs e)
    {
        SomeEventHandler handler;
        lock (someEventLock)
        {
            handler = someEvent;
        }
        if (handler != null)
        {
            handler (this, e);
        }
    } 
    



    Obwohl diese Methode als veraltet angesehen wird (die interne Implementierung von Ereignissen ab C # 4.0 sieht völlig anders aus, siehe die Liste der Quellen am Ende des Artikels), wird deutlich, dass Sie den Ereignisaufruf, das Abonnement und das Abbestellen nicht einfach so umbrechen lockkönnen Mit sehr hoher Wahrscheinlichkeit kann es zu einem Deadlock (im Folgenden Deadlock) kommen. In locknur auf eine lokale Variable kopiert, wird der Anruf selbst das Ereignis eintritt außerhalb der Struktur.

    Damit ist das Problem des Aufrufs von Handlern für nicht abonnierte Ereignisse nicht vollständig gelöst.

    Zurück zur Frage zu SO. Als Antwort auf alle unsere Präventionsmethoden NullReferenceExceptionbringt Daniel einen sehr interessanten Gedanken zum Ausdruck:

    Ja, ich habe diesen Rat wirklich gefunden, um ihn NullReferenceExceptionum jeden Preis zu verhindern . Ich sage, dass es in unserem speziellen Fall NullReferenceExceptionnur vorkommen kann, wenn sich ein anderer Thread vom Event abmeldet. Und er tut dies nur, um Ereignisse nie wieder zu empfangen , was wir bei der Überprüfung lokaler Variablen nicht erreichen . Wo wir den Status des Rennens verbergen , können wir ihn öffnen und die Konsequenzen korrigieren. NullReferenceExceptionermöglicht es Ihnen, den Moment der unsachgemäßen Behandlung Ihres Ereignisses zu bestimmen. Im Allgemeinen bestätige ich, dass diese Technik des Kopierens und Überprüfens - Einfache Cargo-Kult-Programmierung, die Ihrem Code Verwirrung und Rauschen verleiht, aber das Problem von Multithread-Ereignissen überhaupt nicht löst.


    Unter anderem beantwortete John Skeet die Frage, und das ist, was er schreibt.

    John Skeet gegen Jeffrey Richter



    Der JIT-Compiler ist nicht berechtigt, den lokalen Verweis auf den Delegaten zu optimieren, da eine Bedingung vorliegt. Diese Information wurde vor einiger Zeit "geworfen", aber das ist nicht wahr (ich habe diese Frage entweder mit Joe Duffy oder mit Vance Morrison geklärt). Ohne einen Modifikator ist volatileder lokale Verweis auf den Delegaten möglicherweise etwas veraltet, aber insgesamt ist das alles. Dies führt nicht zu NullReferenceException.

    Und ja, wir haben definitiv eine Rennbedingung, Sie haben Recht. Aber es wird immer präsent sein. Nehmen wir an, wir entfernen das Häkchen nullund schreiben einfach
    MyLittleEvent();
    

    Stellen Sie sich nun vor, unsere Abonnentenliste besteht aus 1000 Teilnehmern. Es ist möglich, dass wir ein Ereignis auslösen, bevor sich einer der Abonnenten von diesem abmeldet. In diesem Fall wird es weiterhin aufgerufen, da es in der alten Liste verbleibt (vergessen Sie nicht, dass die Delegierten unveränderlich sind). Soweit ich weiß, ist dies völlig unumgänglich.
    Wenn Sie das leere delegate {};Feld verwenden, müssen wir das Ereignis nullnicht überprüfen , aber es rettet uns nicht vor dem nächsten Status des Rennens. Darüber hinaus garantiert diese Methode nicht, dass wir die aktuellste Version des Ereignisses verwenden.


    Nun sollte angemerkt werden, dass diese Antwort im Jahr 2009 und CLR über C # 4. Auflage - im Jahr 2012 - geschrieben wurde. Also, wem kann man am Ende vertrauen?
    Tatsächlich habe ich nicht verstanden, warum Richter den Fall des Kopierens in eine lokale Variable durch beschreibt Volatile.Read, da er Skeets Worte weiter bestätigt :
    Obwohl empfohlen wird, Version c Volatile.Readals beste und technisch korrekte Version zu verwenden , kann auf Option 2 verzichtet werden , da der JIT-Compiler weiß, was er versehentlich tun kann, indem er eine lokale Variable optimiert tempAction. Rein theoretisch kann sich dies in Zukunft ändern, daher wird empfohlen, Option 3 zu verwenden . Tatsächlich ist es jedoch unwahrscheinlich, dass Microsoft solche Änderungen vornimmt, da dies eine Vielzahl von vorgefertigten Programmen zum Erliegen bringen kann.


    Alles wird völlig verwirrend - beide Optionen sind gleichwertig, aber die mit ist Volatile.Readgleichwertiger. Und keine Option rettet Sie vor dem Status des Rennens, wenn Sie nicht abonnierte Handler anrufen.

    Vielleicht gibt es überhaupt keine thread-sichere Methode zum Aufrufen von Ereignissen? Warum NullReferenceExceptionwird so viel Mühe und Zeit aufgewendet , um das Unwahrscheinliche zu verhindern , aber nicht, um einen nicht weniger wahrscheinlichen Anruf eines nicht abonnierten Handlers zu verhindern? Das habe ich nicht verstanden. Aber auf der Suche nach Antworten habe ich viele andere Dinge erkannt, und hier ist eine kleine Zusammenfassung.

    Was haben wir am Ende?



    • Die beliebteste Methode ist nicht threadsicher, da der Delegat nullnach Überprüfung auf Ungleichheit möglicherweise in "" umgewandelt werden kann . Es besteht die Gefahr vonNullReferenceException
      public event Action MyLittleEvent;
      ...
      protected virtual void OnMyLittleEvent()
      {
          if (MyLittleEvent != null) 
                  // Опасность NullReferenceException
                  MyLittleEvent();
      }
      

    • Die Methoden von Skeet und Richter helfen dabei, das Auftreten zu vermeiden NullReferenceException, sind jedoch nicht threadsicher , da weiterhin die Möglichkeit besteht, bereits abgemeldete Handler aufzurufen.
      Skeet-Methode
      SomeEventHandler someEvent;
      readonly object someEventLock = new object();
      public event SomeEventHandler SomeEvent
      {
          add
          {
              lock (someEventLock)
              {
                  someEvent += value;
              }
          }
          remove
          {
              lock (someEventLock)
              {
                  someEvent -= value;
              }
          }
      }
      protected virtual void OnSomeEvent(EventArgs e)
      {
          SomeEventHandler handler;
          lock (someEventLock)
          {
              handler = someEvent;
          }
          if (handler != null)
          {
              handler (this, e);
          }
      } 
      


      Richter-Methode
      protected virtual void OnMyLittleEvent()
      {
          // Ну теперь уж точно заморозили
          Action tempAction = Volatile.Read(ref MyLittleEvent); 
          if (tempAction != null) tempAction (); 
      }
      


    • Die leere Methode delegate {};ermöglicht es Ihnen, sie loszuwerden NullReferenceException, da das Ereignis nie zugreift null, aber nicht threadsicher ist, da die Möglichkeit besteht, bereits nicht abonnierte Handler aufzurufen. Darüber hinaus volatilehaben wir ohne Modifikator die Möglichkeit, die neueste Version des Delegaten zu erhalten, wenn das Ereignis aufgerufen wird.
    • Sie können das Hinzufügen, Löschen und Aufrufen eines Ereignisses nicht einfach einschließen lock, da dies zu einem Deadlock führen kann. Aus technischer Sicht können wir uns so vor dem Aufrufen nicht abonnierter Handler hüten, können jedoch nicht sicher sein, welche Aktionen das abonnierende Objekt vor dem Abbestellen des Ereignisses ausgeführt hat, sodass wir weiterhin auf ein "beschädigtes" Objekt stoßen können (siehe Beispiel c ObjectDisposedException). . Diese Methode ist auch nicht threadsicher .
    • Der Versuch, abgemeldete Delegierte nach einem lokalen "Einfrieren" -Ereignis zu erwischen, ist sinnlos. Bei einer großen Anzahl von Abonnenten ist die Wahrscheinlichkeit, abgemeldete Handler (nach dem Start eines Ereignisaufrufs) anzurufen, sogar höher als bei einem lokalen "Einfrieren".


    Technisch ist keine der vorgestellten Optionen eine threadsichere Möglichkeit, ein Ereignis auszulösen . Darüber hinaus führt das Hinzufügen einer Delegatenüberprüfungsmethode unter Verwendung lokaler Kopien von Delegaten zu einem falschen Sicherheitsgefühl . Die einzige Möglichkeit, sich vollständig zu schützen, besteht darin, die Ereignishandler zu zwingen, zu überprüfen, ob sie sich bereits von einem bestimmten Ereignis abgemeldet haben. Leider gibt es im Gegensatz zu üblichen Präventionsmethoden NullReferenceExceptionbeim Auslösen von Ereignissen keine Vorschriften für Behandler. Wenn Sie eine separate Bibliothek erstellen, können Sie die Benutzer in den meisten Fällen nicht beeinflussen. Sie können Clients nicht dazu zwingen, anzunehmen, dass ihre Handler nach dem Abbestellen des Ereignisses nicht aufgerufen werden.

    Nachdem ich all diese Probleme erkannt hatte, hatte ich immer noch gemischte Gefühle hinsichtlich der internen Implementierung von Delegierten in C #. Einerseits gibt es, da sie unveränderlich sind, keine Möglichkeit, InvalidOperationExceptioneine sich ändernde Sammlung durchzuzählen foreach, andererseits gibt es keine Möglichkeit, zu überprüfen, ob sich jemand während des Anrufs von dem Ereignis abgemeldet hat oder nicht. Das Einzige, was der Inhaber der Veranstaltung tun kann, ist sich abzusichern NullReferenceExceptionund zu hoffen, dass die Abonnenten nichts ruinieren. Infolgedessen kann die gestellte Frage wie folgt beantwortet werden:

    Es ist unmöglich, einen thread-sicheren Ereignisaufruf in einer Umgebung mit mehreren Threads bereitzustellen, da immer die Möglichkeit besteht, Handler von nicht abonnierten Abonnenten anzurufen. Diese Unsicherheit widerspricht der Definition des Begriffs "Fadensicherheit", insbesondere Klausel
    Die Implementierung ist garantiert frei von Race-Bedingungen, wenn auf mehrere Threads gleichzeitig zugegriffen wird.



    Zusätzliche Lektüre


    Natürlich konnte ich nicht alles kopieren / übersetzen, was ich fand. Deshalb werde ich eine Liste von Quellen hinterlassen, die direkt oder indirekt verwendet wurden.


    Jetzt auch beliebt: