Eine einzelne Anweisung kann wie eine unteilbare Aktion aussehen und doch alles andere als das sein. Nimm an, du willst fünf zu einem Wert im Speicher addieren:
add qword [rel counter], 5
Im Hintergrund kann der Prozessor nicht direkt in den Speicher addieren. Er zerlegt diese eine Anweisung in drei kleinere Schritte, die Mikrooperationen heißen:
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5 ; 2. add five
mov qword [rel counter], rax ; 3. store the result back
Das ist ein gängiges Muster, das Read-Modify-Write (RMW) genannt wird.
Beachte, dass das Lesen (oder Laden) und das Schreiben (oder Speichern) getrennte Ereignisse sind, zwischen denen ein Zeitfenster liegt.
Normalerweise fällt uns das nicht auf, weil auf einem einzelnen Kern garantiert ist, dass jede Anweisung ihre Wirkung vollständig entfaltet, bevor die nächste an der Reihe ist.
Dieses Fenster bleibt deshalb unsichtbar, und add verhält sich wie eine einzige Einheit.
Moderne CPUs haben jedoch selten nur einen Kern, und Anwendungen laufen oft gleichzeitig auf vielen Kernen, ohne feste Reihenfolge zwischen ihnen.
Wenn mehrere Kerne im selben Augenblick laufen, kann ein anderer Kern counter innerhalb dieses Fensters lesen oder schreiben, also nach dem Laden dieses Kerns und vor dem Speichern.
Zwei Threads laden beide denselben alten Wert, addieren beide fünf und speichern beide ihr Ergebnis.
Es fanden zwei Additionen statt, aber der Wert hat sich nur um fünf verändert.
Ein Update ging stillschweigend verloren.
Das ist ein Data Race, und es ist ein häufiges Problem in Multithread-Code.
Nicht jeder Wert ist dieser Gefahr ausgesetzt.
Jeder Thread hat seine eigenen Register und seinen eigenen Stack, daher ist ein Wert in einem Register oder eine lokale Variable auf dem Stack eines Threads exklusiv für diesen Thread und kann nicht von einem Race betroffen sein.
Nur der Speicher, den sich die Threads teilen, wie das obige counter, muss geschützt werden.
x86-64 bietet eine Reihe von Anweisungen, um dieses Problem zu lösen, indem sie eine Anweisung unteilbar machen. Sie verhält sich nicht nur für den Kern, der sie ausführt, wie eine einzige Einheit, sondern auch für jeden anderen Kern. Eine Operation, die zusammenhält und die kein anderer Kern aufspalten kann, heißt atomar.
Es ist üblich, Anwendungen, die auf vielen Kernen laufen, als Multithread-Anwendungen zu bezeichnen. Allerdings ist ein Thread nicht dasselbe wie ein Kern. Zwei Threads können gleichzeitig auf demselben Kern laufen und sich miteinander verschachteln oder parallel auf verschiedenen Kernen.
Verschachtelte Threads können schon bei einem Read-Modify-Write in ein Race geraten, wenn dieses in mehrere Anweisungen aufgeteilt ist, da das Betriebssystem Threads zwischen zwei beliebigen Anweisungen wechseln kann. Das Fenster innerhalb einer einzelnen Anweisung wird jedoch nur von echtem parallelen Code sichtbar gemacht. Da das Betriebssystem Threads nur zwischen Anweisungen umschaltet und niemals innerhalb einer, ist eine einzelne Anweisung auf einem einzelnen Kern von Natur aus sicher.
Echte Atomarität über mehrere Kerne hinweg bieten die unten beschriebenen Anweisungen.
Die Anweisung xchg vertauscht zwei Operanden.
Der Zieloperand nimmt den vorherigen Wert des Quelloperanden an, während der Quelloperand den vorherigen Wert des Zieloperanden annimmt.
Konzeptionell kann man sie sich als zwei mov-Anweisungen vorstellen, die gleichzeitig ablaufen.
Wie üblich kann sie mit zwei Registeroperanden oder mit einem Speicheroperanden und einem Registeroperanden verwendet werden:
mov eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx ; edx = 2, ecx = 3
Wenn ein Speicheroperand beteiligt ist, ist xchg immer atomar.
xchg ist automatisch atomar, wenn einer der Operanden eine Speicheradresse ist.
Das bedeutet aber auch, dass die Operation in diesem Fall viel langsamer ist.
Wenn du keine Atomarität brauchst, führe den Tausch stattdessen über ein freies Register mit einfachen mov-Anweisungen durch.
Die gebräuchlichste Methode, eine Anweisung in x86-64 atomar zu machen, ist das Präfix lock.
Es verschmilzt das Lesen, das Ändern und das Schreiben zu einem unteilbaren Schritt.
Das bedeutet, dass der Kern den Speicher während des gesamten Vorgangs exklusiv hält, sodass kein anderer Kern diese Adresse dazwischen lesen oder schreiben kann.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
lock funktioniert nur, wenn das Ziel im Speicher liegt, und nur bei Anweisungen, die diesen Speicher im Read-Modify-Write-Verfahren bearbeiten:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc;xadd und cmpxchg, die weiter unten beschrieben werden.Eine Adresse exklusiv zu halten und jeden anderen Kern auszusperren, ist nicht umsonst.
Eine Operation mit dem lock-Präfix ist deutlich langsamer als ihre einfache Form, und noch langsamer, wenn mehrere Kerne um dieselbe Adresse konkurrieren.
Dieses Präfix solltest du für Speicher reservieren, von dem erwartet wird, dass ihn mehr als ein Thread verändert. Verwende es nicht, wenn der Speicher nicht geteilt wird oder nur gelesen wird.
Ein einfaches lock add aktualisiert den Speicher, verwirft aber den alten Wert.
Oft ist der alte Wert genau das, was man möchte, zum Beispiel um jedem Thread eine eigene Ticketnummer zu geben.
Die Anweisung xadd (x steht für exchange) gibt den vorherigen Wert zurück, während sie addiert.
Sie schreibt die Summe in das Ziel und lässt den ursprünglichen Wert des Ziels im Quellregister stehen.
mov rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
; rax = the old value of [rdi]
Mit dem lock-Präfix ist das ein atomares Fetch-and-Add.
Wenn viele Threads es auf demselben Zähler ausführen, liefert jeder Aufruf einen anderen alten Wert.
Wie bei lock add steht der Zähler am Ende genau auf der Anzahl der Aufrufe.
Anders als bei lock add wird hier jedoch jeder Zwischenwert zurückgegeben, einer an jeden Aufrufer.
xadd addiert und xchg überschreibt, aber keines von beiden kann den neuen Wert vom aktuellen abhängig machen und ihn nur dann anwenden, wenn sich darunter nichts geändert hat.
Diese bedingte Aktualisierung bietet cmpxchg, compare-and-exchange, und sie ist die allgemeinste dieser Primitive.
cmpxchg dest, src verwendet rax als impliziten Akkumulator und vergleicht ihn mit dest:
dest == rax, dann dest = src und ZF = 1.dest != rax, dann rax = dest und ZF = 0.Beachte, dass dest nur dann aktualisiert wird, wenn es gleich dem erwarteten Wert ist, der zuvor in rax geladen wurde.
Diese Gleichheit stellt sicher, dass dest noch den Wert enthält, aus dem der neue berechnet wurde, sodass eine Aktualisierung auf Basis eines veralteten Lesevorgangs niemals angewendet wird.
Dadurch ist cmpxchg der Baustein für eine atomare Aktualisierung, auch bekannt als Compare-and-Swap (CAS):
mov rax, qword [rdi] ; rax = the value we expect to find
.retry:
lea rcx, [rax + 10] ; rcx = the new value we want to install
lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
; otherwise reload rax with the current value, clear ZF
jnz .retry ; ZF is cleared, so another thread won the race. Recompute and retry
Diese Retry-Schleife ist das Herzstück von lock-freien Aktualisierungen.
Das Fenster zwischen dem Lesevorgang und dem compare-and-exchange ist genau der Moment, in dem ein anderer Thread eingreifen könnte, und cmpxchg fängt das ab, indem es sich weigert, einen aus einem veralteten Lesevorgang berechneten Wert zu speichern.
Jede bisherige Operation hat genau eine Adresse berührt. Wenn Threads über mehr als eine Adresse kommunizieren, taucht eine neue Frage auf: In welcher Reihenfolge werden die Schreibvorgänge eines Threads für einen anderen sichtbar? Die Regeln, die diese Frage beantworten, sind die Speicherordnung des Prozessors.
Das branchless-code-Konzept hat die Idee eingeführt, dass ein moderner Kern nicht die Anweisungen eins nach dem anderen abarbeitet. Er hält viele gleichzeitig in Bearbeitung und läuft voraus, wo er kann. Das bedeutet, dass ein Schreibvorgang für die anderen Kerne später sichtbar werden kann, als das Programm vermuten lässt, während nachfolgende Anweisungen bereits vorausgeeilt sind.
x86-64 hält eine starke Speicherordnung unter gewöhnlichen Lade- und Speicherzugriffen ein, sodass auf jedem Kern gilt:
Die einzige mögliche Umsortierung ist, dass ein Speichervorgang erst nach einem späteren Ladevorgang einer anderen Adresse abgeschlossen zu sein scheint.
Eine Anweisung mit lock-Präfix oder ein xchg mit Speicheroperand ist eine vollständige Barriere: nichts scheint sie in irgendeine Richtung zu überqueren.
Deshalb genügen sie in den meisten Situationen, um die vollständige Ordnung sicherzustellen.
pause
Anweisungen, die ein Flag setzen und gleichzeitig seinen vorherigen Zustand zurückgeben, heißen Test-and-Set. Sie können als Grundlage für einen Spinlock dienen, mit dem ein Kern exklusiven Zugriff auf einen Teil des Codes erhält.
Der Gesamtablauf sieht so aus, mit der Anweisung xchg und einem binären Flag:
0.1.1, bedeutet das, dass die Sperre von einem anderen Kern gehalten wird.
Der aktuelle Kern wartet dann und versucht erneut, die Sperre zu erlangen.0, war die Sperre frei.
xchg hat sie nun auf 1 gesetzt, und andere Kerne warten, bis dieser Kern sie freigibt.0 setzt.acquire:
mov eax, 1
xchg dword [rdi], eax ; try to take the lock; eax = its old value
test eax, eax
jnz .held ; old value was 1: someone else holds it
ret ; old value was 0: the lock is ours
.held:
pause ; wait before trying again
jmp acquire
Die Anweisung pause in der Warteschleife ändert nichts daran, was der Code berechnet.
Sie gibt dem Prozessor den Hinweis, dass hier aktiv gewartet wird.
Die CPU kann dann den Energieverbrauch des wartenden Threads senken und an einen anderen Thread abgeben, der sich denselben Kern teilt.
Eine Warteschleife ohne pause ist immer noch korrekt, nur verschwenderisch.
Sobald der Thread seine Arbeit beendet hat, kann er die Sperre mit einem einfachen mov freigeben, das 0 speichert.
Mehr als ein mov ist nicht nötig, weil auf x86-64 Lade- und Speichervorgänge nie hinter einen späteren Speichervorgang verschoben werden.
Ein Speichervorgang, der die vorhergehenden Zugriffe nie überholt, hat Release-Ordering, und auf x86 bringt jeder einfache Speichervorgang es mit.
Jede Anweisung, die eine Speicheradresse atomar testet und setzt, kann für einen Spinlock verwendet werden.
Zum Beispiel kann lock bts anstelle von xchg verwendet werden, um ein bestimmtes Bit zu setzen und gleichzeitig zu prüfen, ob es bereits gesetzt war.
Beachte, dass Flags wie das von bts geänderte CF Teil von rflags sind, einem Register.
Das bedeutet, dass sie für jeden Thread exklusiv sind.
Es ist Premierenabend im Grand Marquee, und das gesamte Personal ist gleichzeitig im Einsatz. Das Publikum strömt durch jede Tür herein, mehrere Kassenfenster verkaufen von derselben Kartenrolle, die Premiere ist fast ausverkauft, und oben im Vorführraum müssen die Filmrollen weiterlaufen.
Alles, was das Personal gemeinsam nutzt, liegt an einer Speicheradresse, und die Tests rufen deine Funktionen aus mehreren Threads gleichzeitig auf. Ein Wert, der in getrennten Schritten gelesen, geändert und zurückgeschrieben wird, verliert unter dieser Last Aktualisierungen, also muss jede Funktion ihre Aktualisierung atomar ausführen.
Alle gemeinsamen Werte in dieser Übung sind 64-Bit-Ganzzahlen mit Vorzeichen.
Du hast fünf Aufgaben.
Die Aktualisierungen in dieser Übung sollten mit atomaren Anweisungen ausgeführt werden. Die Tests führen deine Funktionen nebenläufig aus und prüfen, dass keine Aktualisierung verloren geht. Ein nicht-atomares Lesen-Ändern-Zurückschreiben wird sie deshalb sehr wahrscheinlich nicht bestehen.
Die Türen sind offen, und an jeder von ihnen reißt ein Platzanweiser Tickets ab. Jede durchgelassene Gruppe wird in einem gemeinsamen Eintrittszähler mitgezählt, und obwohl jede Tür gleichzeitig zählt, muss die Besucherzahl am Ende trotzdem stimmen.
Implementiere die Funktion admit_group, die die Größe einer Gruppe atomar zum Zähler addiert.
Diese Funktion nimmt die folgenden Argumente entgegen, in dieser Reihenfolge:
admissions: Speicheradresse des gemeinsamen Eintrittszählers, eine 64-Bit-Ganzzahl mit Vorzeichen.group_size: die Anzahl der Personen in der Gruppe, eine 64-Bit-Ganzzahl mit Vorzeichen, immer größer als 0.admissions = 120, group_size = 4
// admissions is now 124
Die Tests lassen Gruppen gleichzeitig durch mehrere Türen ein. Der endgültige Zählerstand muss der Summe aller durchgelassenen Gruppen entsprechen.
Diese Funktion hat keinen Rückgabewert. Der Zähler sollte direkt an Ort und Stelle aktualisiert werden.
In den Projektor passt genau eine Rolle. Die nächste Rolle einzulegen und die aktuelle herauszunehmen muss in einem einzigen Handgriff geschehen: Bei zwei Vorführern an derselben Maschine könnten zwei getrennte Schritte dieselbe Rolle an beide aushändigen oder eine davon zu Boden fallen lassen.
Implementiere die Funktion change_reel, die atomar die nächste Rolle einlegt und die zuvor eingelegte zurückgibt.
Diese Funktion nimmt die folgenden Argumente entgegen, in dieser Reihenfolge:
mounted: Speicheradresse der Kennung der eingelegten Rolle, eine 64-Bit-Ganzzahl mit Vorzeichen.next_reel: die Kennung der einzulegenden Rolle, eine 64-Bit-Ganzzahl mit Vorzeichen.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
Diese Funktion gibt die Kennung der zuvor eingelegten Rolle als 64-Bit-Ganzzahl mit Vorzeichen zurück. Die Tests lassen mehrere Threads gleichzeitig an derselben Aufnahme Rollen wechseln und prüfen, dass jede übergebene Rolle genau einmal wieder herauskommt.
Jedes Fenster verkauft aus einem gemeinsamen Zähler der verkauften Karten. Einen Zähler zu erhöhen, wie es die Türen tun, reicht hier nicht: Jeder Verkauf muss auch die Nummer erfahren, die er bekommen hat. Keine zwei Verkäufe dürfen dieselbe Nummer haben, und nach dem Ansturm muss der Zähler genau der Anzahl der verkauften Karten entsprechen.
Implementiere die Funktion sell_ticket, die atomar eins zum Zähler addiert und die Nummer der gerade verkauften Karte zurückgibt.
Die Kartennummer ist der Zählerstand nach diesem Verkauf: Wenn der Zähler 41 war, ist dieser Verkauf Karte 42.
Diese Funktion nimmt folgendes Argument entgegen:
tickets_sold: Speicheradresse des gemeinsamen Zählers der verkauften Karten, eine 64-Bit-Ganzzahl mit Vorzeichen.tickets_sold = 0
// => 1, and tickets_sold is now 1
tickets_sold = 41
// => 42, and tickets_sold is now 42
Diese Funktion gibt die Kartennummer als 64-Bit-Ganzzahl mit Vorzeichen zurück. Die Tests verkaufen gleichzeitig an mehreren Fenstern: Jede zurückgegebene Nummer muss verschieden sein, und der endgültige Zählerstand muss der Anzahl der Verkäufe entsprechen.
Die Premierenvorstellung hat eine feste Anzahl von Plätzen, und jedes Fenster verkauft dafür. Ein Verkauf darf nur durchgehen, solange noch ein Platz frei ist, und die Vorstellung darf nie überbucht werden, nicht einmal, wenn jedes Fenster im selben Augenblick nach dem letzten Platz greift. Den Zähler zu prüfen und ihn dann in zwei getrennten Schritten zu erhöhen lässt ein Zeitfenster dazwischen, deshalb müssen Prüfung und Aktualisierung gemeinsam gelingen oder fehlschlagen.
Implementiere die Funktion claim_seat, die atomar den nächsten Platz beansprucht, wenn noch einer frei ist.
Wird ein Platz beansprucht, gibt die Funktion die Nummer des Platzes zurück, also den Zählerstand der belegten Plätze nach dieser Beanspruchung.
Ist die Vorstellung ausverkauft, gibt sie 0 zurück und lässt den Zähler unverändert.
Diese Funktion nimmt die folgenden Argumente entgegen, in dieser Reihenfolge:
seats_taken: Speicheradresse des Zählers der bereits belegten Plätze, eine 64-Bit-Ganzzahl mit Vorzeichen, nie größer als capacity.capacity: die Anzahl der Plätze in der Vorstellung, eine 64-Bit-Ganzzahl mit Vorzeichen, immer größer als 0.seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1
seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83
seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150
seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150
Diese Funktion gibt die Platznummer als 64-Bit-Ganzzahl mit Vorzeichen zurück, oder 0, wenn die Vorstellung ausverkauft ist.
Der Vorführraum bietet Platz für eine Person.
Ein Wort im Speicher gibt an, ob er besetzt ist: 0 für frei, 1 für besetzt.
Das Personal kommt die ganze Nacht vorbei, jeder mit einer Aufgabe, die drinnen zu erledigen ist: das Logbuch unterschreiben, den Projektor einfädeln, eine Rolle einsortieren.
Wie die Aufgabe auch aussieht, für den Vorführraum gilt immer dieselbe Regel, und du musst sie durchsetzen: immer nur eine Person drinnen, vom Anfang ihrer Aufgabe bis zu ihrem Ende.
Implementiere die Funktion visit_booth, die:
Solange der Vorführraum von jemand anderem belegt ist, sollte sie höflich mit pause warten.
Diese Funktion nimmt die folgenden Argumente entgegen, in dieser Reihenfolge:
booth: Speicheradresse des Besetzt-Worts, eine 64-Bit-Ganzzahl mit Vorzeichen, die entweder 0 oder 1 ist.task: die Aufgabe des Besuchers, eine Funktion, die keine Parameter entgegennimmt und keinen Rückgabewert hat.Beachte, dass die Tests Aufgaben ausführen, die den Vorführraum und ein gemeinsames Logbuch betrachten. Das bedeutet: Der Vorführraum muss während der gesamten Aufgabe besetzt sein, jeder Besuch muss seine Aufgabe genau einmal ausführen, und bei mehreren Besuchern gleichzeitig muss das Logbuch am Ende exakt stimmen.
Die Aufgabe des Besuchers ist eine gewöhnliche Funktion, die der Aufrufkonvention folgt:
Ein Verstoß gegen eine der beiden Regeln kann sich als Absturz äußern statt als fehlgeschlagene Prüfung.
Melde dich bei Exercism an, um x86-64 Assembly mit 22 Konzepte130 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.