Concurrency Guide
Dies ist eine Anleitung zum Verständnis der Nebenläufigkeit im Quellcode von cruby, sei es beim Beitrag zu Ruby durch Schreiben von C oder beim Beitrag zu einem der JITs. Native Erweiterungen werden hier nicht behandelt, nur die Kernsprache. Sie wird Folgendes behandeln:
-
Was muss synchronisiert werden?
-
Wie die VM-Sperre verwendet wird und was Sie tun können und was nicht, wenn Sie diese Sperre erworben haben.
-
Was Sie tun können und was nicht, wenn Sie andere native Sperren erworben haben.
-
Der Unterschied zwischen der VM-Sperre und dem GVL.
-
Was eine VM-Barriere ist und wann sie verwendet werden sollte.
-
Die Sperrenreihenfolge einiger wichtiger Sperren.
-
Wie die Ruby-Unterbrechungsbehandlung funktioniert.
-
Der Timer-Thread und seine Verantwortung.
Was muss synchronisiert werden?
Vor Raktoren konnte nur ein Ruby-Thread gleichzeitig laufen. Das bedeutet jedoch nicht, dass Sie Nebenläufigkeitsprobleme vergessen konnten. Der Timer-Thread ist ein nativer Thread, der mit anderen Ruby-Threads interagiert und einige VM-Interna ändert. Wenn diese Änderungen parallel vom Timer-Thread und einem Ruby-Thread vorgenommen werden können, müssen sie synchronisiert werden.
Wenn Sie Raktoren hinzufügen, wird es komplizierter. Raktoren erlauben es Ihnen jedoch, die Synchronisation für nicht teilbare Objekte zu vergessen, da sie nicht über Raktoren hinweg verwendet werden. Nur ein Ruby-Thread kann das Objekt gleichzeitig berühren. Für teilbare Objekte sind sie tiefgefroren, sodass keine Mutationen an den Objekten selbst stattfinden. Das Lesen/Schreiben von Konstanten über Raktoren hinweg muss jedoch synchronisiert werden. In diesem Fall müssen Ruby-Threads eine konsistente Sicht auf die VM sehen. Wenn die Veröffentlichung des Updates 2 Schritte oder sogar zwei separate Anweisungen erfordert, wie in diesem Fall, ist eine Synchronisation erforderlich.
Die meiste Synchronisation dient dem Schutz von VM-Interna. Diese Interna umfassen Strukturen für den Thread-Scheduler auf jedem Raktor, den globalen Raktor-Scheduler, die Koordination zwischen Ruby-Threads und Raktoren, globale Tabellen (für fstrings, Encodings, Symbole und globale Variablen) usw. Alles, was von einem Raktor mutiert werden kann und gleichzeitig von einem anderen Raktor gelesen oder mutiert werden kann, erfordert eine ordnungsgemäße Synchronisation.
Die VM-Sperre
Es gibt nur eine VM-Sperre, und sie dient kritischen Abschnitten, die nur von einem Raktor gleichzeitig betreten werden können. Ohne Raktoren ist die VM-Sperre nutzlos. Sie verhindert nicht, dass alle Raktoren laufen, da Raktoren auch ohne Erwerb dieser Sperre laufen können. Wenn Sie globale (geteilte) Daten zwischen Raktoren aktualisieren und keine Atomoperationen verwenden, müssen Sie eine Sperre verwenden, und dies ist eine praktische.
Sie können (solange keine anderen Sperren vor der VM-Sperre gehalten werden)
-
Ruby-Objekte erstellen,
ruby_xmallocaufrufen usw.
Sie können nicht
-
In einen anderen Ruby-Thread oder Raktor umschalten. Dies ist wichtig, da viele Dinge zu Kontextwechseln auf Ruby-Ebene führen können, einschließlich
-
Aufruf einer Ruby-Methode, z. B. über
rb_funcall. Wenn Sie Ruby-Code ausführen, kann ein Kontextwechsel stattfinden. Dies gilt auch für in C definierte Ruby-Methoden, da diese in Ruby neu definiert werden können. Dinge, die Ruby-Methoden aufrufen, wie z. B.rb_obj_respond_to, sind ebenfalls nicht gestattet. -
Aufruf von
rb_raise. Dies ruftinitializefür das neue Ausnameobjekt auf. Mit gehaltener VM-Sperre sollte nichts, was Sie aufrufen, eine Ausnahme auslösen können.NoMemoryErrorist jedoch erlaubt. -
Aufruf von
rb_nogvloder einem Ruby-Level-Mechanismus, der einen Kontextwechsel durchführen kann, wie z. B.rb_mutex_lock. -
In eine blockierende Operation eintreten, die von Ruby verwaltet wird. Dies führt zu einem Kontextwechsel zu einem anderen Ruby-Thread, indem
rb_nogvloder etwas Äquivalentes verwendet wird. Eine blockierende Operation ist eine, die den Fortschritt des Threads blockiert, wie z. B.sleepoderIO#read.
-
Intern ist die VM-Sperre vm->ractor.sync.lock.
Sie müssen sich auf einem Ruby-Thread befinden, um die VM-Sperre zu erwerben. Sie können sie auch nicht innerhalb von Funktionen erwerben, die während des Sweeps aufgerufen werden können, da MMTK auf einem anderen Thread sweepen und Sie einen gültigen ec benötigen, um die Sperre zu ergreifen. Aus demselben Grund (unter anderem) können Sie sie auch nicht vom Timer-Thread aus ergreifen.
Andere Sperren
Alle nativen Sperren, die nicht die VM-Sperre sind, teilen sich einen strengeren Satz von Regeln für das, was während des kritischen Abschnitts erlaubt ist. Mit nativen Sperren meinen wir alles, was rb_native_mutex_lock verwendet. Einige wichtige Sperren sind die interrupt_lock, die Raktor-Scheduling-Sperre (schützt globale Scheduling-Datenstrukturen), die Thread-Scheduling-Sperre (lokal für jeden Raktor, schützt Raktor-Scheduling-Datenstrukturen) und die Raktor-Sperre (lokal für jeden Raktor, schützt Raktor-Datenstrukturen).
Wenn Sie eine dieser Sperren erwerben,
Sie können
-
Speicher über Nicht-Ruby-Allokation wie rohes
mallocoder die Standardbibliothek allokieren. Aber Vorsicht, einige Funktionen wiestrdupverwenden Ruby-Allokation durch Makros! -
Verwenden Sie
ccan-Listen, da diese nicht allokieren. -
Führen Sie die üblichen Dinge aus, wie das Setzen von Variablen oder Struct-Feldern, das Manipulieren von verketteten Listen, das Signalisieren von Bedingungsvariablen usw.
Sie können nicht
-
Ruby-verwalteten Speicher allokieren. Dies beinhaltet die Erstellung von Ruby-Objekten oder die Verwendung von
ruby_xmallocoderst_insert. Der Grund, warum dies nicht gestattet ist, ist, dass wenn diese Allokation einenGCverursacht, alle anderen Ruby-Threads so schnell wie möglich eine VM-Barriere beitreten müssen (wenn sie als nächstes Interrupts prüfen oder die VM-Sperre erwerben). Dies geschieht, damit keine anderen Raktoren während desGClaufen. Wenn ein Ruby-Thread auf dieser nativen Sperre wartet (blockiert ist), kann er der Barriere nicht beitreten und es kommt zu einem Deadlock, da die Barriere niemals abgeschlossen wird. -
Ausnahmen auslösen. Sie können
EC_JUMP_TAGauch nicht verwenden, wenn es aus dem kritischen Abschnitt herausspringt. -
Kontextwechsel. Weitere Informationen finden Sie im Abschnitt
VM Lock.
Unterschied zwischen VM Lock und GVL
Die VM Lock ist eine bestimmte Sperre im Quellcode. Es gibt nur eine VM Lock. Das GVL hingegen ist eher eine Kombination von Sperren. Es wird "erworben", wenn ein Ruby-Thread kurz davor steht zu laufen oder läuft. Da viele Ruby-Threads gleichzeitig laufen können, wenn sie sich in verschiedenen Raktoren befinden, gibt es viele GVLs (1 pro SNT + 1 für den Haupt-Raktor). Es kann nicht mehr als "Global VM Lock" betrachtet werden, wie es früher vor Raktoren der Fall war.
VM-Barrieren
Manchmal reicht das Ergreifen der VM-Sperre nicht aus und Sie benötigen die Garantie, dass alle Raktoren angehalten wurden. Dies geschieht zum Beispiel beim Ausführen eines GC. Um eine Barriere zu erhalten, ergreifen Sie die VM-Sperre und rufen rb_vm_barrier() auf. Solange die VM-Sperre gehalten wird, laufen keine anderen Raktoren. Sie wird nicht oft verwendet, da das Ergreifen einer Barriere die Leistung von Raktoren erheblich verlangsamt, aber es ist nützlich zu wissen und manchmal die einzige Lösung.
Sperrenreihenfolgen
Es ist ratsam, nicht mehr als 2 Sperren gleichzeitig auf demselben Thread zu halten. Das Sperren mehrerer Sperren kann zu Deadlocks führen, tun Sie es also mit Vorsicht. Beim Sperren mehrerer Sperren gleichzeitig folgen Sie einer konsistenten Reihenfolge im Programm, sonst können Sie Deadlocks einführen. Hier sind die Reihenfolgen einiger wichtiger Sperren:
-
VM-Sperre vor ractor_sched_lock
-
thread_sched_lock vor ractor_sched_lock
-
interrupt_lock vor timer_th.waiting_lock
-
timer_th.waiting_lock vor ractor_sched_lock
Diese Reihenfolgen können sich ändern, prüfen Sie also den Quellcode, wenn Sie sich nicht sicher sind. Darüber hinaus:
-
Während jeder
ubf(unblock) Funktion kann die VM-Sperre unter bestimmten Umständen darum herum ergriffen werden. Dies geschieht beispielsweise während des VM-Shutdowns. Weitere Details finden Sie im Abschnitt "Interrupt Handling".
Ruby Interrupt Handling
Wenn die VM Ruby-Code ausführt, überprüfen Ruby-Threads periodisch Ruby-Level-Interrupts. Diese Software-Interrupts dienen verschiedenen Zwecken in Ruby und können von anderen Ruby-Threads oder dem Timer-Thread gesetzt werden.
-
Ruby-Threads prüfen, wann sie ihre Timeslice aufgeben sollen. Der native Thread wechselt zu einem anderen Ruby-Thread, wenn seine Zeit abgelaufen ist.
-
Der Timer-Thread sendet einen "Trap"-Interrupt an den Haupt-Thread, wenn Ruby-Signal-Handler ausstehend sind.
-
Ruby-Threads können andere Ruby-Threads bitten, Aufgaben für sie auszuführen, indem sie ihnen einen Interrupt senden. Zum Beispiel senden Raktoren dem Haupt-Thread einen Interrupt, wenn sie eine Datei
requiren müssen, damit dies auf dem Haupt-Thread geschieht. Sie warten auf das Ergebnis des Haupt-Threads. -
Während des VM-Shutdowns wird ein "Terminate"-Interrupt an alle Raktor-Haupt-Threads gesendet, um sie so schnell wie möglich zu stoppen.
-
Beim Aufruf von
Thread#raisesendet der Aufrufer einen Interrupt an diesen Thread, der ihm mitteilt, welche Ausnahme ausgelöst werden soll. -
Das Entsperren eines Mutex sendet dem nächsten Wartenden (falls vorhanden) einen Interrupt, der ihn auffordert, die Sperre zu ergreifen.
-
Das Signalisieren oder Übertragen einer Bedingungsvariable teilt dem/den Wartenden mit, dass sie aufgeweckt werden sollen.
Dies ist keine vollständige Liste.
Beim Senden eines Interrupts an einen Ruby-Thread kann der Ruby-Thread blockiert sein. Er könnte sich zum Beispiel mitten in einem Aufruf von TCPSocket#read befinden. Wenn dies der Fall ist, wird die ubf (unblock function) des empfangenden Threads vom Thread (Ruby-Thread oder Timer-Thread) aufgerufen, der den Interrupt gesendet hat. Jeder Ruby-Thread hat eine ubf, die gesetzt wird, wenn er in eine blockierende Operation eintritt, und nach der Rückkehr davon zurückgesetzt wird. Standardmäßig sendet diese ubf-Funktion ein SIGVTALRM an den empfangenden Thread, um ihn aus dem Kernel zu entblockieren, damit er seine Interrupts prüfen kann. Es gibt andere ubfs, die nicht mit einem Systemaufruf verbunden sind, z. B. beim Aufruf von Ractor#join oder sleep. Alle ubfs werden mit gehaltener interrupt_lock aufgerufen, berücksichtigen Sie dies also, wenn Sie Sperren innerhalb von ubfs verwenden.
Denken Sie daran, dass ubfs vom Timer-Thread aufgerufen werden können, so dass Sie darin keinen ec annehmen können. Der ec (execution context) wird nur auf Ruby-Threads gesetzt.
Der Timer Thread
Der Timer-Thread hat mehrere Funktionen. Diese sind:
-
Senden von Interrupts an Ruby-Threads, die ihre gesamte Timeslice ausgeführt haben.
-
Aufwecken von M:N Ruby-Threads (Threads in Nicht-Haupt-Raktoren), die auf
IOoder nach einer angegebenen Zeitüberschreitung blockiert sind. Dies verwendetkqueueoderepoll, je nach Betriebssystem, umIO-Ereignisse im Namen der Threads zu empfangen. -
Weiteres Aufrufen des
SIGVTARLM-Signals, wenn ein Thread nach dem erstenubf-Aufruf immer noch auf einem Systemaufruf blockiert ist. -
Warten auf
Signalnative Threads (SNT) auf einen Raktor, wenn Raktoren in der globalen Run-Queue warten. -
Erstellen weiterer
SNTs, wenn einige blockiert sind, z. B. aufIOoder aufRactor#join.