Threads, Wettlaufsituationen und Sperren

Lerne, wie Threads es einem Programm ermöglichen, mehrere Dinge gleichzeitig zu tun, und warum die gemeinsame Nutzung von Daten riskant ist. Erzeuge Threads mit POSIX-Threads in C und warte auf ihr Ende, verfolge verlorene Updates durch Wettlaufsituationen und schütze kritische Abschnitte mit Mutexen. Sieh, wie Sperren aus atomaren Hardwarebefehlen wie Test-and-Set und Compare-and-Swap aufgebaut werden, warum Betriebssysteme wartende Threads blockieren und wie du Zähler, Listen, Warteschlangen und Hashtabellen entwirfst, die auch mit vielen Threads korrekt und schnell bleiben.

MittelEinheit 5 von 9 in Kurs: Betriebssysteme – Prozesse, Speicher, Threads und Dateisysteme

Was du lernst

  1. Beschreibe, wie sich die Threads eines Prozesses einen Adressraum teilen und dabei jeweils ihren eigenen Ausführungszustand behalten. Schreibe C-Programme, die Threads erzeugen, ihnen Daten übergeben und auf ihr Ende warten.
    • Vergleiche Threads mit Prozessen
      Erkläre, was einem Thread selbst gehört, was er mit den anderen Threads seines Prozesses teilt, warum Programme Threads nutzen und wie sich ein Wechsel zwischen Threads von einem Wechsel zwischen Prozessen unterscheidet.
    • Erzeuge Threads mit pthreads, übergib ihnen Daten und warte mit Join auf ihr Ende
      Nutze die POSIX-Threadschnittstelle in C, um Threads zu starten, jedem seine Argumente zu übergeben, ihre Ergebnisse einzusammeln und sicherzustellen, dass das Programm vor seinem Ende auf sie wartet.
  2. Verfolge, wie verzahnte Maschinenbefehle auf gemeinsam genutzten Daten falsche Ergebnisse erzeugen, benenne Wettlaufsituationen und kritische Abschnitte im Code und nenne die Garantien, die eine korrekte Lösung für kritische Abschnitte bieten muss.
    • Verfolge, wie bei ungeschützten Aktualisierungen Erhöhungen verloren gehen
      Zerlege eine Aktualisierung gemeinsam genutzter Daten in ihre Maschinenschritte, verfolge Verzahnungen mit verlorenen Updates und erkläre, warum solche Fehler beim Testen selten auftreten und trotzdem real sind.
    • Erkenne Wettlaufsituationen, kritische Abschnitte und atomare Operationen
      Definiere die Grundbegriffe der Nebenläufigkeit, erkenne Wettläufe bei Read-Modify-Write und beim Prüfen mit anschließendem Handeln im Code und nenne die Anforderungen an eine korrekte Lösung des Problems kritischer Abschnitte.
  3. Nutze POSIX-Mutexe, um kritische Abschnitte atomar zu machen, entscheide, was jede Sperre schützt und wie viel Code sie abdeckt, und vermeide Fehler, die die Sperrung unbemerkt unwirksam machen.
    • Sperre einen POSIX-Mutex vor kritischen Abschnitten und gib ihn danach frei
      Erkläre, was eine Sperre garantiert, initialisiere und nutze einen pthread-Mutex korrekt und behebe damit Wettläufe beim gemeinsamen Zähler und beim Bankkonto.
    • Wähle die Sperrgranularität und bewerte Sperren
      Entscheide, wie viele Daten jede Sperre schützt und wie viel Code sie abdeckt, und beurteile eine Sperre nach Korrektheit, Fairness und Leistung.
    • Vermeide häufige Fehler beim Sperren
      Erkenne die typischen Fehler, durch die Daten ungeschützt bleiben oder Threads für immer warten, und behebe jeden davon.
  4. Zeige, warum gewöhnliche Lade- und Speicherzugriffe auf moderner Hardware für eine korrekte Sperre nicht ausreichen, implementiere Spinlocks mit atomaren Befehlen, sorge für Fairness und erkläre, wie Betriebssystemunterstützung unnötiges aktives Warten vermeidet.
    • Erkläre, warum einfache Sperransätze scheitern
      Analysiere drei frühe Ansätze für wechselseitigen Ausschluss, das Abschalten von Interrupts, ein einfaches Flag und den Peterson-Algorithmus, und erkläre, woran jeder davon scheitert.
    • Implementiere einen Spinlock mit atomaren Befehlen
      Beschreibe Test-and-Set, Compare-and-Swap, Load-Linked/Store-Conditional und Fetch-and-Add, implementiere damit einen Spinlock und bewerte ihn.
    • Sorge mit einer Ticketsperre für faire Spinlocks
      Implementiere eine Ticketsperre mit Fetch-and-Add, verfolge ihren Ablauf und erkläre, wie sie garantiert, dass jeder wartende Thread an die Reihe kommt.
    • Vermeide unnötiges aktives Warten durch freiwillige Prozessorabgabe, Blockieren und zweiphasige Sperren
      Erkläre, wie freiwillige Prozessorabgabe, Blockieren in einer Warteschlange, Futexe und zweiphasige Sperren verschwendete CPU-Zeit verringern, entscheide, wann sich aktives Warten lohnt, und erkenne Prioritätsinversion.
  5. Mache Zähler, verkettete Listen, Warteschlangen und Hashtabellen mit Sperren für viele Threads sicher und beurteile jeden Entwurf nach seiner Korrektheit und seiner Skalierung bei zunehmender Threadanzahl.
    • Implementiere skalierbare Zähler
      Schütze einen Zähler mit einer Sperre, erkläre, warum er schlecht skaliert, und implementiere einen approximativen Zähler mit kernlokalen Zählern und einer Schwelle.
    • Mache verkettete Listen threadsicher
      Schütze eine verkettete Liste mit einer Sperre, halte dabei die kritischen Abschnitte kurz und gib die Sperre auf jedem Pfad frei. Beurteile, wann eine Sperre pro Knoten hilft.
    • Entwirf nebenläufige Warteschlangen und Hashtabellen
      Implementiere eine Warteschlange mit zwei Sperren und einem Dummyknoten sowie eine Hashtabelle mit einer Sperre pro Bucket und gehe bei feingranularer Sperrung messgestützt vor.

Wettlaufsituationen einfach erklärt

5 Kapitel

1

Threads und ihr gemeinsamer Speicher

Ein Prozess gibt einem Programm einen Adressraum; Threads geben ihm mehrere Ausführungsstränge innerhalb dieses Raums. Trenne zunächst den Zustand, den jeder Thread mit sich führt, von den Objekten, die alle erreichen können. Nutze dann die POSIX-Threadschnittstelle, um Arbeitsthreads zu starten und ihre Ergebnisse einzusammeln. Die wichtigsten Fragen beim Entwurf sind, wem die einzelnen Argumente gehören, wie lange ihr Speicher existiert und wann der erzeugende Thread das Ergebnis sicher lesen darf.

1.1 Ein Adressraum, mehrere Ausführungsstränge

Ein Thread ist ein Ausführungsstrang innerhalb eines Prozesses. Mehrere Threads können an denselben Programmdaten arbeiten, aber jeder braucht seine eigene Position im Code und seine eigenen noch nicht abgeschlossenen Funktionsaufrufe.

Was gemeinsam genutzt wird und was einem Thread gehört

Threads eines Prozesses teilen sich Code, globale und statische Variablen, Heap-Objekte, offene Dateideskriptoren und die Prozesskennung. Jeder Thread hat seinen eigenen Befehlszähler, seine eigenen Register, seinen eigenen Stack und seine eigene Threadkennung. Ein globales int total bezeichnet ein gemeinsames Objekt. Ein gewöhnliches lokales int i in der Funktion eines Arbeitsthreads bezeichnet bei jedem Aufruf ein anderes Objekt.

Ein Prozess mit drei Threads hat drei Stacks an verschiedenen Adressen im selben Adressraum. Ein Stack ist kein geschützter Bereich: Ein anderer Thread kann eine lokale Variable erreichen, wenn er ihre Adresse erhält. Diese gemeinsame Nutzung ist nur gültig, solange die Variable existiert. Ein Zeiger in den abgeschlossenen Stackframe einer Funktion kann die Variable nicht am Leben erhalten. Große lokale Arrays und tiefe Rekursion können außerdem den Stack eines Threads erschöpfen.

Zwischen Threads wechseln

Das Betriebssystem speichert den Ausführungszustand jedes Threads in einem Threadkontrollblock, kurz TCB. Beim Wechsel von T1 zu T2 speichert es die Register von T1 und stellt die Register von T2 wieder her. Die unvollendete Berechnung von T1 bleibt in seinen gespeicherten Registern, bis er wieder läuft.

Bei Threads desselben Prozesses bleibt der Adressraum während des Wechsels gleich. Die Seitentabellen müssen sich nicht ändern, was diesen Wechsel meist weniger aufwendig macht als einen Wechsel zwischen Prozessen. Gemeinsamer Speicher bedeutet auch gemeinsame Fehlerfolgen: Ein ungültiger Schreibzugriff über einen Zeiger kann die Daten eines anderen Threads beschädigen, und ein Fehler, der den Prozess beendet, beendet jeden Thread.

Warum mehrere Threads nutzen?

Parallelität bedeutet, im selben Moment auf verschiedenen CPU-Kernen ausgeführt zu werden. Vier unabhängige Teile einer Berechnung können auf vier Kernen eine nahezu vierfache Beschleunigung erreichen, wenn gemeinsame Arbeit und Koordination nicht zu Engpässen werden. Threads erlauben einem Programm außerdem, Warten und Arbeit zu überlappen: Während ein Thread auf das Lesen von einer Festplatte oder eine Netzwerkantwort wartet, kann ein anderer die CPU nutzen.

Nebenläufigkeit bedeutet, dass Ausführungen in überlappenden Zeiträumen Fortschritte machen. Auf einem Kern wechselt der Scheduler zwischen Threads, sodass sie nebenläufig sind, ohne parallel zu laufen. Auch auf einem einzelnen Kern sind Nebenläufigkeitsfehler möglich: Ein Wechsel kann eine gemeinsame Aktualisierung zwischen ihren Maschinenbefehlen unterbrechen.

Wer plant die Threads ein?

Linux und Windows nutzen ein Eins-zu-eins-Modell: Jeder gewöhnliche Anwendungsthread entspricht einem vom Kernel eingeplanten Thread. Verschiedene Threads können auf verschiedenen Kernen laufen, und ein blockierender Aufruf blockiert den aufrufenden Thread. Eine Viele-zu-eins-Bibliothek plant mehrere Benutzerthreads auf einem Kernelthread ein. Ihre Wechsel können wenig aufwendig sein, aber sie kann diese Threads nicht auf mehreren Kernen ausführen, und ein blockierender Systemaufruf kann die ganze Gruppe anhalten.

Wird A vor B erzeugt, ist nicht garantiert, dass A zuerst läuft. Gibt A „A“ und B „B“ aus, kann entweder „AB“ oder „BA“ erscheinen. Die Erzeugungsreihenfolge legt fest, wann ein Thread zur Ausführung bereitsteht; der Scheduler wählt den Zeitpunkt seiner Ausführung.

Wichtige Erkenntnisse

  • Threads teilen sich den Adressraum des Prozesses, aber jeder hat eigene Register, einen eigenen Befehlszähler und einen eigenen Stack.
  • Ein Zeiger kann eine Stackvariable gemeinsam nutzbar machen; ihre Lebensdauer endet dennoch mit der Funktion, zu der sie gehört.
  • Nebenläufigkeit ist auf einem Kern möglich; parallele Ausführung braucht mehrere Kerne.
  • Der Scheduler wählt, welcher lauffähige Thread als Nächstes ausgeführt wird.
  • Das Eins-zu-eins-Threadmodell unterstützt parallele Ausführung und das Blockieren einzelner Threads.

1.2 Starte Arbeitsthreads und sammle ihre Ergebnisse ein

Die Schnittstelle für POSIX-Threads, kurz pthreads, startet eine C-Funktion in einem anderen Thread. Binde <pthread.h> ein und erstelle das Programm mit einem Befehl wie gcc -pthread -o sum sum.c. Die Option -pthread liefert die Compiler- und Linkereinstellungen für Code mit Threads.

Einen Thread mit stabilen Argumenten erzeugen

Der Aufruf pthread_create(&t, NULL, worker, arg) speichert eine Threadkennung in t und startet worker(arg). Der Attributzeiger NULL fordert die Standardwerte an. Ein Arbeitsthread hat die Signatur void *worker(void *arg): Argument und Rückgabewert sind Zeiger. Mit einem Struct kann dieser eine Zeiger mehrere Felder übermitteln.

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

struct range { int lo, hi; long result; };

void *sum_range(void *arg) {
    struct range *r = arg;
    long sum = 0;
    for (int n = r->lo; n <= r->hi; n++)
        sum += n;
    r->result = sum;
    return NULL;
}

Der erzeugende Thread bereitet zwei Structs vor, {1, 50, 0} und {51, 100, 0}, und übergibt jedem Arbeitsthread eine andere Adresse. Halte beide Structs am Leben, bis die Arbeitsthreads fertig sind. Jeder Arbeitsthread summiert in seinem eigenen lokalen sum und schreibt nur sein eigenes Feld result.

pthread_create gibt bei Erfolg 0 und bei einem Fehler eine Fehlernummer zurück. Prüfe diese zurückgegebene Nummer direkt; der Aufruf setzt zur Fehlermeldung nicht errno. Nur eine erfolgreiche Erzeugung liefert einen Thread, auf den mit Join gewartet werden kann.

Vor dem Lesen der Ergebnisse mit Join warten

Nachdem beide Arbeitsthreads erfolgreich erzeugt wurden, ruft der Hauptthread pthread_join(t[0], NULL) und pthread_join(t[1], NULL) auf. Ein Join blockiert, bis der Zielthread endet. Ein erfolgreicher Join macht außerdem die Schreibzugriffe dieses Arbeitsthreads für den wartenden Thread sichtbar. Der Hauptthread kann dann 1.275 und 3.775 lesen und ohne Mutex zu 5.050 addieren: Die Arbeitsthreads haben getrennte Objekte beschrieben, und der Hauptthread hat vor dem Lesen gewartet.

Werden beide Threads vor dem Join gestartet, kann ihre Arbeit überlappen. Wird ein Arbeitsthread erzeugt und sofort mit Join auf ihn gewartet, bevor der nächste erzeugt wird, laufen die Arbeitsthreads nacheinander.

Hier ist das Hauptprogramm. Es beendet sich bei einem pthread-Fehler und verwendet daher nie eine Kennung aus einer fehlgeschlagenen Erzeugung:

static void require_success(int rc) {
    if (rc != 0) {
        fprintf(stderr, "pthread error: %d\n", rc);
        exit(EXIT_FAILURE);
    }
}

int main(void) {
    struct range jobs[2] = {{1, 50, 0}, {51, 100, 0}};
    pthread_t threads[2];
    for (int i = 0; i < 2; i++)
        require_success(pthread_create(&threads[i], NULL,
                                      sum_range, &jobs[i]));
    for (int i = 0; i < 2; i++)
        require_success(pthread_join(threads[i], NULL));
    printf("%ld\n", jobs[0].result + jobs[1].result);
    return 0;
}

Um einen zurückgegebenen Zeiger einzusammeln, übergib die Adresse, an der er abgelegt werden soll: void *answer; pthread_join(t, &answer);. Der Zeiger muss auf Speicher verweisen, der länger als der Arbeitsthread existiert, etwa zugewiesenen Heap-Speicher oder ein Struct des erzeugenden Threads. Die Rückgabe von &local_result durch den Arbeitsthread ist ungültig, weil seine lokale Variable mit der Rückkehr der Funktion endet.

Eine veränderliche Schleifenvariable nicht gemeinsam nutzen

Wird in einer Erzeugungsschleife jedem Arbeitsthread &i übergeben, wird wiederholt dieselbe Adresse übergeben. Arbeitsthreads können sie lesen, nachdem die Schleife den Wert geändert hat, sodass mehrere scheinbar dieselbe Kennung erhalten. Nutze stattdessen ein Array: Weise ids[i] = i zu, übergib &ids[i] und halte das Array am Leben und unverändert, bis alle Join-Aufrufe abgeschlossen sind.

Threads und Prozesse bewusst beenden

Die Rückkehr aus main oder der Aufruf von exit() in einem beliebigen Thread beendet den ganzen Prozess und kann Arbeitsthreads abbrechen. Der Hauptthread wartet normalerweise vor seiner Rückkehr mit Join auf sie. Ein Aufruf von pthread_exit() beendet nur den aufrufenden Thread; andere Threads können weiterlaufen.

Ein abgekoppelter Thread gibt seine Threadressourcen beim Ende automatisch frei und kann nicht mit Join abgewartet werden. Sorge dafür, dass jeder Thread, auf den mit Join gewartet werden kann, einmal abgewartet oder abgekoppelt wird. Join wartet auf den Abschluss; es schützt keine zwei Arbeitsthreads, die dasselbe Objekt aktualisieren, während beide laufen.

Wichtige Erkenntnisse

  • Prüfe pthread-Rückgabecodes direkt; warte mit Join nur auf erfolgreich erzeugte Threads.
  • Übergib getrennte, stabile Argumentobjekte und halte sie bis zum Ende der Arbeitsthreads am Leben.
  • Warte mit Join, bevor du ein Ergebnis liest, das ein Arbeitsthread möglicherweise noch schreibt.
  • Gib nur Zeiger auf Objekte zurück, die länger als der Arbeitsthread existieren.
  • Kopple einen Thread ab, wenn seine Ressourcen ohne Join zurückgewonnen werden sollen.
  • Die Rückkehr aus main beendet den Prozess einschließlich noch nicht abgeschlossener Arbeitsthreads.
2

Finde gemeinsame Schritte, die sich gegenseitig stören können

Eine Quellcodeanweisung kann mehrere Maschinenoperationen verbergen. Verfolgst du diese Operationen der Reihe nach, wird ein Nebenläufigkeitsfehler sichtbar: Ein Thread liest, ein anderer ändert das Objekt und der erste speichert eine Antwort auf Grundlage seines veralteten Lesewerts. Nutze Ablaufverfolgungen, um die gemeinsame Folge zu finden, die zusammenbleiben muss. Unterscheide dann einen Datenwettlauf von einem umfassenderen zeitabhängigen Fehler und beurteile eine Lösung nach Ausschluss, Fortschritt und Warten.

2.1 Verfolge ein verlorenes Update Befehl für Befehl

Die Anweisung counter++ sieht kurz aus, aber das gewöhnliche Erhöhen einer gemeinsam genutzten Ganzzahl ist keine unteilbare Operation. Üblicherweise wird ein Wert geladen, in einem Register addiert und das Ergebnis gespeichert. Zwischen diesen Schritten kann ein anderer Thread laufen.

Ein Ablauf, bei dem eine Erhöhung verloren geht

Beginne mit counter gleich 50. Betrachte Laden, Addieren und Speichern als getrennte Maschinenschritte:

SchrittLaufender ThreadAktionGemeinsamer Zähler
1T150 laden und im eigenen Register 1 addieren50
2T250 laden und im eigenen Register 1 addieren50
3T2Eigenen Registerwert 51 speichern51
4T1Eigenen Registerwert 51 speichern51

Zwei Erhöhungen hätten 52 ergeben müssen. Stattdessen überschreibt T1 das Ergebnis von T2 mit einem aus dem älteren Wert 50 berechneten Wert. Das ist ein verlorenes Update.

Ein Kontextwechsel sichert die Register von T1 in seinem TCB. Wenn T1 fortgesetzt wird, enthält sein Register noch 51; der gemeinsame Zähler wird nicht allein deshalb neu geladen, weil T2 gelaufen ist. Der Fehler liegt in der ungeschützten Folge, nicht darin, dass der Scheduler ein falsches Register wiederherstellt.

Mit einer seriellen Ausführung vergleichen

Schließt T1 alle drei Schritte ab, bevor T2 lädt, speichert T1 51 und T2 danach 52. Auch die umgekehrte Reihenfolge der gesamten Threads ergibt 52. Das Ergebnis 51 der verzahnten Ausführung kann in keiner der beiden seriellen Reihenfolgen entstehen. Um diese Art von Fehler zu finden, markiere einen Lesezugriff, platziere danach einen Schreibzugriff eines anderen Threads und setze die Berechnung des ersten Threads mit dem veralteten Wert fort.

Dieser Ablauf ist ein Modell des Fehlers auf Maschinenebene. In tatsächlichem C bilden nicht synchronisierte, miteinander in Konflikt stehende Zugriffe auf eine gewöhnliche gemeinsame Ganzzahl einen Datenwettlauf, der undefiniertes Verhalten zur Folge hat. C beschränkt dieses Programm nicht auf die Ergebnisse eines einfachen Modells verzahnter Befehle.

Warum bestandene Tests ein schwacher Beleg sind

Zwei Threads, die jeweils 10.000.000 Erhöhungen ausführen, sollten 20.000.000 ergeben. Ungeschützte Versuchsläufe können unterschiedliche kleinere Summen ausgeben, etwa 19.345.221 und 19.221.041 in veröffentlichten Beispielen. Ein gut aussehender Lauf bedeutet nur, dass sein beobachtetes Ergebnis zufällig akzeptabel war.

Das anfällige Zeitfenster kann nur wenige Befehle breit sein. Andere Kernzahlen, Compilerentscheidungen oder Systemlasten können einen Ablauf aufdecken, der bei Tausenden früheren Läufen ausblieb. Führen zwei Threads im Modell jeweils eine Erhöhung ab null aus, erlaubt das Ablaufmodell 1 oder 2. Bei mindestens zwei Erhöhungen pro Thread können seltene Abläufe selbst bei steigender Anzahl beabsichtigter Erhöhungen eine Summe von nur 2 hinterlassen.

Die Operation korrigieren, nicht ihr Erscheinungsbild

Die Deklaration der Ganzzahl als volatile macht Laden, Addieren und Speichern nicht unteilbar und synchronisiert keine Threads. Alle Arbeit in eine Zeile zu schreiben, ändert nichts. Selbst ein x86-Befehl, der direkt zum Speicher addiert, braucht die passende atomare Form, um sich über Kerne hinweg abzustimmen.

Schütze das vollständige Read-Modify-Write mit einem gemeinsamen Mutex oder nutze eine atomare C11-Read-Modify-Write-Operation wie atomic_fetch_add. Dasselbe ist auf einem Kern nötig: Ein timerbedingter Wechsel kann Laden und Speichern auch ohne parallele Ausführung trennen.

Wichtige Erkenntnisse

  • Eine gewöhnliche Erhöhung kann aus Laden, Addieren und Speichern bestehen.
  • Ein Schreibzugriff mit veraltetem Wert kann die abgeschlossene Aktualisierung eines anderen Threads überschreiben.
  • Kontextwechsel erhalten die privaten Register jedes Threads.
  • Volatile bietet weder Atomarität noch Threadsynchronisierung.
  • Ein Datenwettlauf in C hat undefiniertes Verhalten; eine Ablaufverfolgung veranschaulicht den Fehler, ohne seine Folgen zu begrenzen.

2.2 Halte eine Entscheidung und ihre Aktualisierung zusammen

Ein Nebenläufigkeitsfehler kann einen einzelnen Speicherzugriff oder eine ganze Geschäftsregel betreffen. Bestimme sowohl die gemeinsamen Objekte als auch die Operation, die anderen Threads unteilbar erscheinen muss.

Wettlaufsituation oder Datenwettlauf?

Eine Wettlaufsituation entsteht, wenn die Korrektheit vom relativen zeitlichen Ablauf abhängt. Ein Datenwettlauf entsteht, wenn verschiedene Threads ohne Synchronisierung auf dieselbe Speicherstelle zugreifen, mindestens einer sie schreibt und mindestens ein Zugriff nicht atomar ist. In C11 und C++11 hat ein Datenwettlauf undefiniertes Verhalten zur Folge.

Atomare Zugriffe können einen Datenwettlauf vermeiden, während eine größere Operation weiterhin eine Wettlaufsituation enthält. Einzeln geschützte Lese- und Schreibzugriffe halten beispielsweise eine Entscheidung zwischen diesen Zugriffen nicht gültig. Zwei Threads können jeweils einen ausreichenden Kontostand lesen, die Sperre freigeben und später aufgrund dieser veralteten Entscheidung Geld abheben.

Eine Abhebung durchgehen

Ein Kontostand beginnt bei 100. Zwei Threads fordern jeweils 80 an. Die Regel lautet, eine Abhebung nur zu akzeptieren, wenn der Kontostand sie deckt.

  1. T1 prüft 100 und entscheidet, dass seine Abhebung möglich ist.
  2. T2 prüft ebenfalls 100 und trifft dieselbe Entscheidung.
  3. T1 zieht 80 ab, sodass 20 bleiben.
  4. T2 handelt aufgrund seiner früheren Entscheidung und zieht 80 von 20 ab, sodass −60 bleiben.

Nur die Subtraktion zu schützen, behebt diesen Ablauf nicht. Schütze die Prüfung und die Subtraktion mit derselben Sperre, die durchgehend gehalten wird. Dann hinterlässt ein Thread 20 und der nächste prüft 20 und lehnt ab. In einem anderen ungeschützten Ablauf kann eine Subtraktion verloren gehen und nach Auszahlung beider Abhebungen 20 übrig bleiben, was ebenfalls falsch ist.

Den kritischen Abschnitt finden

Ein kritischer Abschnitt ist der Bereich für gemeinsame Daten, der konkurrierende Zugriffe ausschließen muss. Eine Read-Modify-Write-Folge liest zunächst Daten, berechnet einen neuen Wert und schreibt ihn zurück. Eine Folge aus Prüfen und anschließendem Handeln entscheidet, ob eine Handlung erlaubt ist, und führt sie dann aus. Auch Listenoperationen, verzögerte Initialisierung und Dateierzeugung können diese Muster enthalten.

Eine Sperre bietet wechselseitigen Ausschluss: Höchstens ein beteiligter Thread befindet sich gleichzeitig im geschützten Bereich. Eine atomare Operation erscheint anderen beteiligten Threads als eine vollständige Operation; sie können keinen halbfertigen Zustand beobachten. Ein Mutex bietet diese Sicht nur für Code, der derselben Sperrvereinbarung folgt.

Nicht jedes Objekt braucht eine Sperre. Daten, auf die ein Thread zugreift, bleiben privat, solange ihre Adresse nicht weitergegeben wird. Daten, die vor dem Start der Arbeitsthreads vollständig initialisiert und danach nur gelesen werden, können ohne Sperre gemeinsam genutzt werden. Beurteile, wer ein Objekt erreichen kann und ob Schreibzugriffe seine Nutzung überlappen, nicht nur, wo es deklariert wurde.

Was eine korrekte Lösung garantiert
  • Wechselseitiger Ausschluss: Konkurrierende Threads können nicht gemeinsam eintreten.
  • Fortschritt: Ist der Abschnitt frei und wollen Threads eintreten, können Threads, die nicht eintreten wollen, die Auswahl eines eintretenden Threads nicht unbegrenzt verzögern.
  • Beschränktes Warten: Nach einer Anfrage ist begrenzt, wie viele konkurrierende Eintritte ihr vorausgehen können.

C11-atomic_fetch_add(&count, 1) führt eine atomare Aktualisierung auf einem atomic_int aus. Atomarität ist selbst auf einer Plattform garantiert, die die Operation mit einer internen Bibliothekssperre implementiert. Werden zwei Kontofelder einzeln atomar gemacht, wird eine Überweisung zwischen ihnen dadurch noch nicht zu einer atomaren Transaktion; auch die Beziehung zwischen den Feldern braucht Schutz.

Wichtige Erkenntnisse

  • Datenwettläufe betreffen nicht synchronisierte, miteinander in Konflikt stehende Speicherzugriffe; Wettlaufsituationen betreffen zeitabhängige Korrektheit.
  • Eine Prüfung und die darauf beruhende Handlung gehören in einen kritischen Abschnitt.
  • Wechselseitiger Ausschluss, Fortschritt und beschränktes Warten sind getrennte Anforderungen.
  • Getrennte atomare Zugriffe machen eine mehrstufige Operation nicht atomar.
3

Schütze gemeinsame Operationen mit Mutexen

Eine Sperre funktioniert durch eine gemeinsame Vereinbarung: Jede Operation auf geschützten Daten folgt derselben Regel. Lerne den Lebenszyklus eines POSIX-Mutex kennen und entscheide dann, wo ein kritischer Abschnitt beginnt und endet. Kurze Abschnitte verringern die Wartezeit, aber eine Prüfung muss bei der Handlung bleiben, die sie erlaubt. Indem du Code auf fehlende Freigaben, nicht zusammenpassende Sperren und unsichere Lebensdauern prüfst, machst du diese Vereinbarung überprüfbar und wartbar.

3.1 Nutze einen Mutex von der Initialisierung bis zur Freigabe

Ein Mutex ist eine Sperre, durch die jeweils ein Thread einen geschützten Bereich besitzen kann. Ein Thread erwirbt sie vor dem Zugriff auf geschützte Daten und gibt sie danach frei. Hält ein anderer Thread sie bereits, wartet ein blockierender Sperraufruf.

Den gemeinsamen Mutex initialisieren

Verwende pthread_mutex_t aus <pthread.h>. Ein statischer Mutex kann mit pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; beginnen. Ein dynamisch erzeugtes Objekt kann sein Feld mit pthread_mutex_init(&obj->lock, NULL) initialisieren. Die Initialisierung muss abgeschlossen sein, bevor ein Thread den Mutex verwendet.

Hier ist eine Zähleroperation mit einem statisch initialisierten gemeinsamen Mutex. Die Hilfsfunktion prüft pthread-Rückgabewerte, die direkt Fehlernummern sind:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
static long counter = 0;  // guarded by m

static void check(int rc) {
    if (rc != 0) {
        fprintf(stderr, "pthread error: %d\n", rc);
        exit(EXIT_FAILURE);
    }
}

void increment(void) {
    check(pthread_mutex_lock(&m));
    counter = counter + 1;
    check(pthread_mutex_unlock(&m));
}

Jeder nebenläufige Lesezugriff und jede Aktualisierung von counter muss derselben Regel folgen. Eine separate Funktion mit einem anderen Mutex würde sich nicht mit increment abstimmen.

Was geschieht, während der Mutex gehalten wird?

T1 sperrt m, lädt den Zähler, rechnet und speichert. Ruft T2 währenddessen pthread_mutex_lock(&m) auf, kann er den Aufruf erst passieren, wenn er nach der Freigabe durch T1 den Mutex erworben hat. T1 kann weiterhin verdrängt werden: Ein Mutex verhindert konkurrierenden Eintritt, keine Kontextwechsel.

Halte für die Kontostandsregel den Mutex bereits vor der Auswertung von balance >= amount. Ziehe den Betrag nur bei bestandener Prüfung ab, halte Erfolg oder Misserfolg fest und gib dann die Sperre frei. Ein Kontostand von 100 und zwei Anfragen über 80 erlauben genau einen Erfolg und hinterlassen 20.

Wählen, wie gewartet wird

pthread_mutex_trylock(&m) kehrt sofort zurück: 0 bedeutet, dass der aufrufende Thread den Mutex erworben hat; EBUSY bedeutet, dass er ihn nicht erworben hat, weil der Mutex gehalten wurde. Auf dem Pfad bei belegtem Mutex kann der Thread andere Arbeit erledigen und darf nicht auf die geschützten Daten zugreifen. pthread_mutex_timedlock wartet bis zu einem absoluten Zeitpunkt. Auch bei diesen Schnittstellen müssen Rückgabecodes geprüft und darf eine Sperre nur nach erfolgreichem Erwerb freigegeben werden.

Besitz und Lebensdauer beachten

Der besitzende Thread gibt einen Mutex frei. Ein normaler nicht rekursiver Mutex blockiert, wenn sein Besitzer versucht, ihn erneut zu sperren. Ein fehlerprüfender Mutex meldet Fehler wie erneutes Sperren mit EDEADLK oder Freigabe durch einen anderen Thread mit EPERM. Ein rekursiver Mutex zählt wiederholte Erwerbungen durch seinen Besitzer und verlangt dieselbe Anzahl von Freigaben. Verlass dich nicht darauf, dass ein Standardmutex Fehlbedienung meldet: Sein Verhalten hängt vom zugeordneten Implementierungstyp und der Operation ab.

Wenn alle Threads den Mutex nicht mehr verwenden und er freigegeben ist, rufe pthread_mutex_destroy auf. Wird ein Objekt zerstört oder sein Speicher freigegeben, während ein anderer Thread es noch sperren kann, wird die Lebensdauer des Mutex verletzt. Die Übergabe des Mutex per Zeiger erhält seine Identität; durch Kopieren entsteht kein weiteres gültiges Handle für dieselbe Sperre.

Wichtige Erkenntnisse

  • Initialisiere einen Mutex, bevor du ihn Arbeitsthreads zur Verfügung stellst.
  • Erwirb vor jedem konkurrierenden Zugriff auf geschützte Daten denselben gemeinsamen Mutex.
  • Ein gehaltener Mutex verhindert konkurrierenden Eintritt, während sein Besitzer weiterhin verdrängt werden kann.
  • Gibt Trylock EBUSY zurück, wurde der Mutex nicht erworben.
  • Halte den Mutex am Leben, bis kein Thread ihn mehr verwenden kann.
  • Fehlerprüfende Mutexe melden Besitzfehler; rekursive Mutexe zählen verschachtelte Erwerbungen.

3.2 Wähle, was eine Sperre schützt

Sperrgranularität bezeichnet den Umfang des gemeinsamen Zustands, den eine Sperre abdeckt. Sie bestimmt, welche Operationen aufeinander warten müssen, selbst wenn sie auf verschiedenen Kernen laufen.

Eine große Sperre oder mehrere kleinere?

Ein grobgranularer Entwurf schützt eine ganze Struktur oder ein Subsystem mit einem Mutex. Er hat eine einfache Schutzregel, aber auch voneinander unabhängige Operationen werden serialisiert. Ein feingranularer Entwurf weist einzelnen Konten, Buckets oder Bereichen getrennte Mutexe zu. Threads können dann gleichzeitig in verschiedenen Bereichen arbeiten, müssen dafür aber mehr Sperroperationen und kompliziertere Regeln berücksichtigen.

Lege die Sperre neben dem Zustand ab, den sie schützt:

struct account {
    pthread_mutex_t lock;
    long balance;
    unsigned id;
};

Eine Operation auf einem Konto erwirbt dessen Mutex. Eine Überweisung zwischen zwei verschiedenen Konten braucht beide, weil sie die Beziehung zwischen ihnen ändert. Wähle eine feste Reihenfolge, etwa aufsteigend nach Kontokennung, und verwende sie auf jedem Pfad. Andernfalls kann ein Thread A halten und auf B warten, während ein anderer B hält und auf A wartet.

Eine Sperre begrenzt den Durchsatz

Angenommen, jede Operation hält eine einzige globale Sperre für 2 µs. Von der geschützten Arbeit kann höchstens eine Operation pro 2 µs abgeschlossen werden:

maximaler Durchsatz=12×10−6=500.000 Operationen pro Sekunde.

Zusätzliche Kerne ermöglichen es nicht, dass zwei Operationen denselben Mutex gleichzeitig halten. Sperraufwand und andere Arbeit können die erreichte Rate weiter senken. Die geschützte Dauer liefert daher eine nützliche Schranke, die du berechnen kannst, bevor du eine Beschleunigung erwartest.

Den Abschnitt verkürzen, ohne die Operation zu zerstören

Bereite private Arbeit vor, bevor du den Mutex erwirbst. Weise beim Einfügen in eine Liste zuerst Speicher für den neuen Knoten zu und trage seinen Schlüssel ein. Erwirb den Listenmutex erst, wenn du den aktuellen Listenanfang liest und den Knoten einhängst. Schlägt die Speicherzuweisung fehl, wird keine Sperre gehalten.

Langsame Ein-/Ausgabe, Ausgaben oder ein unbekannter Callback können alle Wartenden aufhalten. Verlege solche Arbeit nach draußen, wenn die Daten dort gültig bleiben können. Halte eine Entscheidung und die davon abhängige Aktualisierung zusammen: Gibst du bei einer Abhebung zwischen der Prüfung des Kontostands und der Subtraktion die Sperre frei, ist das Ergebnis nicht mehr zuverlässig korrekt.

Drei verschiedene Eigenschaften bewerten

Beim wechselseitigen Ausschluss geht es darum, ob der Entwurf konkurrierenden Eintritt tatsächlich verhindert. Bei der Fairness geht es darum, ob ein wartender Thread irgendwann an die Reihe kommt oder verhungern kann. Bei der Leistung geht es um die Kosten der Sperre mit einem Thread, mehreren Threads auf einer CPU und Threads auf mehreren CPUs. Eine Sperre kann korrekt und trotzdem unfair oder langsam sein.

Ressourcenkonkurrenz entsteht, wenn Threads um eine bereits gehaltene Sperre konkurrieren. Bei starker Konkurrenz verschieben sie außerdem Cachezeilen der gemeinsamen Sperre und Daten zwischen Kernen. Ein veröffentlichtes Zählerexperiment benötigte etwa 0,03 s für eine Million Aktualisierungen durch einen Thread, aber über 5 s für zwei Threads mit je einer Million. Das sind Messwerte dieses Aufbaus, keine allgemein gültigen Zeiten.

Halte bei einem Skalierungsexperiment die Arbeit pro Thread konstant. Bei idealer Skalierung erledigen N Threads auf N Kernen das N-Fache der Arbeit in der ursprünglichen Zeit. Beginne mit einer einfachen korrekten Struktur, miss Warte- und Gesamtzeit und teile eine Sperre nur dort auf, wo die zusätzliche Nebenläufigkeit ihren Mehraufwand aufwiegt.

Wichtige Erkenntnisse

  • Grobgranulare Sperren vereinfachen den Schutz; feingranularere Sperren erlauben die gleichzeitige Bearbeitung unabhängiger Bereiche.
  • Eine einzelne Sperre serialisiert die geschützte Arbeit und begrenzt den Durchsatz.
  • Erwirb mehrere Sperren in einer festen Reihenfolge.
  • Beurteile Ausschluss, Fairness und Leistung getrennt.
  • Miss die Leistung, bevor du eine einfache Sperre durch einen komplizierteren Entwurf ersetzt.

3.3 Prüfe jeden Pfad durch die Sperrung

Ein Mutex kann vollkommen korrekt implementiert sein und trotzdem von einem Programm falsch verwendet werden. Prüfe den gesamten Ablauf einer Funktion einschließlich Lesezugriffen, Fehlern, Callbacks und der Zerstörung von Objekten.

Die Sperre bei jedem Verlassen freigeben

Diese Funktion gibt die Sperre auf ihrem Fehlerpfad nicht frei:

pthread_mutex_lock(&m);
if (failed) return -1;
update_shared_state();
pthread_mutex_unlock(&m);

Der zurückkehrende Thread kann enden, aber der Mutex bleibt gesperrt. Ein späterer Aufrufer wartet dann auf eine Freigabe, die nie erfolgt. Nutze einen gemeinsamen Aufräumpfad oder gib die Sperre vor jeder Rückkehr frei. Mit geprüften pthread-Aufrufen kann der Aufbau so aussehen:

int rc = -1;
check(pthread_mutex_lock(&m));
if (!failed) {
    update_shared_state();
    rc = 0;
}
check(pthread_mutex_unlock(&m));
return rc;

Ein einzelner Freigabepunkt ist leichter zu prüfen als viele verstreute. Entscheidend ist, dass jede erfolgreich erworbene Sperre bei jedem regulären Verlassen freigegeben wird.

Die Schutzregel auch beim Lesen befolgen

Eine Statusfunktion, die „nur liest“, kann mit einer aktualisierenden Funktion in einen Wettlauf geraten. Sie kann außerdem zusammengehörige Felder in verschiedenen Stadien einer Aktualisierung beobachten. Veröffentlicht eine Listenoperation einen Knoten, bevor seine Felder vollständig sind, kann ein ungeschützter Leser einem Zeiger zu unfertigen Daten folgen. Erwirb die dokumentierte Sperre für jeden Zugriff, dessen Korrektheit davon abhängt.

Verschiedene Mutexe für dasselbe Objekt zu verwenden, hilft nicht. Erwirbt T1 lockA und T2 lockB, können beide Erwerbungen gelingen. Ein in jedem Arbeitsthread neu lokal deklarierter Mutex macht diesen Fehler leicht übersehbar: Jeder Thread sperrt sein eigenes Objekt, sodass kein gemeinsamer Ausschluss besteht.

Identität und Operationsgrenzen erhalten

Ein pthread_mutex_t darf nicht durch Zuweisung oder durch Kopieren seines Structs kopiert und anschließend als zweites Mutex-Handle verwendet werden. Die Verwendung der Kopie ist undefiniertes Verhalten. Übergib einen Zeiger auf den ursprünglichen gemeinsamen Mutex.

Prüfe auch, wo Freigaben erfolgen. Eine Funktion, die zur Prüfung eines Kontostands sperrt, freigibt und später für die Subtraktion erneut sperrt, hat einzelne Zugriffe geschützt, aber die Entscheidung kann veralten. Halte die Sperre während der gesamten Folge aus Prüfung und anschließendem Handeln durchgehend.

Vermeiden, auf sich selbst zu warten

Ein Aufruf unbekannten Codes bei gehaltenem Mutex kann unerwartet erneut auf dasselbe geschützte Objekt zugreifen. Bei einem normalen Mutex blockiert ein zweiter Sperraufruf durch den Besitzer diesen selbst. Ein fehlerprüfender Mutex meldet diesen Fehler, während ein rekursiver Mutex verschachtelten Besitz erlaubt und entsprechende Freigaben verlangt. Wähle einen Typ bewusst; nutze Rekursion nicht, um einen unklaren Schutzentwurf zu verbergen.

Halte langsame Ein-/Ausgabe und Schlafphasen nach Möglichkeit aus einem gemeinsamen kritischen Abschnitt heraus. Stelle vor dem Zerstören des Mutex oder dem Freigeben seines Objekts sicher, dass kein Thread ihn besitzt oder erneut darauf zugreifen kann. Initialisierung, Nutzung und Zerstörung müssen eine klare Reihenfolge haben.

Laufzeitwerkzeuge zum Aufdecken beobachteter Fehler nutzen

ThreadSanitizer, aktiviert mit -fsanitize=thread, und Valgrinds Helgrind können Datenwettläufe und fehlerhafte Sperrnutzung in beobachteten Ausführungen melden. Verbinde ihre Meldungen mit einer Prüfung gemeinsamer Zugriffe und möglicher Verzahnungen. Ein fehlerfreier Lauf zeigt, dass das Werkzeug in diesen Ausführungen kein Problem gefunden hat; ein ungetesteter Pfad muss weiterhin analysiert werden.

Wichtige Erkenntnisse

  • Gib jeden erfolgreich erworbenen Mutex auf jedem Rückkehrpfad frei.
  • Auch lesende Threads müssen die Schutzregel befolgen, wenn schreibende Threads nebenläufig laufen können.
  • Ein Mutex wird über seine Adresse gemeinsam genutzt; getrennte oder kopierte Mutexe koordinieren Zugriffe nicht.
  • Trenne eine Prüfung nicht durch eine Freigabe von ihrer Handlung.
  • Unbekannte Callbacks können erneut in Code eintreten und versuchen, den gehaltenen Mutex zu erwerben.
  • Ein fehlerfreier Lauf eines Wettlaufdetektors umfasst die beobachteten Ausführungen; prüfe deshalb auch ungetestete Pfade.
4

Wie Sperren erworben, abgewartet und übergeben werden

Eine Sperre muss ihre Besitzentscheidung unteilbar machen und die geschützten Speicherzugriffe ordnen. Gewöhnliche Flags leisten keines von beidem. Gehe von fehlgeschlagenen Sperrversuchen zum atomaren Austausch über und ergänze dann Fairness durch Ticketnummern. Betrachte schließlich den wartenden Thread: Aktives Warten, freiwillige Prozessorabgabe und Blockieren verursachen unterschiedliche Kosten, und das Blockieren braucht ein Protokoll, das kein Wecksignal verliert. Die Schedulingpriorität kann beeinflussen, mit welcher Wartestrategie Fortschritt möglich ist.

4.1 Warum die Prüfung eines einfachen Flags nicht ausreicht

Eine Sperre braucht eine zuverlässige Möglichkeit, einen Besitzer auszuwählen. Auf einen gewöhnlichen Lesezugriff gefolgt von einem gewöhnlichen Schreibzugriff entsteht eine Lücke in dieser Entscheidung, selbst wenn die geschützte Arbeit sehr kurz ist.

Die Kontrolle von Interrupts hat eine begrenzte Rolle

Auf einer CPU kann ein Kernel Interrupts um einen nicht blockierenden kritischen Abschnitt herum kurz abschalten, um dort eine durch Interrupts ausgelöste Verdrängung zu verhindern. Das setzt voraus, dass der Kernel auf der CPU bleibt, ohne freiwillig zu blockieren oder zu wechseln. Der Kernel nutzt diese Technik für kurze interne Operationen.

Sie ist kein allgemeiner Anwendungsmutex. Befehle zur Interruptsteuerung sind privilegiert; dürfte eine Anwendung sie uneingeschränkt nutzen, könnte sie die CPU monopolisieren. Das Abschalten von Interrupts auf einem Kern hindert andere Kerne nicht daran, auf dieselben Daten zuzugreifen. Lange Abschaltzeiten verzögern außerdem die Interruptbehandlung und können bei manchen Geräten zum Verlust von Ereignissen führen.

Den fehlerhaften Ablauf der Flagsperre verfolgen

Betrachte diesen C-ähnlichen Pseudocode:

while (flag == 1) { }
flag = 1;
// critical section
flag = 0;

Beginne mit flag bei 0. T1 prüft das Flag und verlässt die Schleife, pausiert dann aber vor der Zuweisung. T2 prüft dieselbe 0, setzt das Flag auf 1 und tritt ein. T1 wird fortgesetzt, setzt es auf 1 und tritt ebenfalls ein. Beide haben die Prüfung bestanden, bevor einer den Besitz beansprucht hat.

Würden Lesen und Schreiben einzeln atomar gemacht, entfiele ein Datenwettlauf auf dem Flag, aber in diesem Protokoll blieben es zwei getrennte Operationen. Die Besitzübernahme braucht ein atomares Read-Modify-Write oder einen umfassenderen Synchronisationsalgorithmus.

Die leere Schleife ist aktives Warten: wiederholtes Prüfen unter Verbrauch von CPU-Zeit. Auf einer CPU kann ein Wartender, der nach der Verdrängung des Besitzers aktiv wartet, nicht dafür sorgen, dass dieser die Sperre früher freigibt. Er muss den Besitzer schließlich laufen lassen.

Petersons Lösung für zwei Threads

Der Peterson-Algorithmus koordiniert zwei Threads mit einem Interessenflag pro Thread und einem gemeinsamen Bedienungszähler. Thread i führt aus:

flag[i] = 1;
turn = 1 - i;
while (flag[1 - i] == 1 && turn == 1 - i) { }
// critical section
flag[i] = 0;

Er meldet sein Interesse an, gibt bei Gleichstand dem anderen Thread Vorrang und wartet nur, solange der andere interessiert ist und Vorrang hat. Unter sequenzieller Konsistenz behalten die Operationen jedes Threads ihre Programmreihenfolge, und alle Threads beobachten eine gemeinsame Reihenfolge. In diesem Modell bietet der Peterson-Algorithmus wechselseitigen Ausschluss, Fortschritt und beschränktes Warten für zwei Threads.

Auch die Speicherordnung ist wichtig

Moderne Prozessoren und Compiler können Speicheroperationen in einer anderen Reihenfolge sichtbar machen, als sie im Quellcode stehen. Ein Schreibzugriff kann gepuffert bleiben, während ein späterer Lesezugriff den Speicher liest. Beide Threads können dann das Interessenflag des anderen als 0 sehen und beide eintreten. Gewöhnliche gemeinsame C-Ganzzahlen führen außerdem zu Datenwettläufen in C.

Eine gültige Implementierung braucht geeignete atomare Operationen und eine passende Reihenfolge, etwa sequenziell konsistente atomare C11-Operationen für die Flags und den Bedienungszähler. volatile bietet diese Synchronisierung nicht. Praktische Sperren verbinden eine unteilbare Besitzoperation mit Garantien zur Speicherordnung, sodass geschützte Zugriffe auf der richtigen Seite von Erwerb und Freigabe bleiben.

Wichtige Erkenntnisse

  • Getrenntes Prüfen und Setzen ermöglicht zwei Threads, dieselbe freie Sperre zu beanspruchen.
  • Das Abschalten von Interrupts auf einer CPU schließt einen anderen Kern nicht aus.
  • Der Peterson-Algorithmus für zwei Threads setzt sequenziell konsistentes Verhalten voraus.
  • Atomare Besitzübernahme und korrekte Speicherordnung sind beide für eine praktische Sperre nötig.

4.2 Wähle mit einem atomaren Befehl einen Besitzer

Ein atomares Read-Modify-Write verbindet das Lesen und Aktualisieren einer Speicherstelle zu einer unteilbaren Handlung. Es schließt die Lücke, durch die zwei Threads eine gewöhnliche Flagprüfung gemeinsam bestehen können.

Das Flag austauschen und seinen alten Wert prüfen

Test-and-Set, auch atomarer Austausch, schreibt einen neuen Wert und gibt den alten zurück. Bei einem anfänglich auf 0 gesetzten Flag tauscht T1 eine 1 ein und erhält 0 zurück, sodass er eintritt. T2 tauscht eine 1 ein und erhält 1 zurück, sodass er wartet. Bis die Freigabe 0 schreibt, kann kein weiterer Austausch 0 zurückgeben.

Hier ist ein C11-Spinlock mit den standardmäßig sequenziell konsistenten atomaren Operationen:

#include <stdatomic.h>

static atomic_flag held = ATOMIC_FLAG_INIT;

void lock(void) {
    while (atomic_flag_test_and_set(&held)) { }
}

void unlock(void) {
    atomic_flag_clear(&held);
}

Neben dem Flag ist auch die Reihenfolge wichtig. Der Erwerb muss dafür sorgen, dass nachfolgende Zugriffe des kritischen Abschnitts nach dem erfolgreichen Erwerb bleiben; die Freigabe muss dafür sorgen, dass frühere Zugriffe des kritischen Abschnitts vor der Freigabe bleiben. Auch das Zurücksetzen des Flags muss atomar sein; eine Mischung gewöhnlicher Schreibzugriffe mit atomaren Zugriffen implementiert diese Übergabe nicht.

Vor dem Ersetzen vergleichen

Compare-and-Swap, kurz CAS, ändert einen Wert nur, wenn er einem erwarteten Wert entspricht. Angenommen, zwei Threads lesen einen Zähler mit dem Wert 7 und wollen jeweils 1 addieren. T2 ändert 7 erfolgreich in 8. Der Versuch von T1, 7 in 8 zu ändern, scheitert nun, sodass T1 es ausgehend von 8 erneut versucht und den Wert in 9 ändert. Durch die Wiederholung bleiben beide Erhöhungen erhalten.

C11-atomic_compare_exchange_strong gibt Erfolg oder Misserfolg zurück und aktualisiert bei Misserfolg das Argument mit dem erwarteten Wert. Hardwareähnlicher Pseudocode gibt dagegen manchmal den alten gespeicherten Wert zurück. Prüfe, welche Konvention ein Ablauf verwendet. Auf x86 implementiert xchg mit Speicheroperand den Austausch, während lock cmpxchg atomaren Vergleich und Austausch bietet.

Weitere Hardwarebausteine

Load-Linked/Store-Conditional, kurz LL/SC, liest unter Aufbau einer Reservierung und versucht dann einen Schreibzugriff. Der bedingte Schreibzugriff scheitert, wenn ein anderer Schreibzugriff die Reservierung ungültig gemacht hat; er kann auch aus anderen Gründen scheitern und gehört daher in eine Wiederholungsschleife. LDXR/STXR von ARM und lr/sc von RISC-V unterstützen dieses Muster.

Fetch-and-Add addiert atomar und gibt den alten Wert zurück. C11 stellt es als atomic_fetch_add bereit; x86 bietet lock xadd. Die Rückgabe des alten Werts macht es nützlich, um eindeutige Ticketnummern zu vergeben.

C garantiert das atomare Verhalten seiner atomaren Bibliotheksoperationen. Es verlangt nicht, dass jeder atomare Typ sperrfrei ist; manche Operationen nutzen eine interne Bibliothekssperre. Für atomic_flag ist Sperrfreiheit garantiert, und atomic_is_lock_free prüft andere Objekte.

Korrekt bedeutet nicht fair oder schnell

Eine Test-and-Set-Sperre bietet wechselseitigen Ausschluss, aber keine Ankunftsreihenfolge: Ein Thread kann den Wettlauf nach einer Freigabe wiederholt verlieren und verhungern. Auf einer CPU können bei vier Threads mit 10 ms langen Round-Robin-Zeitscheiben drei Wartende 30 ms mit aktivem Warten verschwenden, bevor der verdrängte Besitzer wieder läuft. Auf mehreren Kernen kann bei laufendem Besitzer und kurzen Abschnitten ein kurzes aktives Warten den Aufwand für Blockieren und Aufwecken vermeiden.

Wichtige Erkenntnisse

  • Test-and-Set gibt den alten Flagwert zurück und schreibt dabei atomar den neuen.
  • CAS wiederholt einen Versuch nach einem veralteten Erwartungswert und erhält so konkurrierende Aktualisierungen.
  • LL/SC-Code muss einen fehlgeschlagenen bedingten Schreibzugriff durch Wiederholung behandeln.
  • Fetch-and-Add gibt beim Erhöhen den alten Wert zurück.
  • Erwerb und Freigabe einer Sperre müssen auch geschützte Speicherzugriffe ordnen.
  • Ein Test-and-Set-Spinlock bietet Ausschluss, garantiert aber nicht, dass ein Thread nicht verhungert.

4.3 Vergib die Reihenfolge mit einer Ticketsperre

Eine Test-and-Set-Sperre merkt sich, ob sie gehalten wird, aber nicht, wer gewartet hat. Eine Ticketsperre ergänzt eine Ankunftsreihenfolge durch einen atomaren Ticketgeber und einen Bedienungszähler.

Eine Nummer ziehen und darauf warten

Beide Zähler beginnen bei 0. Jeder aufrufende Thread erhöht ticket atomar und behält dessen alten Wert als mine. Er tritt erst ein, wenn turn gleich mine ist. Der Besitzer gibt die Sperre frei, indem er turn einmal erhöht.

#include <stdatomic.h>

static atomic_uint ticket = 0;
static atomic_uint turn = 0;

void lock(void) {
    unsigned mine = atomic_fetch_add(&ticket, 1);
    while (atomic_load(&turn) != mine) { }
}

void unlock(void) {
    atomic_fetch_add(&turn, 1);
}

Diese standardmäßigen atomaren C11-Operationen bieten sowohl Atomarität als auch sequenziell konsistente Ordnung. Jeder aufrufende Thread hält mine in seiner eigenen lokalen Variablen. Der gemeinsame Ticketzähler vergibt eindeutige ausstehende Nummern.

Drei Ankünfte verfolgen

A zieht Ticket 0 und tritt ein, weil turn 0 ist. B zieht Ticket 1 und wartet. C zieht Ticket 2 und wartet. Das nächste ungenutzte Ticket ist nun 3, während der aktuelle Bedienungszähler noch 0 ist.

Wenn A freigibt, wird turn 1, sodass B eintritt. C wartet weiterhin auf 2. Die Freigabe von B erhöht turn auf 2 und C tritt ein. Ein neu ankommender Thread erhält Ticket 3 und kann C nicht überholen.

Diese Unterscheidung ist bei der Ablaufverfolgung wichtig: ticket hält die nächste zu vergebende Nummer fest; turn hält die Nummer fest, die gerade bedient wird. Das Ziehen eines Tickets ändert nur den Ticketgeber. Die Freigabe schaltet die Bedienung weiter.

Was Fairness garantiert

Ein aufrufender Thread wartet hinter den Threads mit früheren Tickets. Wenn jeder Besitzer die Sperre irgendwann freigibt und die Threads davor laufen können, können später Ankommende ihn nicht überholen. Die Anzahl der konkurrierenden kritischen Abschnitte vor ihm ist durch die früheren ausstehenden Tickets begrenzt. Unter diesen Fortschrittsbedingungen bietet das beschränktes Warten und verhindert Verhungern.

Die Garantie betrifft die Eintrittsreihenfolge, nicht die verstrichene Zeit. Ein Besitzer, der lange aufgehalten wird, kann auch alle späteren Aufrufer lange warten lassen. Eine Ticketnummer kann den Scheduler nicht zwingen, ihren Besitzer auszuführen.

Welche Kosten die Ticketreihenfolge nicht beseitigt

Jeder Wartende wartet weiterhin aktiv. Wird der Thread, der als Nächster an der Reihe ist, nicht eingeplant, warten später angekommene lauffähige Threads, obwohl sich gerade kein Thread im kritischen Abschnitt befindet. Das sind Schedulingkosten der strengen Reihenfolge.

Wartende lesen außerdem eine gemeinsame Speicherstelle turn. Jede Freigabe ändert die Cachezeile, die alle beobachten, und erzeugt Verkehr zwischen den Kernen. Warteschlangenbasierte Spinlocks verringern diesen Verkehr, indem jeder Wartende eine eigene lokale Speicherstelle beobachtet; Linux wechselte von Ticket-Spinlocks zu warteschlangenbasierten Entwürfen.

Tatsächliche Ticketzähler verwenden vorzeichenlose Arithmetik. Gleichheitsprüfungen können über einen Überlauf hinweg funktionieren, wenn die Anzahl ausstehender Tickets kleiner als der Wertebereich des Zählers bleibt. Würde eine Nummer erneut vergeben, während ein früheres Ticket mit derselben Nummer noch aussteht, wären Eindeutigkeit und Ausschluss nicht mehr gewährleistet.

Wichtige Erkenntnisse

  • Die Ticketvergabe ist atomar; ein aufrufender Thread tritt erst ein, wenn seine Nummer dem aktuellen Bedienungszähler entspricht.
  • Die Freigabe erhöht turn, während der Erwerb eine Nummer aus ticket vergibt.
  • Besitzer früherer Tickets können von später Ankommenden nicht überholt werden.
  • Eine faire Reihenfolge beseitigt weder aktives Warten noch Verzögerungen durch das Scheduling oder Verkehr durch gemeinsam genutzte Cachezeilen.

4.4 Lass Wartende blockieren, ohne eine Freigabe zu verpassen

Eine korrekte Sperre kann den größten Teil einer CPU mit Warten verschwenden. Die richtige Wartestrategie hängt davon ab, ob der Besitzer laufen kann und wie lange er die Sperre halten wird.

Freiwillige Prozessorabgabe gibt einen Zug ab

Ein wartender Thread kann nach einem fehlgeschlagenen Erwerbsversuch yield() aufrufen. Die freiwillige Abgabe versetzt ihn vom Zustand „laufend“ in den Zustand „bereit“, sodass der Scheduler einen anderen Thread auswählen kann. Er bleibt lauffähig, kann also bald wieder ausgewählt werden und erneut scheitern. Bei 100 Threads auf einer CPU können bis zu 99 Wartende jeweils laufen und abgeben, bevor ein verdrängter Besitzer erneut an die Reihe kommt. Die Abgabe verringert aktives Warten, bietet aber keine faire Eintrittsreihenfolge.

Blockieren nimmt Wartende aus der ausführbaren Arbeit

Eine blockierende Sperre setzt einen erfolglosen Aufrufer auf eine Warteschlange. Während er blockiert ist, verbraucht er keine CPU-Zeit. Die Freigabe weckt einen Wartenden, damit er wieder laufen kann. Eine kleine interne Spinlock-Schutzsperre kann das Flag und die Warteschlange schützen; sie wird nur für kurze Verwaltungsarbeit gehalten.

Die Trennung zwischen „Blockieren beschließen“ und „tatsächlich blockieren“ birgt das Risiko eines verlorenen Wecksignals:

  1. Ein wartender Thread reiht sich ein und gibt die Schutzsperre frei.
  2. Er wird vor dem Aufruf von park() angehalten.
  3. Der Besitzer gibt die Sperre frei und ruft für ihn unpark() auf.
  4. Wird dieses Wecksignal verworfen, blockiert der Wartende später, ohne dass noch jemand ihn aufweckt.

setpark() von Solaris kündigt die Absicht zu blockieren an. Ein dazwischen eintreffendes Wecksignal bewirkt dann, dass der spätere Aufruf zum Blockieren sofort zurückkehrt. Das Protokoll verbindet die Entscheidung mit dem tatsächlichen Blockieren.

Linux nutzt atomaren Vergleich mit anschließendem Blockieren

Ein Futex verbindet ein Wort im Benutzerspeicher mit Kernelunterstützung für das Warten. futex_wait(addr, expected) blockiert nur, wenn das Wort noch dem erwarteten Wert entspricht; Prüfung und Blockierentscheidung erfolgen atomar gegenüber Operationen auf diesem Futex-Wort. Hat eine Freigabe es bereits geändert, blockiert der Aufrufer nicht aufgrund der veralteten Erwartung.

futex_wake(addr, n) weckt bis zu n Wartende. Eine nicht umkämpfte Sperre kann mit atomaren Befehlen im Benutzerraum erworben und freigegeben werden, sodass ein Systemaufruf entfällt. Die glibc-pthread-Mutexe von Linux nutzen Futexe für das Warten bei Ressourcenkonkurrenz. Ein Wecksignal macht einen Thread lauffähig; sein Sperrprotokoll muss dennoch den Besitz herstellen, bevor er auf geschützte Daten zugreift.

Kurz aktiv warten, dann blockieren

Eine zweiphasige Sperre wartet zunächst kurz aktiv und blockiert dann, falls das Warten andauert. Aktives Warten kann sich lohnen, wenn der Besitzer auf einem anderen Kern läuft und die erwartete Wartezeit kürzer als Blockieren und Aufwecken ist. Bei langen Wartezeiten oder mehr lauffähigen Threads als Kernen ist Blockieren vorzuziehen. Auf einer CPU kann ein aktiv Wartender einem nicht eingeplanten Besitzer nicht helfen, fertig zu werden.

Schedulingpriorität kann Fortschritt verhindern

Bei einer Prioritätsinversion wartet ein Thread mit hoher Priorität auf einen Mutex, den ein Thread mit niedriger Priorität hält, während Arbeit mit mittlerer Priorität den Besitzer von der CPU fernhält. Das Blockieren des hochpriorisierten Wartenden vermeidet aktives Warten, beseitigt aber nicht diese Schedulingverzögerung.

Prioritätsvererbung hebt die Priorität des Besitzers vorübergehend auf die seines höchstpriorisierten Wartenden an, sodass er fertig werden und die Sperre freigeben kann. Die Mars-Sonde Pathfinder der NASA erlebte 1997 durch Prioritätsinversion verursachte Neustarts; das Einschalten der Prioritätsvererbung für den Mutex behob das Problem. Ein aktiv wartender Thread mit hoher Priorität auf einer einzelnen CPU mit strengem Prioritätsscheduling kann noch schlimmer sein: Er kann verhindern, dass der niedrigpriorisierte Besitzer überhaupt läuft.

Wichtige Erkenntnisse

  • Freiwillige Prozessorabgabe hält einen wartenden Thread lauffähig; Blockieren hält ihn an und verbraucht keine CPU-Zeit.
  • Ein korrektes Blockierprotokoll schließt die Lücke zwischen der Entscheidung zu blockieren und dem tatsächlichen Blockieren.
  • Futex-Wait prüft seinen erwarteten Wert atomar zusammen mit der Blockierentscheidung.
  • Eine zweiphasige Sperre wartet kurz aktiv, bevor sie blockiert.
  • Prioritätsvererbung hilft einem Besitzer mit niedriger Priorität, eine für höher priorisierte Arbeit benötigte Sperre freizugeben.
5

Implementiere gemeinsame Strukturen, die korrekt bleiben

Eine Struktur ist threadsicher, wenn nebenläufige Aufrufe bei ihren möglichen Verzahnungen ihrer Spezifikation entsprechen. Beginne mit einem Mutex und einer ausdrücklichen Regel für jedes gemeinsame Feld. Untersuche dann, wo Operationen einander tatsächlich ausschließen müssen: Zähleraktualisierungen, Listendurchläufe, gegenüberliegende Warteschlangenenden und getrennte Hash-Buckets haben unterschiedliche Anforderungen. Jede Verfeinerung muss sichere Veröffentlichung und Speicherlebensdauer erhalten und in Messungen ihren zusätzlichen Aufwand rechtfertigen.

5.1 Tausche Zählergenauigkeit gegen weniger gemeinsame Aktualisierungen

Ein gemeinsamer Zähler lässt sich leicht mit einem Mutex schützen. Seine Operationen increment, decrement und get erwerben jeweils dieselbe Sperre, führen ihre Aufgabe aus und geben sie frei. Er ist exakt, aber alle Aktualisierungen laufen durch eine gemeinsame Stelle.

Warum der exakte Zähler zum Engpass wird

Auf mehreren Kernen serialisiert ein Mutex die Zähleroperationen. Die Kerne übertragen außerdem den Besitz der Cachezeilen von Sperre und Zähler. Zusätzliche Arbeitsthreads können das Warten schneller erhöhen als die nützliche Arbeit. Ersetzt du den Mutex durch atomic_fetch_add, entfällt auf gängigen Prozessoren der ausdrückliche Sperraufwand, aber jede Aktualisierung schreibt weiterhin dieselbe gemeinsame Speicherstelle.

Ein approximativer Zähler, auch ungenauer Zähler genannt, verteilt Aktualisierungen auf lokale Zähler und überträgt sie gebündelt an einen globalen Zähler. Sein ausgegebener globaler Wert lässt aktuelle lokale Erhöhungen bewusst aus.

Die Aktualisierung von lokal nach global implementieren

Halte pro Kern einen Zähler und einen Mutex, dazu einen globalen Zähler mit eigenem Mutex und eine Schwelle S. Für jede Erhöhung:

  1. Wähle den lokalen Platz für diese Aktualisierung und erwirb seinen Mutex.
  2. Addiere 1 zum lokalen Zählerstand.
  3. Erreicht er S, erwirb den globalen Mutex, während du den lokalen weiterhin hältst.
  4. Addiere den lokalen Zählerstand zum globalen, setze den lokalen auf 0 zurück und gib den globalen Mutex frei.
  5. Gib den lokalen Mutex frei.

Halte den gewählten Platz während der gesamten Aktualisierung unverändert. Threads mit verschiedenen Plätzen greifen gewöhnlich auf verschiedene Sperren zu und erwerben den globalen Mutex nur bei einer Übertragung. Auch das Lesen des globalen Zählerstands folgt seiner Sperrregel.

Eine gebündelte Übertragung verfolgen

Angenommen, bei vier Kernen und S gleich 5 sind ihre Anzahlen an Aktualisierungen 7, 3, 4 und 0. Kern 1 überträgt bei seiner fünften Erhöhung 5 und hat danach noch 2 lokale Erhöhungen. Die lokalen Zählerstände am Ende sind 2, 3, 4 und 0. Der globale Zählerstand ist 5 und die tatsächliche Summe 14: Neun Erhöhungen bleiben lokal.

Nach abgeschlossenen Aktualisierungen um jeweils eins liegt jeder lokale Zählerstand von 0 bis einschließlich S − 1. Bei P Kernen kann die globale Summe um höchstens folgenden Wert zurückliegen:

P(S−1).

Bei vier Kernen und S gleich 5 beträgt die Schranke 16. Diese Schranke betrifft abgeschlossene Aktualisierungen und ihre verbleibenden lokalen Zählerstände; wende sie nicht mitten in einer Übertragung an.

Die Schwelle für die Aufgabe wählen

Eine kleinere Schwelle macht den globalen Wert aktueller, erfordert aber häufigeres Erwerben der globalen Sperre. Bei S gleich 1 wird jede Erhöhung global übertragen. Das verursacht zusätzlichen Aufwand für die lokale Sperre, ohne den globalen Engpass zu vermeiden. Eine größere Schwelle verbessert die Skalierung der Aktualisierungen, vergrößert aber den möglichen Rückstand. In einem veröffentlichten Experiment ermöglichte S gleich 1024 vier Threads, vier Millionen Aktualisierungen in nahezu derselben Zeit auszuführen, die ein Thread für eine Million benötigte.

Für eine exakte Momentaufnahme müssen vor dem Addieren der Zählerstände alle lokalen Mutexe in einer festen Reihenfolge und danach der globale Mutex gehalten werden. Diese Reihenfolge stimmt damit überein, dass Aktualisierungen eine lokale Sperre vor der globalen erwerben. Die Momentaufnahme hält nebenläufige Übertragungen während des Lesens an; das getrennte Lesen jedes Feldes könnte eine Übertragung doppelt zählen oder übersehen.

Approximative Zähler eignen sich für Statistiken, bei denen verzögerte Sichtbarkeit akzeptabel ist. Ein Referenzzähler, der bestimmt, wann Speicher freigegeben werden kann, braucht ein exaktes Verfahren: Ein zu niedriger Zählerstand könnte ein noch verwendetes Objekt freigeben.

Wichtige Erkenntnisse

  • Auch ein einzelner atomarer Zähler kann durch gemeinsam genutzte Cachezeilen zum Engpass werden.
  • Approximative Zähler übertragen lokale Erhöhungen gebündelt in einen geschützten globalen Zähler.
  • Nach abgeschlossenen Aktualisierungen um jeweils eins beträgt der maximale Rückstand P(S − 1).
  • Größere Übertragungsschwellen verringern die globale Ressourcenkonkurrenz und erhöhen zugleich den möglichen Rückstand.
  • Eine exakte Momentaufnahme hält alle relevanten Sperren in einer mit den Aktualisierungen vereinbaren Reihenfolge.

5.2 Veröffentliche und durchlaufe Knoten verketteter Listen sicher

Eine gemeinsame verkettete Liste muss sowohl ihre Verknüpfungen als auch die Lebensdauer ihrer Knoten erhalten. Ein Mutex um jede Operation ist ein nützlicher erster Entwurf, weil jeder gemeinsame Durchlauf und jede Änderung einer einzigen Regel folgt.

Wie zwei Einfügevorgänge einen Knoten verlieren

Angenommen, die Liste beginnt bei A. T1 bereitet Knoten X und T2 Knoten Y vor. Ohne gemeinsame Sperre können beide head == A lesen, das next ihres neuen Knotens auf A setzen und dann ihren eigenen Knoten in head speichern. Die spätere Zuweisung an den Listenanfang verdeckt den zuerst eingefügten Knoten. Dieser Knoten wird unerreichbar; ein Einfügevorgang geht verloren und sein Speicher bleibt belegt.

Die geschützte Folge muss das Lesen des aktuellen Listenanfangs, das Setzen der neuen Verknüpfung und das Veröffentlichen des neuen Listenanfangs umfassen. Eine Sperre nur um die letzte Zuweisung würde beiden Threads erlauben, Verknüpfungen aus demselben veralteten Listenanfang aufzubauen.

Private Daten vor dem Erwerb der Sperre vorbereiten

Ein neuer Knoten bleibt privat, bis er in die gemeinsame Liste eingehängt wird. Speicherzuweisung und Initialisierung des Schlüssels können vor dem Erwerb erfolgen. Mit der zuvor verwendeten pthread-Hilfsfunktion, die Rückgabewerte prüft, lässt sich das Einfügen so schreiben:

int insert(list_t *list, int key) {
    node_t *node = malloc(sizeof(*node));
    if (node == NULL) return -1;
    node->key = key;

    check(pthread_mutex_lock(&list->lock));
    node->next = list->head;
    list->head = node;
    check(pthread_mutex_unlock(&list->lock));
    return 0;
}

Schlägt die Speicherzuweisung fehl, kehrt die Funktion zurück, bevor ein Mutex gehalten wird. Der gemeinsame Abschnitt bleibt kurz, während das Prüfen von head und das Aktualisieren der Verknüpfung zusammenbleiben. Threadsichere Speicherzuweisung macht die Liste selbst nicht threadsicher; ihre Verknüpfungen brauchen weiterhin eigenen Schutz.

Lesende folgen derselben Regel

Eine Suche erwirbt den Listenmutex, bevor sie head liest, und hält ihn während des Durchlaufs. Das koordiniert sie mit Einfügen und Entfernen. Setze vor der Schleife eine Ergebnisvariable, aktualisiere sie bei einem Treffer und verlasse die Schleife zu einem einzigen Freigabepunkt vor der Rückkehr. Eine erfolgreiche vorzeitige Rückkehr innerhalb der gesperrten Schleife bräuchte andernfalls eine eigene Freigabe.

Bei diesem Entwurf hält das Entfernen denselben Mutex, während ein Knoten ausgehängt wird. Andere Durchläufe können sich nicht in diesem Knoten befinden, weil sie ebenfalls den Mutex brauchen. Gib ihn erst frei, wenn keine andere Operation ihn noch erreichen kann. Erlaubt eine Schnittstelle einem Aufrufer, einen Knotenzeiger nach der Freigabe der Sperre zu behalten, braucht diese zusätzliche Nutzung eine eigene Vereinbarung zur Lebensdauer; die Listensperre deckt sie allein nicht mehr ab.

Entscheiden, ob sich Sperren pro Knoten lohnen

Sperrkopplung erwirbt die Sperre des nächsten Knotens, bevor sie die des aktuellen freigibt. Diese Überlappung erhält beim Fortschreiten des Durchlaufs einen geschützten Pfad. Mehrere Operationen können dann verschiedene Bereiche der Liste belegen.

Die zusätzliche Nebenläufigkeit hat ihren Preis: Jeder besuchte Knoten bringt Sperr- und Freigabeoperationen mit sich, selbst wenn kein anderer Thread in der Nähe ist. Eine Arbeitslast mit vielen Durchläufen kann langsamer werden als die Version mit einem Mutex. Das Sperren von Knotengruppen ist ein Mittelweg. Vergleiche den komplizierteren Entwurf mit dem einfachen unter der tatsächlichen Arbeitslast, einschließlich erfolgloser Suchen und Entfernungspfade.

Wichtige Erkenntnisse

  • Schütze das Lesen des alten und das Veröffentlichen des neuen Listenanfangs gemeinsam.
  • Weise privaten Knotendaten Speicher zu und initialisiere sie, bevor du die Liste sperrst.
  • Suche und Entfernen folgen derselben Sperrregel wie das Einfügen.
  • Erwirb bei der Sperrkopplung die Sperre des nächsten Knotens, bevor du die des aktuellen freigibst.

5.3 Trenne Warteschlangenenden und Hash-Buckets

Ein Mutex pro Struktur ist ein guter Ausgangspunkt, kann aber Operationen serialisieren, die auf verschiedene Bereiche zugreifen. Zwei nützliche Verfeinerungen sind eine Sperre pro Warteschlangenende und eine Sperre pro Hash-Bucket.

Einen Dummyknoten am Warteschlangenanfang halten

Eine Warteschlange mit zwei Sperren beginnt mit einem Dummyknoten D und head == tail == D. D enthält kein aktuelles Warteschlangenelement. Das erste echte Element, falls vorhanden, liegt bei head->next. Ein Mutex für den Anfang serialisiert Entnahmen; ein Mutex für das Ende serialisiert Einreihungen. Ein Einreihen und ein Entnehmen können daher gleichzeitig fortschreiten.

Das Einreihen bereitet den Wert eines neuen Knotens und seinen leeren nächsten Zeiger privat vor. Unter dem Mutex für das Ende veröffentlicht es den Knoten über das Feld next des alten Endes und verschiebt tail auf den neuen Knoten. Das Entnehmen nutzt den Mutex für den Anfang, um den nächsten Zeiger des alten Dummyknotens zu lesen. Ist er null, gibt es die Sperre frei und meldet eine leere Warteschlange. Andernfalls kopiert es den ersten echten Wert, verschiebt head auf diesen Knoten, gibt die Sperre frei und gibt den alten Dummyknoten frei.

Der Knoten, der den zurückgegebenen Wert enthielt, wird zum nächsten Dummyknoten. Würde dieser Knoten sofort freigegeben, wäre head ein hängender Zeiger.

Den wandernden Dummyknoten verfolgen

Beginne mit D. Das Einreihen von 5 erzeugt N5 und verknüpft D -> N5, mit tail == N5. Das Einreihen von 8 fügt N8 hinzu und ergibt D -> N5 -> N8. Eine Entnahme gibt 5 zurück, verschiebt head auf N5 und gibt D frei. N5 ist nun der Dummyknoten; nur 8 bleibt als Wert in der Warteschlange. Nach einer weiteren Entnahme wird N8 zum Dummyknoten und die Warteschlange ist leer.

Die beiden Enden teilen sich weiterhin eine Verknüpfung

Ist die Warteschlange leer, kann das Einreihen das next des aktuellen Dummyknotens schreiben, während das Entnehmen es unter einem anderen Mutex liest. Die beiden Mutexe allein synchronisieren diese Zugriffe nicht. Deklariere in C11 next als atomaren Zeiger und veröffentliche und lies ihn mit geeigneter Ordnung; standardmäßige sequenziell konsistente atomare Schreib- und Lesezugriffe reichen aus. Die Veröffentlichung muss den vorbereiteten Knotenwert sichtbar machen, bevor die Entnahme ihn liest.

Das Warten auf ein Element braucht zusätzlich zum Sperren einen Signalisierungsmechanismus.

Nach Schlüssel hashen, dann den Bucket sperren

Eine Hashtabelle fester Größe kann in jedem Bucket eine threadsichere Liste mit eigenem Mutex nutzen. Bei 101 Buckets und bucket = key % 101 nutzen die Schlüssel 7 und 50 verschiedene Sperren. Die Schlüssel 7 und 108 werden beide Bucket 7 zugeordnet und müssen dort serialisiert werden. Kollisionen beeinflussen sowohl die Ressourcenkonkurrenz als auch die Länge der Suche.

Alle Zugriffe auf die Liste eines Buckets einschließlich Lesezugriffen folgen dessen Sperrregel. Eine Größenänderung ändert die Bucket-Zuordnung und verschiebt Einträge, sodass dieser einfache Entwurf währenddessen exklusiven Zugriff auf die gesamte Tabelle braucht. Ein Bucket-Mutex allein kann keine Änderung schützen, die jeden Bucket betrifft.

Erst nach dem Finden des Engpasses verfeinern

Beginne mit einer Übersicht des gemeinsamen Zustands: Welche Felder werden gemeinsam genutzt, welcher Mutex oder welche atomare Operation schützt jedes Feld und wann werden Knoten erreichbar oder können freigegeben werden? Prüfe jeden Rückkehrpfad und jeden Pfad bei fehlgeschlagener Speicherzuweisung. Analysiere Verzahnungen, teste unter Last mit einem Wettlaufdetektor und miss die Ressourcenkonkurrenz, bevor du feingranularere Sperren einführst.

Wichtige Erkenntnisse

  • Ein Dummyknoten ermöglicht getrennte Sperren für Operationen am Anfang und Ende der Warteschlange.
  • Eine erfolgreiche Entnahme gibt den alten Dummyknoten frei und macht den Wertknoten zum neuen Dummyknoten.
  • Die gemeinsame next-Verknüpfung muss in einer C11-Warteschlange mit zwei Sperren atomar veröffentlicht und gelesen werden.
  • Getrennte Bucket-Sperren erlauben, dass Operationen in verschiedenen Buckets überlappen.
  • Die Größenänderung einer Hashtabelle erfordert Koordination über die ganze Tabelle hinweg.
  • Verfeinere einen korrekten Entwurf anhand gemessener Ressourcenkonkurrenz und geprüfter Speicherlebensdauern.

Wettlaufsituationen: Beispiele

5 Interaktive Lektionen

1

Verfolge einen Arbeitsthread von der Erzeugung bis zum Abschluss

14 Schritte
Trenne gemeinsamen Speicher vom Threadzustand, übergib stabile Argumente und sammle Ergebnisse nach dem Join ein.
  1. 1
    EinführungBeginne mit zwei Arten von Zustand

    Ein Programm kann mehrere Threads haben, die seinen Code durchlaufen. Jede Ausführung braucht ihren eigenen aktuellen Befehl und ihre eigenen unvollendeten Berechnungen, während alle Threads dieselben Programmobjekte erreichen können.

    Verfolge zuerst diese beiden Arten von Zustand. Implementiere dann eine Summe mit zwei Arbeitsthreads und entscheide, wann der Hauptthread die Ergebnisse lesen darf. Die Sicherheit dieses Entwurfs hängt von Objektlebensdauern und dem Abschluss ab, nicht davon, welcher Arbeitsthread vermutlich zuerst läuft.

  2. 2
    ErklärungGib jeder Ausführung ihren eigenen Platz

    Ein Thread hat einen eigenen Befehlszähler, eigene Register, einen eigenen Stack und eine eigene Threadkennung. Sein Stack speichert seine Stackframes für Funktionsaufrufe und gewöhnliche lokale Variablen. Das Betriebssystem speichert den Ausführungszustand in einem TCB, einem Threadkontrollblock.

    Wenn der Scheduler Threads wechselt, stellt er den Zustand des neuen Threads wieder her. Eine Berechnung im Register des ersten Threads bleibt unvollendet, bis dieser fortgesetzt wird.

  3. 3
    BeispielFinde die gemeinsamen Objekte

    Angenommen, total ist eine globale Variable und jeder Arbeitsthread deklariert ein lokales i. Alle Arbeitsthreads erreichen ein gemeinsames total, aber jeder Funktionsaufruf hat sein eigenes i.

    Die Threads teilen sich auch den Heap und offene Dateideskriptoren. Ihre Stacks liegen an verschiedenen Adressen innerhalb desselben Prozessadressraums. Wird die Adresse einer lokalen Variablen eines Arbeitsthreads übergeben, kann ein anderer Thread sie erreichen; getrennte Stacks erzwingen keine Isolation.

  4. 4
    VerbindungLass Arbeit auch auf einem Kern überlappen

    Auf einem CPU-Kern verzahnt der Scheduler Threads: Sie machen nebenläufig Fortschritte, werden aber nicht gleichzeitig ausgeführt. Auf mehreren Kernen können sie parallel laufen.

    Threads können unabhängige Berechnungen beschleunigen oder das Warten auf Ein-/Ausgabe mit nützlicher Arbeit überlappen. Ein Wechsel innerhalb desselben Prozesses behält Adressraum und Seitentabellen bei und kostet daher meist weniger als ein Prozesswechsel. Gemeinsamer Speicher ermöglicht auch, dass ein ungültiger Schreibzugriff die Daten anderer Threads beschädigt.

  5. 5
    ErklärungSieh, was der Kernel einplanen kann

    In einem Eins-zu-eins-Modell ist jeder Anwendungsthread ein vom Kernel eingeplanter Thread. Der Scheduler kann sie auf verschiedene Kerne verteilen, und ein blockierender Systemaufruf hält den aufrufenden Thread an. Linux und Windows nutzen dieses Modell für gewöhnliche Threads.

    Eine Viele-zu-eins-Bibliothek führt Benutzerthreads auf einem Kernelthread aus. Diese Benutzerthreads können nicht auf mehreren Kernen laufen, und ein blockierender Systemaufruf kann ihre Gruppe anhalten.

  6. 6
    HerausforderungErzeugt ein zusätzlicher Kern den Fehler?

    Ein Arbeitsthread pausiert nach dem Laden gemeinsamer Daten, und ein anderer Arbeitsthread ändert sie, bevor der erste fortgesetzt wird. Könnte das auf einem Kern geschehen?

    ---reveal---

    Ja. Der Scheduler kann zwischen dem Laden und der späteren Nutzung des Werts wechseln. Parallele Ausführung kann mehr Überlappung sichtbar machen, aber Nebenläufigkeit allein erlaubt diesen Ablauf. Wer behauptet, dass eine Ausführung auf einem Kern sicher ist, muss Wechsel zwischen Maschinenbefehlen berücksichtigen.

  7. 7
    ErklärungStarte eine Funktion mit einem Zeiger

    Binde <pthread.h> ein und erstelle das Programm mit -pthread. Eine Arbeitsthreadfunktion hat die Signatur void *worker(void *arg). pthread_create(&t, NULL, worker, arg) startet sie, fordert Standardattribute an und speichert ihre Threadkennung in t.

    Der Aufruf gibt bei Erfolg 0 oder direkt eine Fehlernummer zurück. Prüfe dieses Ergebnis, bevor du den Thread als erzeugt betrachtest. Der neue Thread kann sofort laufen; die Initialisierung muss bereits abgeschlossen sein.

  8. 8
    BeispielTeile die Summe in getrennte Aufgaben auf

    Bereite zwei Structs mit lo, hi und result vor: Ein Bereich ist 1–50 und der andere 51–100. Übergib jedem Arbeitsthread eine andere Struct-Adresse.

    Jeder Arbeitsthread addiert seinen Bereich in einem privaten lokalen Summenspeicher und schreibt dann sein eigenes Ergebnisfeld. Der erste erhält 1.275 und der zweite 3.775. Kein Arbeitsthread schreibt das Ergebnis des anderen, sodass der Entwurf konkurrierende Aktualisierungen eines gemeinsamen Summenspeichers vermeidet.

  9. 9
    BeispielWarte, bevor du die Ergebnisse liest

    Erzeuge beide Arbeitsthreads, bevor du auf einen von ihnen mit Join wartest, damit ihre Berechnungen überlappen können. Dann ruft der Hauptthread für jede erfolgreiche Erzeugung pthread_join auf. Ein Join wartet auf den Abschluss seines Zielthreads und macht dessen Schreibzugriffe für den wartenden Thread sichtbar.

    Der Hauptthread kann nun die zwei getrennten Ergebnisfelder lesen und zu 5.050 addieren. Ein Join für nur einen Arbeitsthread würde nicht sicherstellen, dass der andere sein eigenes Ergebnis fertiggestellt hat.

  10. 10
    TippHalte Argumente und Ergebnisse am Leben

    Ein Argumentzeiger verlängert die Lebensdauer eines Objekts nicht. Halte Structs des erzeugenden Threads am Leben, bis die Arbeitsthreads fertig sind. Ein zurückgegebener Zeiger kann auf Heap-Speicher oder geeigneten Speicher des erzeugenden Threads verweisen; er kann nicht sicher auf eine nicht mehr existierende lokale Variable des Arbeitsthreads zeigen.

    Übergib in einer Erzeugungsschleife getrennte stabile Arrayelemente, statt wiederholt &i zu übergeben. Letzteres lässt alle Arbeitsthreads auf dieselbe veränderliche Schleifenvariable zeigen.

  11. 11
    HerausforderungWelches Ergebnis ist bereit?

    Zwei Arbeitsthreads schreiben getrennte Ergebnisfelder. Der Hauptthread wartet erfolgreich mit Join auf Arbeitsthread A und liest dann beide Felder, während Arbeitsthread B noch laufen kann. Ist das gesamte Lesen sicher?

    ---reveal---

    Das Ergebnis von A ist nach dem Join bereit und sichtbar. Das Feld von B kann sich noch ändern, sodass der Hauptthread bei diesem Entwurf auch auf B mit Join warten muss, bevor er es liest. Getrennter Speicher verhindert gegenseitige Störungen der Arbeitsthreads; Join stellt die jeweilige Übergabe vom Arbeitsthread an den Hauptthread sicher.

  12. 12
    TippUnterscheide das Ende eines Threads vom Ende seines Prozesses

    Die Rückkehr aus main oder der Aufruf von exit() beendet den ganzen Prozess einschließlich nicht abgeschlossener Arbeitsthreads. Der Hauptthread wartet normalerweise vor seiner Rückkehr mit Join auf benötigte Arbeitsthreads; pthread_exit() beendet nur den aufrufenden Thread.

    Ein abgekoppelter Arbeitsthread gibt seine Threadressourcen beim Abschluss automatisch frei und kann nicht mit Join abgewartet werden. Das Abkoppeln hält den Prozess nicht am Leben und gibt dem Arbeitsthread keine private Kopie der Argumentobjekte des erzeugenden Threads.

  13. 13
    HerausforderungBeende das Programm zum richtigen Zeitpunkt

    Der Hauptthread startet zwei Arbeitsthreads, die sein lokales Array lesen, koppelt einen ab und wartet mit Join auf den anderen. Der abgekoppelte Arbeitsthread läuft noch. Kann er dank seines getrennten Stacks das Array nach der Rückkehr aus main weiter verwenden?

    ---reveal---

    Nein. Die Rückkehr aus main beendet den gesamten Prozess einschließlich dieses Arbeitsthreads. Sein getrennter Stack kopiert das Array auch nicht: Der Zeiger erreicht das Objekt von main im gemeinsamen Adressraum. Der Join für einen Arbeitsthread stellt nur dessen Abschluss sicher.

  14. 14
    ZusammenfassungPrüfe die Übergabe durch Arbeitsthreads

    Bestimme für den Entwurf eines Arbeitsthreads seinen Ausführungszustand, die gemeinsam genutzten Objekte und die Lebensdauer jedes Arguments und Ergebnisses. Bestimme dann das Ereignis, das ein Ergebnis für seinen Leser bereitstellt.

    Getrennte Ergebnisobjekte und Join-Aufrufe können Sperren vollständig vermeiden. Gemeinsame Aktualisierungen während überlappender Arbeitsthreads brauchen zusätzliche Koordination. Erzeugungsreihenfolge, getrennte Stacks und Abkoppeln beantworten jeweils eine andere Frage; keines davon stellt sicher, dass ein anderer Thread seine Schreibzugriffe abgeschlossen hat.

2

Verfolge die Operation, die Schutz braucht

11 Schritte
Verfolge ein verlorenes Update, unterscheide zwei Arten von Wettläufen und schütze eine Entscheidung zusammen mit ihrer Handlung.

Speichere diese Einheit, um jede Interaktive Lektion Schritt für Schritt durchzugehen.

  1. 1
    EinführungZerlege die gemeinsame Operation
  2. 2
    ErklärungTrenne Speicher von privaten Registern
  3. 3
    BeispielHalte vor dem Speichern an
  4. 4
    BeispielSetze mit dem veralteten Ergebnis fort
  5. 5
    HerausforderungÄndere den Zeitpunkt des Threadwechsels
  6. 6
    TippWähle eine Synchronisierungslösung
  7. 7
    ErklärungUnterscheide die beiden Wettläufe
  8. 8
    BeispielGenehmige eine Abhebung zweimal
  9. 9
    KernaussageBenenne den vollständigen Abschnitt
  10. 10
    HerausforderungVerfolge Zustimmung und Verringerung gemeinsam
  11. 11
    ZusammenfassungFinde vor dem Sperren die Grenze
3

Prüfe eine Mutex-Schutzregel

14 Schritte
Nutze eine gemeinsame Sperre, wähle ihren Umfang und verfolge jeden Erfolgs- und Fehlerpfad bis zur Freigabe.

Speichere diese Einheit, um jede Interaktive Lektion Schritt für Schritt durchzugehen.

  1. 1
    EinführungMache die Vereinbarung sichtbar
  2. 2
    ErklärungInitialisiere vor der Veröffentlichung des Objekts
  3. 3
    BeispielNimm die vollständige Aktualisierung hinein
  4. 4
    HerausforderungLies ein Trylock-Ergebnis
  5. 5
    TippWähle den Mutextyp bewusst
  6. 6
    ErklärungLass unabhängige Bereiche überlappen
  7. 7
    BeispielBerechne die Serialisierungsschranke
  8. 8
    HerausforderungVerkürze die richtige Arbeit
  9. 9
    KernaussageMiss drei verschiedene Ergebnisse
  10. 10
    BeispielVerfolge die vorzeitige Rückkehr
  11. 11
    ErklärungBewahre eine Sperre und eine Lebensdauer
  12. 12
    HerausforderungErkenne einen Callback-Zyklus
  13. 13
    HerausforderungWähle eine vollständige Korrektur
  14. 14
    ZusammenfassungLies Code mit Blick auf den Besitz
4

Implementiere Besitz, faire Reihenfolge und effizientes Warten

21 Schritte
Gehe von fehlerhaften Flagprüfungen zu atomaren Sperren über und vergleiche dann Ticketreihenfolge, Blockierprotokolle und Prioritätsvererbung.

Speichere diese Einheit, um jede Interaktive Lektion Schritt für Schritt durchzugehen.

  1. 1
    EinführungBaue die Sperre in Schichten auf
  2. 2
    BeispielZwei Aufrufer bestehen dieselbe Prüfung
  3. 3
    ErklärungDie Reihenfolge gehört zum Algorithmus
  4. 4
    HerausforderungWürde Interruptsteuerung zwei Kerne schützen?
  5. 5
    ErklärungTausche das Flag atomar aus
  6. 6
    BeispielErstelle ein portables Flag für aktives Warten
  7. 7
    BeispielEin fehlgeschlagener Vergleich liefert nützliche Information
  8. 8
    ErklärungKenne die Ergebnisse der anderen atomaren Operationen
  9. 9
    HerausforderungWiederhole die Aktualisierung mit einem neuen Wert
  10. 10
    KernaussageAusschluss garantiert nicht, dass jeder an die Reihe kommt
  11. 11
    AnalogieNutze eine Warteschlange mit Nummern
  12. 12
    BeispielLass die Besitzer nacheinander an die Reihe kommen
  13. 13
    HerausforderungUnterscheide Ticketvergabe und Bedienung
  14. 14
    ErklärungProzessorabgabe und Blockieren ändern verschiedene Zustände
  15. 15
    BeispielFinde das Zeitfenster für ein verlorenes Wecksignal
  16. 16
    ErklärungVergleiche und blockiere als eine Entscheidung
  17. 17
    HerausforderungEin Wecksignal trifft vor dem Warten ein
  18. 18
    VerbindungWarte kurz aktiv, wenn der Besitzer fertig werden kann
  19. 19
    BeispielBetrachte Prioritätsinversion aus Sicht des Besitzers
  20. 20
    HerausforderungVerbinde Scheduling und Sperre
  21. 21
    ZusammenfassungFrag dich, worauf der wartende Thread wartet
5

Verfolge gemeinsame Zähler, Knoten und Buckets

16 Schritte
Arbeite gebündelte Zähler, die Veröffentlichung von Listenknoten, den wandernden Dummyknoten einer Warteschlange und Sperren auf Bucket-Ebene durch.

Speichere diese Einheit, um jede Interaktive Lektion Schritt für Schritt durchzugehen.

  1. 1
    EinführungFinde die Grenze der gemeinsamen Nutzung
  2. 2
    ErklärungVerteile häufige Zählerschreibzugriffe
  3. 3
    BeispielÜbertrage ein vollständiges Bündel
  4. 4
    KernaussageBestimme eine Schranke für die fehlenden Erhöhungen
  5. 5
    HerausforderungWähle die Aktualität für eine neue Arbeitslast
  6. 6
    TippHalte Übertragungen für eine exakte Momentaufnahme an
  7. 7
    BeispielVeröffentliche den neuen Listenknoten zuletzt
  8. 8
    ErklärungLesende und entfernende Threads nutzen dieselbe Sperre
  9. 9
    HerausforderungBewege dich durch Sperren pro Knoten
  10. 10
    BeispielInitialisiere die Warteschlange ohne echtes Element
  11. 11
    BeispielVerschiebe den Dummyknoten beim Entnehmen
  12. 12
    KernaussageDer leere Fall verbindet die beiden Enden
  13. 13
    HerausforderungVerfolge den nächsten Dummyknoten
  14. 14
    VerbindungTrenne Hash-Buckets und erhalte die Zuordnung
  15. 15
    HerausforderungVerbinde eine Bucket-Sperre mit sicherem Einfügen
  16. 16
    ZusammenfassungMiss den vollständigen Entwurf

Wettlaufsituationen: Übungsaufgaben

40 Übungsfragen in 4 Sets

Set 1: Mutexgrenzen und atomare Besitzübernahme10 Übungsfragen

Frage 1 von 10

Ein Mutex schützt jeden Bucket einer Tabelle. Welche Art von Sperrgranularität ist das?
  1. Feingranulare Sperrung
  2. Sperrkopplung
  3. Grobgranulare Sperrung
  4. Kernlokale Sperrung
Antwort zeigenAntwort ausblenden
Antwort: C
Eine einzige Sperre für die ganze Tabelle ist grobgranular: Selbst Operationen auf verschiedenen Buckets teilen sich einen Ausschlusspunkt. Feingranulare Sperrung würde kleineren Bereichen wie einzelnen Buckets getrennte Sperren zuweisen. Die Anzahl der Buckets allein bestimmt die Sperrgranularität nicht.

Frage 2 von 10

Eine Listensuche sperrt ihren Mutex, kehrt bei einem Treffer sofort zurück und gibt die Sperre bei einer erfolglosen Suche erst nach der Schleife frei. Warum können spätere Operationen stehen bleiben?
  1. Eine erfolgreiche Suche kehrt mit gehaltenem Mutex zurück.
  2. Eine erfolglose Suche gibt den Mutex vor dem Lesen des Anfangs frei.
  3. Eine erfolgreiche Suche zerstört den Mutex des Knotens automatisch.
  4. Ein zurückgegebenes Ergebnis überträgt den Mutexbesitz an den Aufrufer.
Antwort zeigenAntwort ausblenden
Antwort: A
Der Trefferpfad umgeht die einzige Freigabe und lässt den gemeinsamen Mutex nach der Rückkehr der Suche gesperrt. Die Rückgabe eines Ergebnisses überträgt den Besitz nicht und gibt ihn auch nicht implizit frei. Nutze eine Ergebnisvariable und einen gemeinsamen Freigabepfad oder gib die Sperre ausdrücklich vor jeder Rückkehr nach dem Erwerb frei.

Frage 3 von 10

In einem Flagprotokoll beobachtet A ein zurückgesetztes Flag und pausiert vor dem Setzen. B beobachtet es als zurückgesetzt, setzt es und tritt ein. Was kann geschehen, wenn A fortgesetzt wird?
  1. A muss das Flag vor seiner ausstehenden Zuweisung erneut prüfen.
  2. A setzt das Flag und tritt ebenfalls ein, wodurch der Ausschluss verletzt wird.
  3. Die Zuweisung von B ändert den Wert, den A bereits beobachtet hat.
  4. Der Kontextwechsel von A macht seine abgeschlossene Verfügbarkeitsprüfung rückgängig.
Antwort zeigenAntwort ausblenden
Antwort: B
A hat seine Verfügbarkeitsprüfung bereits bestanden, sodass das Protokoll ihn mit der Zuweisung fortfahren und neben B eintreten lässt. Ein Schedulerwechsel führt eine frühere Prüfung nicht erneut aus und ändert den bereits gelesenen Wert nicht. Prüfung und Besitzübernahme müssen eine atomare Handlung oder Teil eines korrekten umfassenderen Protokolls sein.

Frage 4 von 10

Ein atomarer Austausch speichert 1 in einem Flag, das derzeit 0 enthält. Welchen Wert gibt Test-and-Set zurück?
  1. 1
  2. Die Adresse des Flags
  3. Das Ticket des Threads
  4. 0
Antwort zeigenAntwort ausblenden
Antwort: D
Test-and-Set gibt den Wert zurück, der vor dem Austausch vorhanden war, also 0, während das Flag auf 1 gesetzt bleibt. Die Rückgabe der neu gespeicherten 1 würde in diesem Sperrprotokoll ein freies Flag nicht von einem bereits belegten unterscheiden. Der zurückgegebene alte Wert zeigt dem Aufrufer, ob er den Besitz erworben hat.

Frage 5 von 10

Ein Dienst mit 16 Kernen nutzt einen Mutex pro Konto. Eine Überweisung erwirbt zuerst den Mutex des Quellkontos und dann den des Zielkontos. Nebenläufige Überweisungen können in entgegengesetzte Richtungen gehen. Welche Regel erlaubt weiterhin Nebenläufigkeit für unabhängige Konten und behebt das Problem der Sperrreihenfolge?
  1. Erwirb zuerst den Mutex des Quellkontos und gib ihn vor dem Sperren des Zielkontos frei.
  2. Erwirb bei jeder Überweisung in beiden Richtungen zuerst den Mutex des Zielkontos.
  3. Erwirb einen globalen Mutex für jede Operation auf jedem Konto.
  4. Erwirb beide Kontomutexe in aufsteigender Reihenfolge der Kontokennungen.
Antwort zeigenAntwort ausblenden
Antwort: D
Die aufsteigende Reihenfolge der Kontokennungen gibt entgegengesetzten Überweisungen dieselbe Erwerbsreihenfolge, während unabhängige Kontopaare weiterhin gleichzeitig bearbeitet werden können. Würde man immer zuerst das Zielkonto sperren, würden beide Überweisungen ihre Reihenfolge umkehren und entgegengesetzte Sperrreihenfolgen blieben möglich. Die Freigabe des Quellkontos vor dem Erwerb des Zielkontos würde außerdem die atomare Grenze der Überweisung aufheben.

Frage 6 von 10

Eine CAS-Schleife liest einen Zähler mit dem Wert 12 und bereitet 13 vor. Ein anderer Thread ändert den Zähler vor dem CAS auf 13. Wie erhält der erste Thread seine Erhöhung?
  1. Er wiederholt denselben Versuch mit erwartetem Wert 12, bis er gelingt.
  2. Er versucht es mit dem neuen Zählerwert erneut und will 14 speichern.
  3. Er speichert die vorbereitete 13, ohne den Zähler erneut zu prüfen.
  4. Er behandelt den fehlgeschlagenen Versuch als abgeschlossene Erhöhung.
Antwort zeigenAntwort ausblenden
Antwort: B
Der erwartete Wert 12 ist veraltet, daher muss das CAS fehlschlagen, ohne 13 zu überschreiben. Ein erneuter Versuch mit aktuellen Daten lässt den ersten Thread seine eigene Erhöhung ausführen und 14 erreichen. Ein gewöhnlicher Schreibzugriff mit der vorbereiteten 13 würde den Unterschied zwischen der Aktualisierung des anderen Threads und der noch nicht ausgeführten Aktualisierung dieses Threads beseitigen.

Frage 7 von 10

Eine Ticketsperre hat turn == 6 und das nächste ungenutzte Ticket ist ticket == 9. Ein neuer Aufrufer zieht ein Ticket. Auf welche Nummer wartet er?
  1. 9
  2. 6
  3. 8
  4. 10
Antwort zeigenAntwort ausblenden
Antwort: A
Fetch-and-Add gibt den alten Wert des Ticketgebers zurück, sodass der Aufrufer 9 erhält und das nächste ungenutzte Ticket 10 wird. Er wartet auf den Bedienungszähler 9, hinter früheren ausstehenden Tickets. Der aktuelle Bedienungszähler 6 beschreibt die Bedienung, nicht die Nummer, die ein neu ankommender Thread erhält.

Frage 8 von 10

Arbeitsthreads kopieren ein Registry-Struct, das einen Mutex und einen Zeiger auf gemeinsame Datensätze enthält. Sie sperren ihre Kopien, kehren bei fehlgeschlagener Prüfung vorzeitig zurück und lesen den Status mit getrennten lokalen Mutexen. Welche Korrektur stellt eine einheitliche Schutzregel her und gibt den Besitz bei einem Fehler frei?
  1. Nutze die ursprüngliche Registry per Zeiger, schütze Schreibzugriffe mit ihrem Mutex und lass lesende Threads private lokale Sperren behalten.
  2. Initialisiere jeden kopierten Mutex, schütze Lese- und Schreibzugriffe mit dieser Kopie und leite Fehler über deren Freigabe.
  3. Nutze die ursprüngliche Registry per Zeiger, schütze Lese- und Schreibzugriffe mit ihrem Mutex und leite Fehler über die Freigabe.
  4. Nutze die ursprüngliche Registry per Zeiger, schütze Lese- und Schreibzugriffe mit ihrem Mutex und kehre bei Fehlern direkt zurück.
Antwort zeigenAntwort ausblenden
Antwort: C
Die gemeinsamen Datensätze brauchen eine ursprüngliche Sperre, die lesende und schreibende Threads nutzen, und jeder Pfad nach einem Erwerb muss sie freigeben. Auch ein privater initialisierter Mutex stimmt sich nicht mit anderen Arbeitsthreads ab; ein kopierter pthread-Mutex ist ungültig. Wird nur die Identität korrigiert, bleibt bei den übrigen Entwürfen entweder die Schutzlücke beim Lesen oder die fehlende Freigabe bei vorzeitiger Rückkehr.

Frage 9 von 10

Zwei Threads führen Petersons Protokoll aus. Ihre Schreibzugriffe auf die eigenen Flags bleiben gepuffert, sodass beide später beim Lesen der Flags den anderen als nicht interessiert sehen. Nur turn ist atomar; die Flags sind gewöhnliche C-Ganzzahlen. Welche Schlussfolgerung und Korrektur ergeben sich?
  1. Nur einer kann eintreten; ein atomarer Bedienungszähler allein erhält das gesamte Protokoll.
  2. Beide können eintreten; nutze passend geordnete atomare Zugriffe für Flags und Bedienungszähler.
  3. Beide können eintreten; nur die gewöhnlichen Flags als volatile zu markieren, korrigiert die Reihenfolge.
  4. Nur einer kann eintreten; getrennte Cachezeilen beseitigen Datenwettläufe auf gewöhnlichen Flags.
Antwort zeigenAntwort ausblenden
Antwort: B
Jede Beobachtung eines zurückgesetzten Flags lässt den jeweiligen Thread das Warten beenden, sodass der Ausschluss scheitern kann; gewöhnliche gemeinsame Flags führen außerdem zu Datenwettläufen in C. Geeignet geordnete atomare Operationen für alle Protokollfelder, etwa sequenziell konsistente Operationen, beheben beide Probleme. Ein atomarer Bedienungszähler allein kann nicht synchronisierte und umgeordnete Flagzugriffe nicht korrigieren.

Frage 10 von 10

Jede Aktualisierung eines Paares gemeinsamer Felder nutzt den Mutex m, aber eine Diagnosefunktion liest beide Felder ohne m. Was muss die Diagnosefunktion tun, um ein konsistentes geschütztes Paar zu erhalten?
  1. Ihre lokalen Kopien als volatile deklarieren.
  2. Für die Lesezugriffe einen privaten Mutex halten.
  3. Beim Lesen beider Felder m halten.
  4. Das erste Feld zweimal ungesperrt lesen.
Antwort zeigenAntwort ausblenden
Antwort: C
Die Diagnosefunktion muss dieselbe Schutzregel wie die aktualisierende Funktion befolgen und m während beider Lesezugriffe halten. Ein privater Mutex schließt keine aktualisierende Funktion aus und kann das Paar daher nicht konsistent machen. Das Lesen ohne gemeinsame Sperre kann bei gewöhnlichen Feldern außerdem einen Datenwettlauf erzeugen, obwohl die Diagnosefunktion selbst keines der Felder schreibt.
Set 2: Threadlebensdauern und gemeinsame Aktualisierungen10 Übungsfragen

Speichere diese Einheit, um jede Antwort zu sehen und jedes Set zu üben.

Frage 1 von 10

Zwei Threads gehören zum selben Prozess. Welchen Ausführungszustand hält jeder getrennt?
  1. Seinen ausführbaren Code und Heap
  2. Seinen Befehlszähler und seine Register
  3. Seine offenen Dateien und Prozesskennung
  4. Seine globalen Variablen und Seitentabellen
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 2 von 10

Ein Server mit einem Kern wechselt zwischen zwei Anfragethreads, während einer auf eine Netzwerkantwort wartet. Wie lässt sich ihre Ausführung beschreiben?
  1. Parallel, ohne nebenläufigen Fortschritt
  2. Nebenläufig, mit paralleler Ausführung
  3. Sequenziell, ohne überlappenden Fortschritt
  4. Nebenläufig, ohne parallele Ausführung
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 3 von 10

Ein Arbeitsthread gibt einen Zeiger auf ein zugewiesenes Ergebnis zurück. Welcher Aufruf wartet auf Thread t und legt diesen Zeiger in void *result ab?
  1. pthread_join(t, &result)
  2. pthread_join(t, result)
  3. pthread_create(&t, NULL, result, NULL)
  4. pthread_detach(t)
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 4 von 10

In einem Modell aus Laden, Addieren und Speichern beginnt ein Zähler bei 36. A und B laden ihn jeweils, addieren jeweils privat 1 und speichern dann in der Reihenfolge A und B. Wie lautet der Endstand des Zählers?
  1. 36
  2. 38
  3. 37
  4. 39
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 5 von 10

Ein atomarer Kontostand beginnt bei 90. Zwei Threads lesen ihn jeweils atomar, genehmigen eine Anfrage über 60 und führen dann jeweils eine atomare Subtraktion von 60 aus. Welcher Fehler bleibt möglich?
  1. Eine Zustimmung geht verloren, der Kontostand bleibt bei 30.
  2. Die atomaren Zugriffe bilden einen Datenwettlauf in C.
  3. Beide stimmen zu, der Kontostand fällt auf −30.
  4. Beide Subtraktionen verschmelzen zu einer Aktualisierung.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 6 von 10

Ein Aufruf von pthread_mutex_trylock(&m) gibt EBUSY zurück. Welchen Besitz hat der aufrufende Thread durch diesen Aufruf?
  1. Er hat keinen Besitz an m erworben.
  2. Er besitzt m bis zu seinem nächsten Blockieren.
  3. Er teilt m mit dem aktuellen Besitzer.
  4. Er besitzt m, sobald der Besitzer es freigibt.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 7 von 10

Eine Startfunktion startet zwei Arbeitsthreads, indem sie wiederholt &i, die Adresse ihres lokalen Schleifenindex, übergibt, und kehrt zurück, während sie laufen. Der Hauptthread möchte getrennte Ergebnisse von beiden. Welcher Ersatzentwurf macht die Übergabe von Argumenten und Ergebnissen sicher?
  1. Der Hauptthread hält ein veränderliches Aufgaben-Struct am Leben, startet beide Arbeitsthreads und wartet vor dem Lesen des jeweiligen Ergebnisses mit Join auf jeden.
  2. Die Startfunktion hält zwei lokale Aufgaben-Structs, startet beide Arbeitsthreads und kehrt zurück; der Hauptthread wartet später vor dem Lesen mit Join.
  3. Der Hauptthread hält zwei getrennte Aufgaben-Structs am Leben, startet beide Arbeitsthreads und liest die Ergebnisse, bevor er mit Join auf einen wartet.
  4. Der Hauptthread hält zwei getrennte Aufgaben-Structs am Leben, startet beide Arbeitsthreads und wartet vor dem Lesen des jeweiligen Ergebnisses mit Join auf jeden.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 8 von 10

Ein Programm verliert auf einem Kern gemeinsame Erhöhungen. Warum kann das Ergänzen von volatile beim Zähler das Problem möglicherweise nicht beheben?
  1. Der Qualifizierer volatile zwingt alle Threads, Register zu teilen.
  2. Laden und Speichern können weiterhin durch einen Threadwechsel getrennt werden.
  3. Der Zähler wird für den gerade laufenden Thread privat.
  4. Der Qualifizierer verschiebt Threadwechsel bis nach einem Laden.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 9 von 10

Ein Kapazitätswert beginnt bei 7. Anfragen über 3, 5 und 2 prüfen ihn jeweils unter einem Mutex, bevor eine Verringerung erfolgt, sodass alle zustimmen. Danach verringern sie den Wert unter diesem Mutex in derselben Reihenfolge. Welcher Endwert entsteht und welche Mutexgrenze korrigiert die Regel?
  1. Endwert 2; Besitzzeiten für Prüfung und Verringerung getrennt halten.
  2. Endwert −3; Besitz während jeder Prüfung und ihrer bedingten Verringerung halten.
  3. Endwert −3; Zeiten getrennt halten, aber jeden Zugriff zusätzlich atomar machen.
  4. Endwert −1; Besitz nur während jeder bereits genehmigten Verringerung halten.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 10 von 10

T1 besitzt einen Mutex, während er zwei zusammengehörige Felder ändert, und wird dann verdrängt. T2 ruft pthread_mutex_lock für denselben Mutex auf. Was geschieht mit dem geschützten Zugriff?
  1. T2 wartet auf den Erwerb; T1 kann fortsetzen und seine Aktualisierung beenden.
  2. T2 tritt ein, weil T1 nicht mehr auf dem Prozessor läuft.
  3. T2 übernimmt den Besitz und beendet die unvollständige Aktualisierung von T1.
  4. T1 verliert den Besitz und muss beide Feldänderungen neu beginnen.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Set 3: Warteprotokolle und gemeinsame Strukturen10 Übungsfragen

Speichere diese Einheit, um jede Antwort zu sehen und jedes Set zu üben.

Frage 1 von 10

Ein auf eine Sperre wartender Thread ist in einer Kernelwarteschlange blockiert. Wie viel CPU-Zeit verbraucht er, solange er blockiert bleibt?
  1. Eine volle Zeitscheibe in jedem Schedulerzyklus.
  2. Einen Kern durchgehend bis zur Freigabe durch den Besitzer.
  3. Kurzes aktives Warten bei jedem geschützten Speicherzugriff.
  4. Keine für die Ausführung dieses blockierten Threads.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 2 von 10

A hält Ticket 20; B und C halten 21 und 22. A gibt die Sperre frei, B wird nicht eingeplant und der laufende C wartet weiter. D zieht nun das nächste Ticket, ohne dass weitere Threads ankommen. Welches Ticket ist als Nächstes ungenutzt und welche Eintrittsreihenfolge muss folgen, sobald B laufen kann?
  1. Als Nächstes ungenutzt 23; B, dann C, dann D.
  2. Als Nächstes ungenutzt 24; C, dann D, dann B.
  3. Als Nächstes ungenutzt 24; B, dann C, dann D.
  4. Als Nächstes ungenutzt 22; B, dann D, dann C.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 3 von 10

Ein Wartender beschließt zu blockieren, pausiert und erhält vor dem Aufruf zum Blockieren ein Wecksignal. Das Wecksignal wird verworfen. Welches Risiko entsteht?
  1. Er kann blockieren, nachdem das einzige Wecksignal bereits vorbeigegangen ist.
  2. Er kann zwei Tickets für denselben Warteschlangeneintrag erwerben.
  3. Er muss bis zur nächsten Zeitscheibe durchgehend aktiv warten.
  4. Er wacht sofort auf, weil das Blockieren sich jedes Wecksignal merkt.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 4 von 10

Ein approximativer Zähler nutzt drei lokale Plätze und die Schwelle 4. Ausgehend von null betragen ihre Anzahlen an Aktualisierungen 6, 1 und 3. Wie lauten nach Abschluss aller Aktualisierungen der globale Zählerstand und die tatsächliche Summe?
  1. Global 8; tatsächliche Summe 10.
  2. Global 4; tatsächliche Summe 10.
  3. Global 6; tatsächliche Summe 10.
  4. Global 4; tatsächliche Summe 6.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 5 von 10

Welche Arbeit kann ohne den Listenmutex erledigt werden, bevor ein neu angelegter Listenknoten in eine gemeinsame Struktur eingehängt wird?
  1. Den gemeinsamen Anfang für eine spätere Verknüpfungsänderung lesen.
  2. Den gemeinsamen Anfang ändern, um den neuen Knoten zu veröffentlichen.
  3. Das Schlüsselfeld des privaten Knotens initialisieren.
  4. Gemeinsame Knoten durchlaufen, die beim Entfernen freigegeben werden können.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 6 von 10

Eine Warteschlange enthält den Dummyknoten R, gefolgt von Knoten mit 14 und 29. Welcher Knoten wird nach einer erfolgreichen Entnahme freigegeben und welcher wird zum Dummyknoten?
  1. Den Knoten mit 14 freigeben; R bleibt der Dummyknoten.
  2. R freigeben; der Knoten mit 14 wird zum Dummyknoten.
  3. R freigeben; der Knoten mit 29 wird zum Dummyknoten.
  4. Den Knoten mit 29 freigeben; R bleibt der Dummyknoten.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 7 von 10

Eine Hashtabelle fester Größe nutzt 17 Buckets, key % 17 und einen Mutex pro Bucket. Arbeitsthreads aktualisieren die Schlüssel 8, 25 und 26. Welches Paar muss serialisiert werden und welcher Schlüssel nutzt eine getrennte Sperre?
  1. 8 und 25 werden serialisiert; 26 nutzt eine getrennte Sperre.
  2. 8 und 26 werden serialisiert; 25 nutzt eine getrennte Sperre.
  3. 25 und 26 werden serialisiert; 8 nutzt eine getrennte Sperre.
  4. Alle drei werden serialisiert; jeder nutzt dieselbe Bucket-Sperre.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 8 von 10

Ein approximativer Zähler hat vier lokale Plätze und Erhöhungen um jeweils eins. Sein Rückstand nach abgeschlossenen Aktualisierungen darf nie größer als 20 sein. Wähle die größte ganzzahlige Schwelle, die diese Vorgabe erfüllt. Die Plätze führen dann ausgehend von null 17, 8, 5 und 0 Aktualisierungen aus. Welche Schwelle und welcher globale Lesewert ergeben sich?
  1. Schwelle 5; globaler Lesewert 25.
  2. Schwelle 7; globaler Lesewert 21.
  3. Schwelle 6; globaler Lesewert 30.
  4. Schwelle 6; globaler Lesewert 18.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 9 von 10

Ein Durchlauf mit Sperrkopplung hält die Sperre des aktuellen Knotens. In welcher Reihenfolge sollte er zum nächsten Knoten weitergehen?
  1. Die aktuelle Sperre freigeben, dann die nächste Sperre erwerben.
  2. Die aktuelle Sperre freigeben, dann den nächsten Zeiger kopieren.
  3. Alle späteren Knotensperren erwerben, bevor eine Sperre freigegeben wird.
  4. Die nächste Sperre erwerben, dann die aktuelle Sperre freigeben.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 10 von 10

Auf einer einzelnen CPU mit strengem Prioritätsscheduling hält L mit niedriger Priorität eine Sperre, H mit hoher Priorität wartet aktiv darauf und M mit mittlerer Priorität ist bereit. Zuerst wird die Sperre in einen Ticket-Spinlock geändert. Dann wird sie durch einen Mutex mit Prioritätsvererbung ersetzt, an dem H blockiert. Welchen Fortschritt erlaubt die zweite Änderung, den die Ticketreihenfolge allein nicht erlaubt?
  1. L läuft vor M, gibt die Sperre frei und lässt H fortfahren; bei bloßer Ticketreihenfolge hindert H weiterhin L am Laufen.
  2. H tritt vor L ein, gibt die Sperre frei und lässt M fortfahren; bei bloßer Ticketreihenfolge muss M zuerst freigeben.
  3. M läuft vor L, gibt den Mutex von H frei und lässt H fortfahren; Ticketreihenfolge allein blockiert alle Prioritäten gleich.
  4. L bleibt unter M, bis M fertig ist; Ticketreihenfolge allein würde L stattdessen auf die Priorität von H anheben.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Set 4: Prüfe nebenläufige Entwürfe über Grenzen hinweg10 Übungsfragen

Speichere diese Einheit, um jede Antwort zu sehen und jedes Set zu üben.

Frage 1 von 10

Der Rückgabewert eines Arbeitsthreads, auf den mit Join gewartet werden kann, wird nie eingesammelt. Seine Threadressourcen sollen beim Ende zurückgewonnen werden. Welche pthread-Operation veranlasst das?
  1. pthread_join aufrufen, ohne darauf zu warten.
  2. Den Arbeitsthread mit pthread_detach abkoppeln.
  3. Aus main zurückkehren, bevor der Arbeitsthread endet.
  4. Den pthread_t des Arbeitsthreads lokal kopieren.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 2 von 10

W beobachtet ein Futex-Sperrwort mit dem Wert 1. Der Besitzer setzt es auf 0 und weckt auf, bevor W die Warteoperation aufruft. Dann erwirbt C die Sperre und setzt das Wort auf 1. W ruft Futex-Wait mit dem erwarteten Wert 1 auf, und C setzt es später zurück und weckt auf. Kann W bei diesem Aufruf blockieren und verleiht das spätere Wecksignal den Besitz?
  1. W kann bei der von C gehaltenen Sperre blockieren; nach dem Aufwecken muss W den Erwerb erneut versuchen.
  2. W kann nicht blockieren, weil ein früherer Besitzer bereits ein Wecksignal gesendet hat.
  3. W kann bei der von C gehaltenen Sperre blockieren; das spätere Wecksignal verleiht automatisch den Besitz.
  4. W muss für immer blockieren, weil sich sein früherer Lesezugriff auf einen anderen Besitzer bezog.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 3 von 10

Eine Warteschlange mit zwei Sperren ist beim Dummyknoten D leer. E bereitet Knoten R mit dem Wert 11 vor, veröffentlicht D->next = R und pausiert vor dem Verschieben des Endes. Q entnimmt dann erfolgreich; E verschiebt danach das Ende vollständig auf R. Welcher Zustand bleibt und wie müssen die Zugriffe auf die nächste Verknüpfung zwischen den Enden in C11 implementiert werden?
  1. Ein Element 11 bei R; die nächste Verknüpfung atomar und passend geordnet veröffentlichen und lesen.
  2. Leer bei Dummyknoten R; gewöhnliche Zugriffe auf die nächste Verknüpfung werden durch die getrennten Mutexe der Enden geordnet.
  3. Leer bei Dummyknoten D; volatile Zugriffe auf die nächste Verknüpfung bieten die nötige Veröffentlichungsordnung.
  4. Leer bei Dummyknoten R; die nächste Verknüpfung atomar und passend geordnet veröffentlichen und lesen.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 4 von 10

Jede Anfrage hält einen gemeinsamen Mutex für 5 µs. Welche obere Durchsatzschranke setzt diese geschützte Arbeit, wenn alle anderen Kosten unberücksichtigt bleiben?
  1. 5.000 Anfragen pro Sekunde.
  2. 1.000.000 Anfragen pro Sekunde.
  3. 200.000 Anfragen pro Sekunde.
  4. 5.000.000 Anfragen pro Sekunde.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 5 von 10

Ein Zähler hat einen lokalen Platz mit dem Wert 5 und einen globalen Wert von 40. Ein lesender Thread kopiert den globalen Wert unter dessen Sperre und gibt sie frei. Ein Arbeitsthread überträgt die lokalen 5 global und setzt den lokalen Wert zurück. Danach kopiert der lesende Thread den lokalen Wert unter dessen Sperre. Welche Summe berechnet er und wie kann er eine exakte Momentaufnahme erhalten?
  1. Er berechnet 40; für die Momentaufnahme alle lokalen Sperren und dann die globale Sperre halten.
  2. Er berechnet 45; den globalen Wert zweimal ohne lokale Sperren lesen.
  3. Er berechnet 50; während der gesamten Momentaufnahme nur den globalen Zähler sperren.
  4. Er berechnet 40; jedes Feld nur beim Kopieren kurz einzeln sperren.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 6 von 10

Eine Cacheaktualisierung hält einen normalen Mutex und ruft einen Callback auf, der denselben Cache abfragt. Würde der Callback hinter die Freigabe verschoben, wäre der erneute Eintritt vermieden, aber der Knoten, auf den sein direkter Zeiger verweist, könnte dann von einem anderen Arbeitsthread freigegeben werden. Welcher neue Entwurf erlaubt den Callback außerhalb der Sperre und hält seine Eingabe gültig?
  1. Den direkten Knotenzeiger behalten, vor dem Callback freigeben und den Callback beim Start denselben Mutex erwerben lassen.
  2. Die benötigten Felder unter dem Mutex in Speicher des Callbacks kopieren, freigeben und dann den Callback mit dieser Kopie aufrufen.
  3. Die Felder in Speicher des Callbacks kopieren, den Mutex halten und den Callback über denselben Mutex abfragen lassen.
  4. Den direkten Knotenzeiger behalten, freigeben und den Zeiger als volatile markieren, damit späteres Entfernen den Knoten nicht ungültig macht.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 7 von 10

Ein Store-Conditional schlägt nach einem Load-Linked fehl, obwohl der Code keinen Hinweis auf einen anderen schreibenden Thread hat. Wie sollte die Aktualisierungsroutine reagieren?
  1. Das fehlgeschlagene Store-Conditional als erfolgreich behandeln.
  2. Den Wert mit einem gewöhnlichen unbedingten Schreibzugriff schreiben.
  3. Die LL/SC-Folge mit einem neuen Load-Linked wiederholen.
  4. Den alten Wert zurückgeben und die Aktualisierung als durchgeführt melden.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 8 von 10

Zwei Suchen finden einen Knoten. A kopiert unter dem Listenmutex seinen ganzzahligen Wert in Speicher des Aufrufers. B speichert einen Zeiger und gibt die Sperre frei. Ein entfernender Thread hängt dann den Knoten aus und gibt ihn frei; seine Adresse kann vor dem Lesen durch B wiederverwendet werden. Welches Ergebnis bleibt gültig und was muss die Schnittstelle von B bieten?
  1. Beide Ergebnisse bleiben gültig; B muss vor dem Lesen der gespeicherten Adresse nur die Liste erneut sperren.
  2. Nur der Zeiger von B bleibt gültig; A muss beim Lesen des kopierten Werts jeweils die Liste sperren.
  3. Der kopierte Wert von A bleibt gültig; die Markierung des gespeicherten Zeigers von B als volatile erhält den entfernten Knoten.
  4. Der kopierte Wert von A bleibt gültig; B braucht eine Wertkopie oder Schutz für die weitere Lebensdauer des Knotens.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 9 von 10

Das nächste ungenutzte Ticket einer Ticketsperre ist 15 und ihr aktueller Bedienungszähler ist 12. Drei Aufrufer ziehen vor einer Freigabe Tickets. Wie lauten ihre Tickets und der neue Wert des Ticketgebers?
  1. Tickets 15, 16, 17; als Nächstes ungenutzt 18.
  2. Tickets 12, 13, 14; als Nächstes ungenutzt 15.
  3. Tickets 16, 17, 18; als Nächstes ungenutzt 18.
  4. Tickets 15, 15, 15; als Nächstes ungenutzt 18.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Frage 10 von 10

Eine Tabelle wächst von 11 auf 17 Buckets und verwendet Schlüssel modulo Bucket-Anzahl. Ein Arbeitsthread hat den alten Bucket für Schlüssel 24 ausgewählt. Die Größenänderung sperrt nur den neuen Ziel-Bucket, verschiebt den Eintrag und gibt das alte Bucket-Array frei. Welches Ziel und welche Übergangsregel erhalten diesen einfachen Entwurf?
  1. Bucket 2; die alte Zuordnung beibehalten und zugleich die neue Bucket-Anzahl für gewöhnliche Operationen veröffentlichen.
  2. Bucket 7; nur das neue Ziel schützen, weil der bisherige Arbeitsthread seinen Index bereits berechnet hat.
  3. Bucket 7; gewöhnliche Tabellenoperationen ausschließen und bisherige Nutzer abschließen lassen, bevor der alte Speicher freigegeben wird.
  4. Bucket 2; nur die alte Quelle schützen und nach dem Verschieben des Eintrags alle alten Buckets freigeben.
Antwort zeigenAntwort ausblenden

Speichere diese Einheit, um diese Antwort zu sehen.

Hängst du bei deiner eigenen Aufgabe fest? Hausaufgabenhilfe

Schlüsselbegriffe

75 Schlüsselbegriffe, jeweils mit Definition

A

Abgekoppelter Thread
Ein abgekoppelter Thread gibt seine Threadressourcen beim Ende automatisch frei und kann nicht mit Join abgewartet werden, um seinen Rückgabewert einzusammeln.
Ein Hintergrund-Arbeitsthread kann abgekoppelt sein, aber die Rückkehr aus main beendet dennoch den Prozess, der ihn enthält.
Adressraum
Ein Adressraum ist die Menge der Speicheradressen, die einem Prozess zur Verfügung stehen; seine Threads teilen sich den darin enthaltenen Code und die Objekte.
Zwei Stacks von Arbeitsthreads liegen in verschiedenen Bereichen eines Adressraums, sodass ein Zeiger zwischen ihnen verweisen kann.
Aktives Warten
Aktives Warten prüft eine Bedingung wiederholt, bleibt dabei lauffähig und verbraucht CPU-Zeit, bis die Bedingung Fortschritt erlaubt.
Ein Wartender kann seine gesamte Zeitscheibe mit der Prüfung eines Flags verbringen, während der verdrängte Besitzer nicht laufen kann.
Ankunftsreihenfolge
Bei der Ankunftsreihenfolge werden Anfragen in der erfassten Reihenfolge ihres Eintreffens bedient, sodass später erfasste Ankünfte frühere nicht überholen.
Bei einer Ticketsperre wird die Ankunft erfasst, wenn der Aufrufer seine Ticketnummer atomar erhält.
Approximativer Zähler
Ein approximativer Zähler überträgt geschützte lokale Aktualisierungen gebündelt in einen geschützten globalen Zählerstand, dessen Lesewert hinter der exakten Summe zurückliegen kann.
Eine Statistikerfassung kann es verkraften, dass aktuelle Erhöhungen lokal bleiben, bis ihre Übertragungsschwelle erreicht ist.
Atomarität
Atomarität bedeutet, dass eine Operation relevanten Beobachtern unteilbar erscheint, sodass sie keinen unvollständigen Zwischenzustand beobachten können.
Ein gemeinsamer Mutex kann eine Aktualisierung zweier Felder für andere Nutzer desselben Mutex atomar machen.
Aufräumpfad
Ein Aufräumpfad ist eine gemeinsame Abschlussfolge, die erworbene Ressourcen freigibt, bevor eine Funktion bei Erfolg oder Fehler zurückkehrt.
Eine Suche speichert ihren Rückgabewert, verlässt die Schleife und erreicht eine gemeinsame Mutexfreigabe.

B

Bedienungszähler
Ein Bedienungszähler hält das Ticket fest, das aktuell in eine Ticketsperre eintreten darf, und wird bei der Freigabe durch dessen Besitzer einmal erhöht.
Das Ziehen eines neuen Tickets erhöht den Ticketgeber, während der aktuelle Bedienungszähler unverändert bleibt.
Beschränktes Warten
Beschränktes Warten begrenzt, wie viele konkurrierende Eintritte in einen kritischen Abschnitt einem Thread vorausgehen dürfen, nachdem er Eintritt verlangt hat.
Frühere ausstehende Tickets begrenzen die Anzahl der Eintritte, bevor ein Wartender an einer Ticketsperre an die Reihe kommt.
Blockierter Thread
Ein blockierter Thread kann erst wieder zur Ausführung ausgewählt werden, wenn ein Ereignis ihn lauffähig macht; während des Blockierens verbraucht er keine CPU-Zeit.
Ein blockierter Mutexwartender verlässt die Menge der lauffähigen Threads, bis ein Wecksignal ihm erlaubt, erneut zu konkurrieren.
Bucket-Sperre
Eine Bucket-Sperre schützt die Einträge eines Hash-Buckets und erlaubt Operationen auf verschiedenen Buckets unter verschiedenen Sperren.
Zwei Schlüssel mit demselben Bucket-Index konkurrieren trotz verschiedener Werte um dieselbe Sperre.

C

Compare-and-Swap
Compare-and-Swap ersetzt einen Wert atomar nur dann, wenn er einem erwarteten Wert entspricht, und meldet genügend Information, um einen Fehlschlag zu erkennen.
Ein Thread, der einen Zähler aktualisiert, versucht es erneut, wenn eine konkurrierende Erhöhung seinen erwarteten Wert veraltet macht.

D

Datenwettlauf
Ein Datenwettlauf besteht aus nicht synchronisierten, miteinander in Konflikt stehenden Zugriffen verschiedener Threads auf eine Speicherstelle, darunter einem Schreibzugriff und einem nicht atomaren Zugriff.
Ein Statuslesezugriff kann mit einem gewöhnlichen Schreibzugriff auf einen Zähler in einen Wettlauf geraten, obwohl die Statusfunktion nichts ändert.
Dummyknoten
Ein Dummyknoten verankert eine verkettete Warteschlange, ohne ein aktuelles Element darzustellen; sein nächster Knoten enthält, falls vorhanden, den ersten eingereihten Wert.
Eine Entnahme gibt den Wert des nächsten Knotens zurück, gibt den alten Dummyknoten frei und macht den Wertknoten zum neuen Dummyknoten.

E

Eins-zu-eins-Threadmodell
Das Eins-zu-eins-Threadmodell ordnet jedem Anwendungsthread einen vom Kernel eingeplanten Thread zu und erlaubt so parallele Ausführung und das Blockieren einzelner Threads.
Zwei gewöhnliche Linux-Threads können vom Scheduler des Betriebssystems auf getrennte Kerne verteilt werden.
Exakte Momentaufnahme
Eine exakte Momentaufnahme liest zusammengehörige gemeinsame Werte konsistent in einem geschützten Zustand und verhindert, dass Übertragungen die Beobachtung aufspalten.
Eine Momentaufnahme eines approximativen Zählers hält die lokalen Sperren und die globale Sperre, während alle Zählerstände addiert werden.

F

Fehlerprüfender Mutex
Ein fehlerprüfender Mutex meldet festgelegte Besitzfehler, darunter erneutes Sperren durch den Besitzer und Freigabe durch einen Thread, der ihn nicht besitzt.
Während der Entwicklung kann ein erneuter Sperrversuch EDEADLK zurückgeben, statt denselben Thread unbemerkt zu blockieren.
Feingranulare Sperrung
Feingranulare Sperrung gibt kleineren Bereichen des gemeinsamen Zustands getrennte Sperren und lässt unabhängige Operationen nebenläufig fortschreiten.
Getrennte Kontomutexe erlauben, zwei unabhängige Kontopaare gleichzeitig zu aktualisieren.
Fetch-and-Add
Fetch-and-Add addiert atomar einen Betrag zu einem gespeicherten Wert und gibt den Wert zurück, der vor der Addition vorhanden war.
Ein Ticketgeber gibt einem Aufrufer 12 zurück und erhöht zugleich seine nächste verfügbare Nummer auf 13.
Fortschritt
Fortschritt verlangt, dass konkurrierende Threads für einen freien kritischen Abschnitt einen eintretenden Thread auswählen können, ohne durch nicht konkurrierende Threads unbegrenzt behindert zu werden.
Ein Thread, der nicht einzutreten versucht, darf einen ansonsten verfügbaren Abschnitt nicht für immer geschlossen halten.
Freiwillige Prozessorabgabe
Eine freiwillige Prozessorabgabe gibt den aktuellen CPU-Zug des Aufrufers auf, während dieser bereit bleibt, erneut zu laufen, wenn der Scheduler ihn auswählt.
Viele auf eine Sperre wartende Threads, die freiwillig abgeben, können wiederholte Kontextwechsel verursachen, bevor der Besitzer erneut an die Reihe kommt.
Futex
Ein Futex ist ein Wort im Benutzerspeicher mit Unterstützung des Linux-Kernels, um einen erwarteten Wert atomar zu prüfen und zu blockieren sowie Wartende aufzuwecken.
Ein nicht umkämpfter Mutex kann atomare Operationen im Benutzerraum nutzen und den Kernel nur für das Warten bei Ressourcenkonkurrenz aufrufen.

G

Grobgranulare Sperrung
Grobgranulare Sperrung schützt eine große Struktur oder ein Subsystem mit einer Sperre. Das vereinfacht die korrekte Umsetzung, begrenzt aber unabhängige Zugriffe.
Ein einzelner Listenmutex lässt jede Suche hinter einer Einfügung warten, selbst wenn ihr Ziel weit entfernt liegt.
Größenänderung der Hashtabelle
Die Größenänderung der Hashtabelle ändert deren Bucket-Anordnung und ordnet Einträge neu zu. Sie braucht Koordination über die gewöhnliche Sperre eines einzelnen Buckets hinaus.
Eine einfache Hashtabelle mit Bucket-Sperren schließt nebenläufige Tabellenoperationen aus, während sie Einträge in neue Buckets verschiebt.

H

Hash-Bucket
Ein Hash-Bucket ist ein durch die Hashzuordnung eines Schlüssels ausgewählter Tabellenbereich, der Einträge mit derselben zugeordneten Stelle enthält.
Modulo 101 ordnet die Schlüssel 7 und 108 demselben Bucket zu, obwohl sich ihre Zahlenwerte unterscheiden.
Hashkollision
Eine Hashkollision entsteht, wenn verschiedene Schlüssel demselben Tabellen-Bucket zugeordnet werden und sich dadurch einen Suchbereich und dessen Sperre teilen.
Bei 13 Buckets kollidieren die Schlüssel 2 und 15, weil beide bei Division durch 13 den Rest 2 haben.

J

Join
Ein Join wartet auf das Ende eines Threads, auf den mit Join gewartet werden kann, und macht dessen abgeschlossene Schreibzugriffe für den erfolgreich wartenden Thread sichtbar.
Der Hauptthread wartet mit Join auf zwei Summen-Arbeitsthreads, bevor er ihre getrennten Ergebnisfelder liest und zusammenführt.

K

Kernlokaler Zähler
Ein kernlokaler Zähler erfasst Aktualisierungen in einem Platz, der einem CPU-Kern zugeordnet ist, und verringert so Schreibzugriffe auf eine global gemeinsame Stelle.
Threads mit verschiedenen lokalen Plätzen können unter verschiedenen Mutexen aktualisieren, bis eine gebündelte Übertragung nötig ist.
Knotenveröffentlichung
Knotenveröffentlichung macht einen vorbereiteten privaten Knoten über gemeinsame Verknüpfungen erreichbar, sodass andere Operationen seine initialisierten Daten sicher finden können.
Das Einfügen am Anfang trägt zuerst den Schlüssel ein und veröffentlicht den Listenanfang nur bei gehaltenem gemeinsamem Listenmutex.
Kritischer Abschnitt
Ein kritischer Abschnitt ist ein Codebereich für gemeinsamen Zustand, der konkurrierende Operationen ausschließen muss, um die Korrektheit der Operation zu erhalten.
Der kritische Abschnitt einer Abhebung beginnt vor der Prüfung des Kontostands und umfasst die bedingte Subtraktion.

L

Load-Linked/Store-Conditional
Load-Linked/Store-Conditional verbindet einen reservierungssetzenden Lesezugriff mit einem Schreibversuch, der scheitern kann und bei Misserfolg wiederholt werden muss.
Eine ungültig gewordene Reservierung lässt Store-Conditional scheitern, statt eine konkurrierende Änderung zu überschreiben.

M

Mutex
Ein Mutex ist eine Sperre für wechselseitigen Ausschluss, die jeweils einem Thread gehört und eine gemeinsame Operation oder einen gemeinsamen Datenbereich schützt.
Alle Kontooperationen erwerben denselben Kontomutex, bevor sie das geschützte Kontostandsfeld nutzen.
Mutex-Identität
Die Mutex-Identität ist an das ursprüngliche Mutexobjekt an seiner initialisierten Adresse gebunden; ein kopierter pthread-Mutex ist keine gültige Referenz auf dasselbe Objekt.
Übergib eine gemeinsame Registry per Zeiger, statt ihr Struct zu kopieren und den kopierten Mutex zu verwenden.

N

Nebenläufigkeit
Nebenläufigkeit ist der Fortschritt mehrerer Ausführungen in überlappenden Zeiträumen, einschließlich verzahnter Ausführung auf einem einzelnen CPU-Kern.
Ein Timer kann zwischen nebenläufigen Arbeitsthreads wechseln, selbst wenn der Rechner in einem Moment nur einen ausführen kann.

O

Objektlebensdauer
Die Objektlebensdauer ist der Zeitraum, in dem ein gespeichertes Objekt existiert und über eine ansonsten gültige Referenz auf es zugegriffen werden darf.
Die Rückgabe eines Zeigers auf eine lokale Variable eines Arbeitsthreads scheitert, weil dieses Objekt mit der Funktion endet.

P

Parallelität
Parallelität ist die gleichzeitige Ausführung auf verschiedenen Verarbeitungsressourcen, etwa wenn mehrere Threads auf unterschiedlichen CPU-Kernen laufen.
Unabhängige Teile eines Bildes können parallel verarbeitet werden, wenn mehrere Kerne verfügbar sind.
Peterson-Algorithmus
Der Peterson-Algorithmus ist ein Ausschlussprotokoll für zwei Threads mit Interessenflags und einer Variable für den Vorrang; unter sequenzieller Konsistenz ist es korrekt.
Wenn beide Threads Eintritt verlangen, entscheidet die Vorrangvariable, welcher der interessierten konkurrierenden Threads wartet.
POSIX-Threads
POSIX-Threads sind die standardisierte Threadschnittstelle, mit der C-Programme auf Unix-artigen Systemen Threads erzeugen und koordinieren.
Ein C-Programm mit Arbeitsthreads bindet <pthread.h> ein und wird mit der Option -pthread kompiliert und gelinkt.
Prioritätsinversion
Prioritätsinversion entsteht, wenn höher priorisierte Arbeit auf einen Besitzer mit niedrigerer Priorität wartet, dessen Fortschritt durch andere Schedulingaktivität verzögert wird.
Arbeit mit mittlerer Priorität kann einen Mutexbesitzer mit niedriger Priorität daran hindern, für einen Wartenden mit hoher Priorität freizugeben.
Prioritätsvererbung
Prioritätsvererbung hebt einen Sperrbesitzer vorübergehend auf die höhere Priorität eines wartenden Threads an, damit der Besitzer laufen und früher freigeben kann.
Das Anheben des Besitzers verhindert, dass Arbeit mit mittlerer Priorität die benötigte Freigabe unbegrenzt verzögert.

R

Read-Modify-Write
Eine Read-Modify-Write-Operation liest einen gespeicherten Wert, berechnet einen geänderten Wert und schreibt das Ergebnis an dieselbe gemeinsame Stelle zurück.
Bei einer gewöhnlichen Zählererhöhung liegt eine Lücke zwischen dem anfänglichen Laden und dem abschließenden Speichern.
Reentranter Aufruf
Ein reentranter Aufruf tritt erneut in eine Operation ein, bevor deren früherer Aufruf abgeschlossen ist, und kann dabei versuchen, eine gehaltene Sperre erneut zu erwerben.
Ein Protokollierungs-Callback kann denselben Cache abfragen, während die äußere Aktualisierung noch den Cachemutex besitzt.
Rekursiver Mutex
Ein rekursiver Mutex erlaubt seinem Besitzer wiederholten Erwerb und wird erst nach der entsprechenden Anzahl von Freigaben verfügbar.
Zwei erfolgreiche Erwerbungen durch einen Thread brauchen zwei Freigaben, bevor ein anderer Thread den Mutex erwerben kann.
Ressourcenkonkurrenz
Ressourcenkonkurrenz entsteht, wenn Threads um eine gemeinsame Ressource konkurrieren, etwa indem sie versuchen, einen von einem anderen Thread gehaltenen Mutex zu erwerben.
Zusätzliche Arbeitsthreads an einem gesperrten Zähler können statt des Durchsatzes das Warten und den Verkehr durch gemeinsam genutzte Cachezeilen erhöhen.

S

Schutzregel
Eine Schutzregel ordnet gemeinsamen Zustand einer bestimmten Sperre zu und legt fest, welche Zugriffe dieselbe Sperre erwerben müssen.
Ein Kommentar neben einem Zähler kann den Mutex nennen, den jeder nebenläufige Lesezugriff und jede Aktualisierung erwerben muss.
Sequenzielle Konsistenz
Sequenzielle Konsistenz gibt Operationen eine gemeinsame, von allen Threads beobachtete Reihenfolge, die die Programmreihenfolge jedes Threads erhält.
Petersons Beweis setzt voraus, dass Flags und Vorrangvariable dieser Ordnung folgen, statt in umgeordneter Reihenfolge sichtbar zu werden.
Speicherleck
Ein Speicherleck ist zugewiesener Speicher, der nicht mehr benötigt wird, aber nicht zurückgewonnen werden kann, weil das Programm seine nutzbare Referenz verloren hat.
Zwei miteinander konkurrierende Einfügungen am Anfang können den Anfang überschreiben und den ersten neuen Knoten unerreichbar machen.
Speicherordnung
Speicherordnung legt Beschränkungen dafür fest, wie Speicheroperationen sichtbar werden, einschließlich der Operationen um Sperrübergaben herum.
Die Freigabe einer Sperre muss die geschützten Schreibzugriffe vor der Veröffentlichung des wieder verfügbaren Zustands halten.
Speicherrückgewinnung
Speicherrückgewinnung gibt zugewiesenen Speicher erst frei, nachdem keine Operation mehr auf das darin liegende Objekt zugreifen kann.
Das Aushängen eines Knotens reicht nicht aus, wenn ein Aufrufer seinen Zeiger behalten hat und ihn später dereferenzieren könnte.
Sperrbesitz
Sperrbesitz ist das ausschließliche Recht, das durch erfolgreichen Erwerb entsteht und bestehen bleibt, bis der besitzende Thread den Mutex freigibt.
Ein Kontextwechsel pausiert den Besitzer, ohne einem wartenden Thread die Erlaubnis zum Eintritt zu geben.
Sperrfreie atomare Operation
Eine sperrfreie atomare Operation erzielt ihre atomare Wirkung, ohne davon abzuhängen, dass ein anderer Thread eine interne Sperre freigibt.
Andere atomare Objekte können eine Bibliothekssperre nutzen, sodass atomare Korrektheit allein keine Sperrfreiheit belegt.
Sperrgranularität
Die Sperrgranularität ist der Umfang des gemeinsamen Zustands, den eine Sperre schützt; sie bestimmt, welche Operationen miteinander serialisiert werden müssen.
Der Wechsel von einem Tabellenmutex zu Mutexen pro Bucket lässt Operationen in verschiedenen Buckets überlappen.
Sperrkopplung
Sperrkopplung erwirbt die Sperre des nächsten Knotens, bevor sie die des aktuellen freigibt, und erhält so den Schutz während des Durchlaufs.
Verschiedene Durchläufe können getrennte Listenbereiche belegen, obwohl jeder besuchte Knoten Sperraufwand verursacht.
Spinlock
Ein Spinlock ist eine Sperre für wechselseitigen Ausschluss, deren erfolglose Aufrufer wiederholt prüfen oder den Erwerb versuchen und dabei CPU-Zeit verbrauchen.
Eine kurze Sperrdauer auf einem anderen laufenden Kern kann aktives Warten günstiger machen als Blockieren und Aufwecken.

T

Test-and-Set
Test-and-Set speichert atomar einen neuen Wert und gibt den alten zurück, sodass ein Aufrufer ein freies Flag ohne Lücke beanspruchen kann.
Ein Austausch, der 0 zurückerhält, besitzt das Flag; konkurrierende Threads, die 1 erhalten, warten weiter auf die Freigabe.
Thread
Ein Thread ist ein Ausführungsstrang innerhalb eines Prozesses mit eigenem Befehlszähler, eigenen Registern, eigenem Stack und eigener Threadkennung.
Ein Server kann einen Thread zum Warten auf eine Antwort nutzen, während ein anderer Thread eine abgeschlossene Anfrage bearbeitet.
Threadargument
Ein Threadargument sind Daten, die einem neuen Arbeitsthread über den Zeigerparameter seiner Startfunktion übergeben werden; der Zeiger verweist oft auf ein Struct.
Ein Array gibt jedem Arbeitsthread ein eigenes Argumentobjekt, das bis zum Abschluss der Join-Aufrufe gültig bleibt.
Threaderzeugung
Threaderzeugung startet einen neuen Ausführungsstrang bei einer angegebenen Funktion, mit einem Argumentzeiger und konfigurierbaren Threadattributen.
Der Hauptthread bereitet ein Bereichs-Struct vor, bevor er dessen Adresse an einen erfolgreich erzeugten Summen-Arbeitsthread übergibt.
Threadkontrollblock
Ein Threadkontrollblock ist ein Datensatz des Betriebssystems, der den gespeicherten Ausführungszustand eines Threads für dessen Einplanung und Fortsetzung enthält.
Bei einem Wechsel zwischen zwei Threads wird der Registerzustand des ersten Threads in seinem Threadkontrollblock gespeichert, bevor der Zustand des zweiten wiederhergestellt wird.
ThreadSanitizer
ThreadSanitizer ist ein Laufzeitanalysewerkzeug, das Datenwettläufe in instrumentierten Ausführungen erkennt und die miteinander in Konflikt stehenden Zugriffe meldet.
Ein mit -fsanitize=thread erstelltes Testprogramm kann einen Lesepfad aufdecken, der den Mutex der aktualisierenden Funktion ausgelassen hat.
Threadsicherheit
Threadsicherheit bedeutet, dass sich eine Struktur gemäß ihrer Spezifikation verhält, wenn Operationen mehrerer Threads verzahnt werden.
Ein Zähler braucht eine Schutzregel für Lesezugriffe ebenso wie für Erhöhungen, wenn diese Operationen überlappen können.
Ticketsperre
Eine Ticketsperre gibt jedem anfragenden Thread ein eindeutiges atomares Ticket und lässt ihn eintreten, wenn der Bedienungszähler dieses Ticket erreicht.
Ein Aufrufer mit Ticket 6 wartet, solange der Bedienungszähler 5 ist, selbst wenn ein Aufrufer mit späterem Ticket bereits läuft.
Trylock
Ein Trylock ist ein sofortiger Erwerbsversuch, der meldet, ob der Aufrufer den Mutex erhalten hat, statt bei belegtem Mutex zu warten.
Eine EBUSY-Rückgabe lässt einen Arbeitsthread unabhängige Arbeit wählen, erlaubt aber keinen Zugriff auf geschützte Felder.

Ü

Übertragungsschwelle
Eine Übertragungsschwelle ist der lokale Zählerstand, bei dem ein approximativer Zähler seine angesammelten Erhöhungen in den globalen Zählerstand überträgt.
Eine höhere Schwelle verringert globale Sperrerwerbungen, lässt aber mehr Erhöhungen ungemeldet bleiben.

V

Verhungern
Verhungern ist die unbegrenzte Verzögerung eines Threads, während konkurrierende Threads weiter Fortschritte machen und die von ihm benötigte Ressource wiederholt erhalten.
Ein Test-and-Set-Wartender kann nach jeder Freigabe den Wettlauf verlieren, weil die Sperre keine Ankunftsreihenfolge speichert.
Verlorenes Update
Ein verlorenes Update entsteht, wenn ein Thread ein auf einem alten Lesewert beruhendes Ergebnis speichert und die Wirkung einer konkurrierenden Aktualisierung überschreibt.
Zwei Arbeitsthreads können jeweils 50 aus einem gemeinsamen Zähler laden und 51 speichern, sodass nach zwei Versuchen nur eine Erhöhung sichtbar bleibt.
Verlorenes Wecksignal
Ein verlorenes Wecksignal entsteht, wenn eine benötigte Benachrichtigung ein Warten nicht verhindert oder beendet, sodass ein Thread fälschlich blockiert bleiben kann.
Ein einfacher Wartender kann blockieren, nachdem ein früherer Weckaufruf verworfen wurde, ohne dass eine weitere Freigabe ihn noch aufweckt.
Verzahnung
Eine Verzahnung ist eine Reihenfolge, in der Schritte verschiedener Threads unter Beibehaltung der relevanten Ausführungsreihenfolge innerhalb jedes Threads stattfinden.
Wird die vollständige Aktualisierung eines Threads zwischen das Laden und Speichern eines anderen gesetzt, zeigt sich, wie ein veraltetes Ergebnis sie überschreiben kann.
Viele-zu-eins-Threadmodell
Das Viele-zu-eins-Threadmodell ordnet mehrere Benutzerthreads einem Kernelthread zu und verhindert ihre gleichzeitige Ausführung auf verschiedenen Kernen.
Ein blockierender Systemaufruf kann jeden Benutzerthread anhalten, der diesem einen Kernel-Ausführungsstrang zugeordnet ist.
volatile
Der C-Qualifizierer volatile kennzeichnet Zugriffe, für die besondere Regeln zur Beobachtbarkeit gelten; er bietet weder Atomarität noch Threadsynchronisierung.
Einen gemeinsamen Zähler als volatile zu kennzeichnen, verbindet Laden, Addieren und Speichern nicht zu einer Aktualisierung.

W

Warteschlange mit zwei Sperren
Eine Warteschlange mit zwei Sperren serialisiert Einreihungen mit einer Sperre für das Ende und Entnahmen mit einer Sperre für den Anfang, sodass Operationen an den gegenüberliegenden Enden überlappen können.
In einer C11-Warteschlange mit zwei Sperren muss die nächste Verknüpfung des Dummyknotens bei leerer Warteschlange weiterhin atomar veröffentlicht und gelesen werden.
Wechselseitiger Ausschluss
Wechselseitiger Ausschluss ist die Eigenschaft, dass sich höchstens ein konkurrierender Thread gleichzeitig in einem geschützten kritischen Abschnitt befindet.
Ein zweiter Thread kann den Erwerb des gemeinsamen Mutex nicht abschließen, während ein erster ihn besitzt, selbst wenn dieser Besitzer vorübergehend verdrängt wird.
Wettlaufsituation
Eine Wettlaufsituation liegt vor, wenn die Korrektheit einer Operation vom relativen zeitlichen Ablauf nebenläufiger Handlungen abhängt.
Zwei getrennt geschützte Zustimmungen können beide den alten Kontostand nutzen, bevor eine der Abhebungen ausgeführt wird.

Z

Zählerüberlauf
Ein Zählerüberlauf tritt auf, wenn vorzeichenlose Arithmetik den größten darstellbaren Wert überschreitet und bei null fortgesetzt wird.
Der Vergleich von Tickets auf Gleichheit bleibt über den Überlauf hinweg nur nutzbar, solange ausstehende Ticketnummern eindeutig bleiben.
Zweiphasige Sperre
Eine zweiphasige Sperre wartet zunächst kurz aktiv und blockiert dann, falls der Besitzer während dieses kurzen Intervalls nicht freigegeben hat.
Das kurze aktive Warten kann bei kurzen Sperrzeiten den Aufwand für das Aufwecken vermeiden, ohne bei langen Sperrzeiten einen Kern zu verbrauchen.

Über diese Einheit

Von StudeliFehler melden

Erstelle deine eigenen Lernmaterialien

Mach aus deinen Notizen, Folien und Dokumenten Lernleitfäden, Übungsfragen, Karteikarten und 6 weitere Tools.

Jetzt starten