|
|
Veröffentlicht am 13.09.2016 13:33:04
|
|
|

Vor .NET 4.0 hatten wir, wenn wir die Dictionary-Klasse in einer Multithreaded-Umgebung verwenden mussten, keine andere Wahl, als selbst eine Thread-Synchronisation zu implementieren, um die Threads zu schützen.
Viele Entwickler haben sicherlich eine ähnliche threadsichere Lösung implementiert, entweder indem sie einen völlig neuen threadsicheren Wörterbuchtyp erstellt oder einfach ein Dictionary-Objekt in einer Klasse kapseln und allen Methoden einen Sperrmechanismus hinzufügen, den wir "Dictionary + Locks" nennen.
Aber jetzt haben wir ConcurrentDictionary. Die thread-sichere Beschreibung der Dictionary-Klassendokumentation auf MSDN besagt, dass man bei Verwendung einer threadsicheren Implementierung ConcurrentDictionary verwenden muss.
Jetzt, da wir eine threadsichere Wörterbuchklasse haben, müssen wir sie nicht mehr selbst implementieren. Großartig, oder?
Ursprung des Problems
Tatsächlich habe ich CocurrentDictionary bisher nur einmal benutzt, in meinem Test, um seine Reaktionsfähigkeit zu testen. Da es in den Tests gut abgeschnitten hat, habe ich es sofort durch meinen Kurs ersetzt, einige Tests gemacht, und dann ging etwas schief.
Also, was ist schiefgelaufen? Hast du nicht gesagt, thread-sicher?
Nach weiteren Tests habe ich die Ursache des Problems gefunden. Aber aus irgendeinem Grund enthält MSDN Version 4.0 keine Beschreibung der GetOrAdd-Methodensignatur, die das Übergeben eines Delegiertentypparameters erfordert. Nachdem ich mir Version 4.5 angesehen habe, habe ich diese Notiz gefunden:
Wenn Sie GetOrAdd gleichzeitig auf verschiedenen Threads aufrufen, kann addValueFactory mehrfach aufgerufen werden, aber das Schlüssel-/Wert-Paar wird möglicherweise nicht für jeden Aufruf ins Wörterbuch aufgenommen. Das ist das Problem, auf das ich gestoßen bin. Da es in der Dokumentation vorher nicht beschrieben wurde, musste ich weitere Tests durchführen, um das Problem zu bestätigen. Natürlich hängt das Problem, auf das ich stoße, mit meiner Nutzung zusammen, im Allgemeinen nutze ich den Wörterbuchtyp, um einige Daten zu cachen:
Diese Daten sind sehr langsam zu erstellen; Diese Daten können nur einmal erstellt werden, da die zweite Erstellung eine Ausnahme auslöst oder mehrere Erstellungen zu Ressourcenlecks führen können usw.; Ich hatte ein Problem mit der zweiten Erkrankung. Wenn beide Threads feststellen, dass ein Datenstück nicht existiert, wird es einmal erstellt, aber nur ein Ergebnis wird erfolgreich gespeichert. Was ist mit dem anderen?
Wenn der von dir erstellte Prozess eine Ausnahme auslöst, kannst du versuchen: Catch (nicht elegant genug, aber es löst das Problem). Aber was, wenn eine Ressource geschaffen und nicht recycelt wird?
Man könnte sagen, dass ein Objekt erstellt wird und als Garbage Collect übernommen wird, wenn es darin nicht mehr referenziert wird. Betrachten Sie jedoch, was passieren würde, wenn die unten beschriebene Situation eintreten würde:
Code dynamisch mit Emit generieren. Ich habe diesen Ansatz in einem Remoting-Framework verwendet und alle Implementierungen in eine Assembler gelegt, die nicht recycelt werden konnte. Wenn ein Typ zweimal erstellt wird, wird der zweite immer existieren, auch wenn er nie verwendet wurde. Erstelle direkt oder indirekt einen Thread. Zum Beispiel müssen wir eine Komponente bauen, die einen proprietären Thread nutzt, um asynchrone Nachrichten zu verarbeiten und sich auf die Reihenfolge ihres Empfangs verlässt. Wenn die Komponente instanziiert wird, wird ein Thread erstellt. Wenn diese Komponenteninstanz zerstört wird, wird auch der Thread beendet. Wenn wir jedoch die Referenz auf das Objekt nach der Zerstörung der Komponente löschen, endet der Thread aus irgendeinem Grund nicht und hält die Referenz auf das Objekt. Wenn der Faden dann nicht stirbt, wird das Objekt auch nicht recycelt. Führen Sie eine P/Invoke-Operation aus. Es wird verlangt, dass die Anzahl der geschlossenen Zeiten für den empfangenen Griff mit der Anzahl der Öffnungen gleich sein muss. Sicherlich gibt es viele ähnliche Situationen. Zum Beispiel hält ein Dictionary-Objekt eine Verbindung zu einem Dienst auf einem entfernten Server, die nur einmal angefordert werden kann, und wenn sie ein zweites Mal angefordert wird, glaubt der andere Service, dass ein Fehler aufgetreten ist, und protokolliert diese im Log. (In einer Firma, für die ich gearbeitet habe, gab es für diese Erkrankung einige rechtliche Sanktionen.) ) Es ist also leicht zu erkennen, dass Dictionary + Locks nicht übereilt durch ConcurrentDictionary ersetzt werden kann, selbst wenn die Dokumentation sagt, es sei thread-sicher.
Analysieren Sie das Problem
Verstehst du es immer noch nicht?
Es stimmt, dass dieses Problem unter dem Dictionary + Locks-Ansatz möglicherweise nicht auftritt. Da dies von der spezifischen Implementierung abhängt, schauen wir uns dieses einfache Beispiel an:
Im obigen Code halten wir die Sperre im Wörterbuch, bevor wir beginnen, den Schlüsselwert abzufragen. Wenn das angegebene Schlüssel-Wert-Paar nicht existiert, wird es direkt erstellt. Gleichzeitig, da wir bereits eine Sperre auf diesem Wörterbuch halten, können wir Schlüssel-Wert-Paare direkt zum Wörterbuch hinzufügen. Dann lösen Sie das Wörterbuch-Lock und geben Sie das Ergebnis zurück. Wenn zwei Threads gleichzeitig denselben Schlüsselwert abfragen, schließt der erste Thread, der das Dictionary-Lock erhält, die Erstellung des Objekts ab, und der andere Thread wartet auf den Abschluss dieser Erstellung und erhält nach dem Erhalten des Dictionary-Locks das erstellte Schlüsselwert-Ergebnis.
Das ist doch gut, oder?
Das ist es wirklich nicht! Ich glaube nicht, dass das Parallelerstellen eines Objekts, bei dem am Ende nur eines verwendet wird, nicht das von mir beschriebene Problem verursacht.
Die Situation und das Problem, das ich erläutern möchte, sind nicht immer reproduzierbar; in einer parallelen Umgebung können wir einfach zwei Objekte erstellen und eines dann verwerfen. Wie genau vergleichen wir also Dictionary + Locks und ConcurrentDictionary?
Die Antwort lautet: Es hängt von der Strategie der Schlossnutzung und der Verwendung des Wörterbuchs ab.
Spiel 1: Erstelle dasselbe Objekt parallel
Zunächst nehmen wir an, ein Objekt kann zweimal erstellt werden, also was passiert, wenn zwei Threads dieses Objekt gleichzeitig erstellen?
Zweitens: Wie viel Zeit verbringen wir mit ähnlichen Kreationen?
Wir können einfach ein Beispiel erstellen, bei dem das Instantiieren eines Objekts 10 Sekunden dauert. Wenn der erste Thread das Objekt 5 Sekunden später erstellt, versucht die zweite Implementierung, die Methode GetOrAdd aufzurufen, um das Objekt zu erhalten, und da das Objekt immer noch nicht existiert, beginnt sie auch, das Objekt zu erstellen.
In diesem Zustand arbeiten 2 CPUs parallel für 5 Sekunden, und wenn der erste Thread fertig ist, muss der zweite Thread noch 5 Sekunden weiterlaufen, um den Bau des Objekts abzuschließen. Wenn der zweite Thread den Bau des Objekts fertiggestellt hat, stellt er fest, dass ein Objekt bereits existiert, und entscheidet sich, das bestehende Objekt zu verwenden und das neu erstellte Objekt direkt zu verwerfen.
Wenn der zweite Thread einfach wartet und die zweite CPU andere Aufgaben erledigt (andere Threads oder Anwendungen ausführen und so Strom sparen), erhält sie das gewünschte Objekt nach 5 Sekunden statt nach 10 Sekunden.
Unter diesen Bedingungen gewinnt Dictionary + Locks ein kleines Spiel.
Spiel 2: Verschiedene Objekte parallel besuchen
Nein, die von dir beschriebene Situation stimmt überhaupt nicht!
Nun, das obige Beispiel ist etwas merkwürdig, aber es beschreibt das Problem, nur ist diese Verwendung extremer. Betrachten wir also, was passiert, wenn der erste Thread ein Objekt erstellt und der zweite Thread auf ein anderes Schlüssel-Wert-Objekt zugreifen muss und dieses Schlüssel-Wert-Objekt bereits existiert?
In ConcurrentDictionary macht das sperrfreie Design das Lesen sehr schnell, da es keine Sperre beim Lesen gibt. Im Fall von Dictionary + Locks wird die Leseoperation gegenseitig ausschließend gesperrt, selbst wenn es sich um einen völlig anderen Schlüssel handelt, was den Lesevorgang offensichtlich verlangsamt.
Auf diese Weise hat ConcurrentDictionary ein Spiel zurückgezogen.
Hinweis: Hier bin ich der Meinung, dass Sie mehrere Konzepte wie Bucket/Node/Entry im Wörterbuchkurs verstehen; falls nicht, wird empfohlen, Ofir Makmal Artikel "Understanding Generic Dictionary in-depth" zu lesen, der diese Konzepte gut erklärt.
Das dritte Spiel des Spiels: Mehr lesen und Single schreiben
Was passiert, wenn du Multiple Readers und Single Writer anstelle einer vollständigen Sperre auf dem Wörterbuch in Dictionary + Locks verwendest?
Wenn ein Thread ein Objekt erstellt und ein aufrüstbares Schloss hält, bis das Objekt erstellt ist, wird das Schloss zu einem Schreib-Lock aufgerüstet, dann kann die Leseoperation parallel durchgeführt werden.
Wir können das Problem auch lösen, indem wir eine Leseoperation für 10 Sekunden im Leerlauf lassen. Aber wenn es deutlich mehr Lesungen als Schreibvorgänge gibt, werden wir feststellen, dass ConcurrentDictionary immer noch schnell ist, weil es lock-freie Reads implementiert.
Die Verwendung von ReaderWriterLockSlim für Wörterbücher verschlechtert das Lesen, und es wird allgemein empfohlen, Full Lock für Wörterbücher statt ReaderWriterLockSlim zu verwenden.
Unter diesen Bedingungen gewann ConcurrentDictionary also ein weiteres Spiel.
Hinweis: Ich habe die YieldReaderWriterLock- und YieldReaderWriterLockSlim-Klassen in früheren Artikeln behandelt. Durch die Verwendung dieses Lese-Schreib-Locks wurde die Geschwindigkeit erheblich verbessert (inzwischen zu SpinReaderWriterLockSlim weiterentwickelt) und ermöglicht es, mehrere Lesevorgänge parallel durchzuführen, ohne große oder keine Auswirkungen. Solange ich diese Methode weiterhin benutze, wäre ein lockloses ConcurrentDictionary natürlich schneller.
Spiel 4: Mehrere Schlüssel-Wert-Paare hinzufügen
Der Showdown ist noch nicht vorbei.
Was, wenn wir mehrere Schlüsselwerte addieren müssen und alle nicht kollidieren und in verschiedene Buckets zugewiesen werden?
Anfangs war diese Frage neugierig, aber ich habe einen Test gemacht, der nicht ganz passte. Ich habe ein Wörterbuch des Typs <int, int> verwendet, und die Konstruktionsfabrik des Objekts lieferte direkt ein negatives Ergebnis als Schlüssel.
Ich hatte erwartet, dass ConcurrentDictionary das schnellste ist, aber es stellte sich als das langsamste heraus. Dictionary + Locks hingegen läuft schneller. Warum ist das so?
Dies liegt daran, dass ConcurrentDictionary Knoten zuweist und sie in verschiedene Buckets einordnet, was optimiert ist, um dem lock-freien Design für Leseoperationen gerecht zu werden. Beim Hinzufügen von Key-Value-Elementen wird jedoch der Prozess der Erstellung eines Knotens teuer.
Selbst unter parallelen Bedingungen benötigt die Zuweisung eines Knotenschlosses mehr Zeit als die Nutzung eines vollständigen Schlosses.
Also, Dictionary + Locks gewinnt dieses Spiel.
Das fünfte Spiel spielen: Die Häufigkeit der Leseoperationen ist höher
Ehrlich gesagt, wenn wir einen Delegierten hätten, der Objekte schnell instanziieren könnte, bräuchten wir kein Wörterbuch. Wir können den Delegierten direkt anrufen, um das Objekt zu bekommen, oder?
Tatsächlich ist die Antwort auch, dass es von der jeweiligen Situation abhängt.
Stellen Sie sich vor, der Schlüsseltyp ist eine Zeichenkette und enthält Pfadkarten für verschiedene Seiten im Webserver, und der entsprechende Wert ist ein Objekttyp, der den Datensatz der aktuellen Benutzer enthält, die auf die Seite zugreifen, sowie die Anzahl aller Besuche auf der Seite seit Serverstart.
Ein solches Objekt zu erschaffen geschieht fast augenblicklich. Danach musst du kein neues Objekt mehr erstellen, sondern nur die darin gespeicherten Werte ändern. Es ist also möglich, die Erstellung eines Weges zweimal zu erlauben, bis nur noch eine Instanz verwendet wird. Da ConcurrentDictionary jedoch Node-Ressourcen langsamer zuweist, führt die Verwendung von Dictionary + Locks zu schnelleren Erstellungszeiten.
Dieses Beispiel ist also sehr besonders, wir sehen auch, dass Dictionary + Locks unter diesen Bedingungen besser funktioniert und weniger Zeit benötigt.
Obwohl die Knotenzuweisung in ConcurrentDictionary langsamer ist, habe ich nicht versucht, 100 Millionen Datenelemente einzubauen, um die Zeit zu testen. Denn das kostet natürlich viel Zeit.
Aber in den meisten Fällen wird ein Datenelement, sobald es erstellt wurde, immer gelesen. Wie sich der Inhalt des Datenelements ändert, ist eine andere Frage. Es spielt also keine Rolle, wie viele Millisekunden es dauert, ein Datenelement zu erstellen, denn Lesungen sind schneller (nur ein paar Millisekunden schneller), aber Lesungen erfolgen häufiger.
Also hat ConcurrentDictionary das Spiel gewonnen.
Spiel 6: Erstelle Objekte, die verschiedene Zeiten verbrauchen
Was passiert, wenn die Zeit, die für die Erstellung verschiedener Datenelemente benötigt wird, variiert?
Erstelle mehrere Datenelemente, die unterschiedliche Zeiten benötigen, und füge sie parallel dem Wörterbuch hinzu. Das ist der stärkste Punkt von ConcurrentDictionary.
ConcurrentDictionary verwendet verschiedene Sperrmechanismen, um das gleichzeitige Hinzufügen von Datenelementen zu ermöglichen, aber Logik wie die Entscheidung, welches Schloss verwendet wird, das Anfordern eines Schlosses zur Änderung der Größe des Eimers usw. hilft nicht. Die Geschwindigkeit, mit der Datenelemente in einen Eimer gelegt werden, ist maschinenschnell. Was ConcurrentDictionary wirklich zum Erfolg macht, ist seine Fähigkeit, Objekte parallel zu erstellen.
Aber wir können tatsächlich dasselbe tun. Wenn es uns egal ist, ob wir Objekte parallel erstellen oder ob einige von ihnen verworfen wurden, können wir ein Schloss hinzufügen, um zu erkennen, ob das Datenelement bereits existiert, dann das Schloss freigeben, das Datenelement erstellen, es drücken, um das Schloss zu erhalten, erneut prüfen, ob das Datenelement existiert, und wenn nicht, das Datenelement hinzufügen. Der Code könnte ungefähr so aussehen:
* Beachte, dass ich ein Wörterbuch von Typen <int, int> verwende.
In der oben genannten einfachen Struktur funktioniert Dictionary + Locks fast genauso gut wie ConcurrentDictionary beim Erstellen und Hinzufügen von Datenelementen unter parallelen Bedingungen. Aber es gibt auch dasselbe Problem, bei dem einige Werte erzeugt, aber nie verwendet werden.
Schlussfolgerung
Also, gibt es eine Schlussfolgerung?
Im Moment gibt es noch einige:
Alle Wörterbuchkurse sind sehr schnell. Obwohl ich Millionen von Daten erstellt habe, geht es immer noch schnell. Normalerweise erstellen wir nur eine kleine Anzahl von Datenelementen, und es gibt einige Zeitintervalle zwischen den Lesungen, sodass wir den Zeitaufwand beim Lesen der Datenelemente im Allgemeinen nicht bemerken. Wenn dasselbe Objekt nicht zweimal erstellt werden kann, verwenden Sie ConcurrentDictionary nicht. Wenn du dir wirklich Sorgen um die Leistung machst, könnte Dictionary + Locks trotzdem eine gute Lösung sein. Ein wichtiger Faktor ist die Anzahl der hinzugefügten und entfernten Datenelemente. Aber wenn es viele Leseoperationen gibt, ist es langsamer als ConcurrentDictionary. Obwohl ich es nicht eingeführt habe, gibt es tatsächlich mehr Freiheit, das Dictionary + Locks-Schema zu verwenden. Zum Beispiel kannst du einmal sperren, mehrere Datenelemente hinzufügen, mehrere Datenelemente löschen oder mehrfach abfragen usw. und dann die Sperre aufheben. Im Allgemeinen sollten Sie ReaderWriterLockSlim vermeiden, wenn es deutlich mehr Lese- als Schreibvorgänge gibt. Wörterbuchtypen sind bereits viel schneller als ein Lese-Lock in einem Read-Write-Lock. Natürlich hängt das auch von der Zeit ab, die benötigt wird, um ein Objekt in einem Lock zu erstellen. Ich denke, die gegebenen Beispiele sind etwas extrem, aber sie zeigen, dass die Verwendung von ConcurrentDictionary nicht immer die beste Lösung ist.
Spür den Unterschied
Ich habe diesen Artikel mit der Absicht geschrieben, eine bessere Lösung zu finden.
Ich versuche bereits, ein tieferes Verständnis dafür zu bekommen, wie ein bestimmter Wörterbuchkurs funktioniert (jetzt habe ich das Gefühl, sehr klar zu sein).
Man könnte argumentieren, dass Bucket und Node in ConcurrentDictionary sehr einfach sind. Ich habe etwas Ähnliches gemacht, als ich versucht habe, einen Wörterbuchkurs zu erstellen. Die reguläre Wörterbuchklasse mag einfacher erscheinen, ist aber tatsächlich komplexer.
In ConcurrentDictionary ist jeder Knoten eine vollständige Klasse. In der Dictionary-Klasse wird Node mit einem Wertetyp implementiert, und alle Nodes werden in einem riesigen Array gespeichert, während Bucket zur Indexierung im Array verwendet wird. Er wird auch anstelle der einfachen Referenz eines Knotens auf seinen nächsten Knoten verwendet (schließlich kann er als Knoten eines Struct-Typs kein Knotenmitglied eines Struct-Typs enthalten).
Beim Hinzufügen und Entfernen eines Wörterbuchs kann die Dictionary-Klasse nicht einfach einen neuen Knoten erstellen, sondern muss prüfen, ob ein Index einen gelöschten Knoten markiert, und ihn dann wiederverwenden. Oder "Count" wird verwendet, um die Position des neuen Knotens im Array zu ermitteln. Tatsächlich erzwingt die Dictionary-Klasse eine Größenänderung, wenn das Array voll ist.
Für ConcurrentDictionary kann ein Knoten als neues Objekt betrachtet werden. Das Entfernen eines Knotens bedeutet einfach, seine Referenz zu entfernen. Das Hinzufügen eines neuen Knotens kann einfach eine neue Knoteninstanz erstellen. Die Größe zu ändern dient nur dazu, Konflikte zu vermeiden, ist aber nicht zwingend erforderlich.
Wenn also die Dictionary-Klasse bewusst komplexere Algorithmen verwendet, um damit umzugehen, wie stellt ConcurrentDictionary dann sicher, dass sie in einer Multithread-Umgebung besser funktioniert?
Die Wahrheit ist: Alle Knoten in einem Array zusammenzuführen ist der schnellste Weg, um zuzuweisen und zu lesen, selbst wenn wir ein weiteres Array brauchen, um zu verfolgen, wo diese Datenelemente zu finden sind. Es sieht also so aus, als würde die gleiche Anzahl von Buckets mehr Speicher verbrauchen, aber die neuen Datenelemente müssen nicht neu verteilt werden, es sind keine neuen Objekt-Synchronisationen nötig und eine neue Garbage Collection findet nicht statt. Denn alles ist bereits an seinem Platz.
Das Ersetzen von Inhalten in einem Knoten ist jedoch keine atomare Operation, was einer der Faktoren ist, die den Thread unsicher machen. Da Knoten alle Objekte sind, wird zunächst ein Knoten erstellt, und dann wird eine separate Referenz aktualisiert, um darauf zu verweisen (atomare Operation hier). Der Lesethread kann also den Inhalt des Wörterbuchs ohne Sperre lesen, und das Lesen muss einer der alten und neuen Werte sein, und es besteht keine Chance, einen unvollständigen Wert zu lesen.
Die Wahrheit ist also: Wenn du keine Sperre brauchst, ist die Wörterbuch-Klasse beim Lesen schneller, weil sie die Sperre ist, die das Lesen verlangsamt.
Dieser Artikel ist aus Paulo Zemeks Artikel "Dictionary + Locking versus ConcurrentDictionary" auf CodeProject übersetzt, und einige Anweisungen werden aus Verständnisgründen geändert.
|
Vorhergehend:IoC-effizienter AutofacNächster:Alibaba 4 Leute wurden gefeuert, weil sie JS-Skripte benutzt haben, um eilig Mondkuchen zu kaufen
|