it-swarm.com.de

Implementieren einer Hash-Tabelle mit echter Parallelität

Dies war kürzlich eine Frage, die mir in einem Screening gestellt wurde und die mich zum Nachdenken brachte. Ich habe mich nie ganz darauf geeinigt, wie das Gespräch endete, und ich habe ein bisschen gegraben. Keine der Antworten, die ich sah, schien mir zufriedenstellend.

Die Frage lautet im Wesentlichen: "Wie würden Sie eine thread-sichere Hash-Map implementieren?" Natürlich mit einem Mutex schützen, aber dann müssen Sie sich mit der Konkurrenz mehrerer Threads auseinandersetzen, die auf einen einzelnen Mutex warten, was bedeutet, dass der Zugriff nicht wirklich gleichzeitig erfolgt, und so weiter. Wir kamen schließlich zu einem einfachen Design von n Eimern in der Hash-Tabelle, wobei ein Mutex einem Eimer entspricht. Einfach genug, und hier sehe ich viele Antworten auf diese Frage auf anderen Websites aufhören. Es wird nicht behandelt, was passiert, wenn die Größe der Hash-Tabelle geändert wird oder aus welchem ​​Grund die Tabelle erneut aufbereitet wird.

Es wurde angenommen, dass ein Thread das erneute Aufbereiten verwalten würde, sodass unser einfaches Design zu n + 1 Mutexen wird und der verwaltende Thread alle n + 1 Mutexe zum erneuten Aufbereiten sperren muss. Jeder Thread, der auf einen der n Buckets wartet, kann jedoch den Besitz eines Mutex erlangen, der jetzt mit einem anderen Bucket übereinstimmt (nach einer erneuten Aufbereitung). Die einzige Antwort, die ich finden konnte, war die Rückkehr zu einer nicht gleichzeitigen Hash-Karte.

Ich glaube, Java hat eine Art gleichzeitige Hash-Map aus der Box, aber ich lebe in einer C++ - Welt. Ich habe den Bildschirm passiert, bin aber immer noch sehr neugierig, da ich das nie hatte "a-ha" Moment und es klingt wie eine praktische Anwendung.

Ich denke, Lese-/Schreibsperren sind vielleicht eine geeignete Antwort, aber ich denke, es gibt immer noch die Einschränkung beim Aufwärmen.

6
kiss-o-matic

Ich habe damit gerungen. Ich habe ein paar Iterationen an meinem Design gemacht ...

Erste Lösung : Haben Sie einfach eine Hash-Tabelle mit einer globalen Sperre.

Zweite Lösung : Warten Sie auf eine kostenlose Hash-Tabelle mit fester Größe. Wir müssen herausfinden, dass es möglich ist, einen wartefreien Thread-sicheren Hash-Satz mit fester Größe zu implementieren. Zu diesem Zweck verwenden wir ein Array, bei dem der Index der Hash ist, wir verwenden die lineare Prüfung und alle Operationen werden ineinander greifen.

Also, ja, wir werden Sperren haben. Ein Thread wartet jedoch niemals auf einen Wartegriff. Es erfordert einige Arbeit, um herauszufinden, wie sichergestellt werden kann, dass sie alle atomar sind.

Dritte Lösung : Wachstum sichern. Der naive Ansatz besteht darin, die Struktur zu sperren, ein neues Array zu erstellen, alle Daten zu kopieren und dann zu entsperren. Das funktioniert, es ist threadsicher. Wir können davonkommen, uns nur auf Wachstum zu beschränken, denn solange sich die Größe nicht ändert, können wir wie in der vorherigen Lösung arbeiten.

Außer ich mag die Ausfallzeit nicht. Es gibt Threads, die darauf warten, dass die Kopie abgeschlossen wird. Wir können es besser machen ...

Vierte Lösung : Kooperatives Wachstum. Implementieren Sie zunächst eine thread-sichere Zustandsmaschine, damit sich die Struktur vom normalen Gebrauch zum Wachstum und zurück ändern kann. Als nächstes kooperiert jeder Thread, der eine Operation ausführen möchte, anstatt zu warten, beim Kopieren von Daten in das neue Array. Wie? Wir können einen Index haben, den wir inkrementieren, wiederum mit einer ineinandergreifenden Operation ... Jeder Thread erhöht den Index, nimmt das Element, berechnet, wo es in das neue Array eingefügt werden soll, schreibt es dort und durchläuft dann Schleifen, bis die Operation abgeschlossen ist.

Es gibt ein Problem: Es schrumpft nicht.

Fünfte Lösung : Sparse Array. Anstatt ein Array zu verwenden, können wir einen Baum von Arrays verwenden und ihn wie ein spärliches Array verwenden. Seine Größe reicht aus, um jeden Wert zuzuweisen, den unsere Hash-Funktion ausgeben kann. Jetzt können Threads nach Bedarf Knoten erstellen. Wir werden die Nutzung jedes Knotens verfolgen, dh wie viele untergeordnete Knoten belegt sind und wie viele Threads ihn derzeit verwenden. Wenn die Nutzung Null erreicht, können wir sie entfernen ... warten ... sperren? Es stellt sich heraus, dass wir eine optimistische Lösung finden können: Wir halten uns an Verweise auf die Knotendaten, jeder Thread schreibt nach der Operation eine. Der Thread, der 0 gefunden hat, wird gelöscht, dann gelesen (ein anderer Thread könnte ihn geschrieben haben) und dann in die zweite Referenz kopiert. Alle verriegelten Operationen.

Übrigens, diese atomaren Operationen? Ja, es stellt sich heraus, dass es sich um einige gängige Muster handelt, die wir abstrahieren können. Am Ende habe ich nur DoMayIncrement für Operationen, die Elemente hinzufügen können oder nicht, DoMayDecrement für diejenigen, die entfernt werden können oder nicht, und Do für alles andere. Sie nehmen Rückrufe mit Verweisen auf die Werte entgegen, für die der Rückrufcode nicht genau wissen muss, wo sie gespeichert sind, und wir bauen auf diesen spezifischeren Operationen auf und fügen eine Prüfung hinzu, um Kollisionen auf dem Weg zu behandeln.

Oh, ich habe vergessen, um die Zuordnungen zu minimieren, bündle ich die Arrays, aus denen die Knoten des Baums bestehen.

Sechste Lösung : Phil Bagwells Ideale Hash-Bäume . Es ist ähnlich wie bei meiner fünften Lösung. Der Hauptunterschied besteht darin, dass sich der Baum nicht wie ein Array mit geringer Dichte verhält, bei dem alle Werte in derselben Tiefe eingefügt werden. Stattdessen ist die Tiefe der Äste dynamisch. Wenn eine Kollision auftritt, wird der alte Knoten durch einen Zweig ersetzt, in den der alte Knoten zusätzlich zum neuen Knoten wieder eingefügt wird. Dies sollte die Lösung von Bagwell im Durchschnitt speichereffizienter machen. Bagwell bündelt auch Arrays. Bagwell geht nicht näher auf das Schrumpfen ein.


Die fünfte Lösung ist die, die ich derzeit verwende, um ConcurrentDictionary<TKey, TValue> Nach .NET 2.0 zurück zu portieren/zu füllen. Genauer gesagt, mein Backport von ConcurrentDictionary<TKey, TValue> umschließt einen benutzerdefinierten Typ ThreadSafeDictionary<TKey, TValue> , der ein Bucket<KeyValuePair<TKey, TValue>> Verwendet. die atomare Operationen über ein BucketCore implementieren, das ist mein thread-sicheres Sparse-Array-Ding, das die Logik zum Verkleinern und Wachsen hat.

10
Theraot

Eine "Hashtabelle mit echter Parallelität" hat sowieso Probleme. Wenn Thread A einen Schlüssel nachschlägt, während Thread B genau denselben Schlüssel löscht oder hinzufügt, ist das bestmögliche Ergebnis, dass Sie nicht wissen, ob A den Schlüssel findet oder nicht.

Sie benötigen mehr als nur die üblichen Operationen, um atomar zu sein. Beispielsweise benötigen Sie eine Operation, die einen Schlüssel nachschlägt, entfernt und atomar an den Aufrufer zurückgibt, da sonst Ihre Algorithmen in Schwierigkeiten geraten. Wenn Sie dem Wörterbuch einen Schlüssel hinzufügen und ihn sofort nachschlagen, gibt es keine Garantie dafür, dass er noch vorhanden ist. Spaß überall.

Der allererste Schritt besteht also darin, die von Ihnen unterstützten Vorgänge threadsicher zu gestalten. Das nächste ist, sie threadsicher zu machen. Am einfachsten ist es, einen Mutex zu verwenden. Nur weil eine Hashtabelle threadsicher sein muss, heißt das nicht, dass sie häufig verwendet wird. Ich denke Java hat einige Tricks im Ärmel, um unbestrittene Sperren ziemlich schnell zu machen. Dann würde ich einen Blick darauf werfen, ob Sie eine Spin-Sperre verwenden können, die effizient sein sollte, da Hashtable-Lookups schnell sein sollten Sie sollten wahrscheinlich versuchen, die Schlüssel außerhalb des Schlosses zu vergleichen.

5
gnasher729

Stellen Sie sich vor, wir möchten Ihrem Hashmap zwei neue (Schlüssel, Wert) Paare hinzufügen:

  • die Berechnung des Hash jedes Schlüssels erfolgt ohne Race-Bedingung.
  • die Race-Bedingung tritt ein, sobald Sie auf die Buckets zugreifen und nach möglichen Kollisionen suchen, da ein anderer Thread diese möglicherweise ändert.
  • rennbedingungen treten auch in einem Eimer auf, da andere ihn möglicherweise ändern, während Sie suchen, wo Sie ihn in die Kette der Farbwerte einfügen möchten.

Als erste Bemerkung ist es jetzt einfach, eine sperrenfreie Likörliste zu erstellen. Dies würde das letzte Problem lösen

Sie können diese Funktion nutzen und jeden Bucket der Hashmap bei der Erstellung zu einer (sperrenfreien) (leeren) verknüpften Liste machen, bevor die Hashmap gleichzeitig verwendet wird. Dies löst das erste Problem: Der Zugriff auf einen Bucket erfolgt immer über eine sperrenfreie verknüpfte Liste.

Mit einer solchen Struktur hätten Sie eine schöne gleichzeitige sperrfreie Hashmap, solange Sie die Buckets nicht neu anordnen müssen (z. B. wenn Sie die Anzahl der Buckets dynamisch vergrößern oder verkleinern oder die Hashing-Funktion ändern möchten).

Bearbeiten

Wenn Sie die gesamte Tabelle sperren würden, wären alle Vorteile unserer sperrenfreien Strukturen weg, da wir die globale Sperre erwerben müssten.

Es ist sehr schwierig, es sperrfrei zu halten, daher muss die folgende Idee überprüft werden. Die Idee könnte sein, mit einer Schattentabelle zu arbeiten, die Sie nach dem Start der Größenänderung erstellen würden und die die neuen Buckets enthält:

  • die Leser schauen immer zuerst in die Schattentabelle. Wird es nicht gefunden, wird es in der aktuellen Tabelle angezeigt.
  • die Autoren würden in die Schattentabelle einfügen und den Wert aus der alten Tabelle als gelöscht markieren, falls vorhanden.
  • ein Mover (um zu definieren, wer) liest die alte Tabelle ein, fügt sie in die Schattentabelle ein (aber wenn der CAS fehlschlägt, wird nichts unternommen, weil ein neuerer Wert bereits den alten ersetzt hat), markiert als in der alten Tabelle gelöscht.
  • sobald alle Elemente in der alten Tabelle als gelöscht markiert sind, wechseln Sie vollständig zur neuen und entfernen Sie die alte.

Ich habe die detaillierte Analyse noch nicht durchgeführt, aber ich denke, das Schlimmste, was passieren könnte, ist, dass ein Schriftsteller einen neuen Wert in den Schatten schreibt, während ein Leser ihn nur verpasst und trotzdem den alten liest, was defacto so wäre, als ob es passieren würde vor dem Update. Ein weiterer schlechter Fall wäre, dass ein Thread beginnt, in der aktuellen Tabelle zu suchen, angehalten wird und fortgesetzt wird, sobald die alte Tabelle gelöscht wurde. Es ist nicht ganz klar, wie dies vermieden werden kann. Vielleicht eine Anzahl von Threads, die auf den alten Tisch gesprungen sind?

3
Christophe

TLDR: Es gibt viele verschiedene Möglichkeiten, mit solchen Dingen umzugehen, und Sie können erst dann zwischen ihnen wählen, wenn Sie genau definiert haben, welche Verhaltensweisen Sie benötigen und welche Verhaltensweisen Sie flexibel sind, damit Sie zumindest eingrenzen können Ihre Liste der anwendbaren Parallelitätsmodelle.

Sie sollten am besten zuerst eine genauere Definition dessen herausarbeiten, was Sie brauchen.

  1. Sie fragen nach einer Hash-Tabelle, aber muss Ihre Datenstruktur wirklich eine Hash-Tabelle sein und sonst nichts? (Und wenn ja, warum?) Oder kann es sich um eine Datenstruktur handeln, die eine schnelle Suche nach Schlüsselwerten ermöglicht und in irgendeiner Weise veränderbar ist?

  2. Ihr Kommentar "Offensichtlich mit einem Mutex schützen" könnte so verstanden werden, dass er ein serialisiertes Parallelitätsmodell impliziert. Sobald ein Thread eine Änderung vornimmt, dürfen andere Threads niemals Daten von vor der Änderung sehen. Aber ist das auch wirklich notwendig? Oder wäre es in Ordnung (oder vielleicht sogar besser dran) mit einem "Multiversion" -Überwachungsmodell, bei dem ein Thread eine bestimmte Version der Datenstruktur erhält und alle Suchvorgänge von dieser Version stammen, bis eine spätere Version angefordert wird. (Wenn ein Thread mehrere Suchvorgänge ausführen möchte, die konsistent sein müssen, impliziert das serialisierte Modell, dass er vor dem ersten Lesen gesperrt und die Sperre beibehalten muss, wenn alle Lesevorgänge für diese Transaktion abgeschlossen sind.)

Wenn Sie beispielsweise mit einer Datenstruktur einverstanden sind, die Ihnen ein Schlüsselwertwörterbuch bietet, und Sie mit der Parallelität mehrerer Versionen leben können (oder diese sogar benötigen), können Sie stattdessen ein Hash-Baum mit verwenden ein Verweis darauf, der in einer globalen Variablen gespeichert ist.

Lesevorgänge lesen einfach die globale Variable, um die Wurzel des Baums zu erhalten, und verwenden diese Kopie dann so lange wie nötig, um Werte aus dieser Version des Baums nachzuschlagen, um Konsistenz zu erzielen.

Für Schreibvorgänge haben Sie mindestens zwei Möglichkeiten, je nachdem, welche Art von Leistungskompromissen Sie eingehen möchten. Sie können die globale Variable sperren, bevor Sie Änderungen am Wörterbuch vornehmen, und die Sperre beibehalten, bis Ihre Änderungen abgeschlossen sind. Dies blockiert keine Leser, aber andere Autoren. Alternativ können Sie Ihre Änderungen an einer vorhandenen Version des Baums vornehmen, den neuen Baum jedoch nur dann speichern, wenn die aktuelle Version immer noch mit der Version übereinstimmt, die Sie geändert haben, und andernfalls abbrechen. Dies spart die Kosten für die Sperre, verursacht jedoch höhere Kosten für die Wiederherstellung nach Konflikten zwischen Schreibthreads (und möglicherweise auch eine komplexere Wiederherstellung, wenn Sie die Änderungen, die Sie an der neuen Version des Baums vornehmen möchten, nicht einfach "wiedergeben" können). .

3
cjs

Thread-sichere (auch als "gleichzeitige") Datenstrukturen (z. B. Hash-Maps) sind niemals vollständig "gleichzeitig" in dem Sinne, dass zwei oder mehr Threads jederzeit vollständig gleichzeitig auf einen Teil von ihnen zugreifen können. "Gleichzeitig", wie es auf Datenstrukturen angewendet wird, bedeutet einfach, dass sie threadsicher sind, d. H. Zwei oder mehr Threads können jederzeit einen gleichzeitigen Zugriff versuchen, und die Struktur behält die Konsistenz für alle Leser/Schreiber bei. In der Praxis bedeutet dies, dass Threads unter bestimmten Umständen blockiert werden. Gut geschriebene gleichzeitige Strukturen verwenden geeignete Strategien, um das Auftreten von Blockierungen und die Verzögerung aufgrund von Blockierungen zu minimieren, wenn diese auftreten.

1
Zenilogix