Scheinwecken — Warum Bedingungsvariablen „ohne Benachrichtigung“ aufwachen und wie Sie unter Windows richtig warten

· · Windows, Multithreading, Bedingungsvariablen, Synchronisierung, C++, C#, Win32-API, Fehleruntersuchung

„Wir legen Daten in die Warteschlange und wecken den wartenden Worker-Thread. Es war sechs Monate gelaufen, dann hat es eines Tages versucht, eine leere Warteschlange zu lesen, und ist abgestürzt.“ „Wir senden die Benachrichtigung, aber ab und zu wacht ein Thread nie auf.“ — Multithread-Rendezvous sieht so aus, als funktionierte es, und ist ein Brutplatz für Fehler, die nur selten erscheinen. Untersuchungen dieser Art landen oft auf Code, der das wait einer Bedingungsvariable in ein if wickelt. Und dahinter sitzt das Scheinwecken (spurious wakeup) — das Phänomen, von wait zurückzukehren, ohne eine Benachrichtigung empfangen zu haben.

„Es wacht auf, obwohl niemand es benachrichtigt hat“ klingt nach einem Defekt der Umsetzung, aber es ist Verhalten, das Win32, C++ und POSIX alle in ihrer Dokumentation oder ihren Standards ausschreiben, und .NETs Monitor ist unter der Annahme entworfen, dass „einmal aufgeweckt, prüfen Sie die Bedingung erneut“. Warum ist dieses Verhalten erlaubt? In welcher Schicht geschieht es unter Windows? Und wie schreiben Sie das Warten, sodass Sie es nie treffen? An Entwickler gerichtet, die Geschäftsanwendungen und Gerätesteuerungssoftware unter Windows schreiben, packt dieser Artikel aus, was ein Scheinwecken wirklich ist, anhand von Primärquellen, und kocht das richtige Warten auf Win32 (C), C++ und C# herunter.

1. Zuerst das Fazit

  • Das wait einer Bedingungsvariable kann zurückkehren, auch wenn keine Benachrichtigung angekommen ist. Die offizielle Win32-Dokumentation stellt fest, dass Bedingungsvariablen Scheinwecken (Aufwachen, die nicht an ein ausdrückliches Wecken gebunden sind) und gestohlenen Aufwachen (ein anderer Thread verbraucht die Bedingung, bevor der aufgeweckte Thread es tut) unterliegen.1
  • Also müssen Sie das Warten immer als „eine while-Schleife plus eine erneute Prüfung der Bedingung“ schreiben. Code, der einmal mit if prüft und dann waitet, sieht so aus, als funktionierte er, und beherbergt einen Fehler, der sich nur selten reproduziert.12
  • Das ist keine Windows-spezifische Eigenheit; POSIX und der C++-Standard sagen dasselbe. Eine Umsetzung, die „absolut nie scheinweckt“, würde jede Bedingungsvariablen-Operation verlangsamen, also ist das Aufwachen unter der Annahme erlaubt, dass der Wartende erneut prüft.34
  • In C++ führt die Prädikatform wait(lock, pred) die Schleife durch die Bibliothek für Sie aus. Diese Form führt wirksam while (!pred()) wait(lock); aus. Sie ist die Vorgabe für neuen Code.5
  • C#s Monitor.Wait braucht dieselbe Disziplin. Die Bedingung kann in dem Intervall zwischen dem Aufgewecktwerden und dem erneuten Erwerben der Sperre verbraucht werden, also prüfen Sie die Bedingung in einem while erneut und gehen zurück zu Wait.6
  • Aktualisieren und prüfen Sie die Bedingung unter derselben Sperre. Wenn Sie die Bedingung außerhalb der Sperre ansehen und dann wait betreten, kann eine Benachrichtigung durch die Lücke gehen — ein verlorenes Aufwachen.1
  • Bauen Sie die transiente Benachrichtigung einer Bedingungsvariable „wecke, wer gerade wartet“ nicht mit einem Pulse auf einem Ereignis nach. PulseEvent insbesondere kann die Benachrichtigung in dem Augenblick verfehlen, in dem ein Kernelmodus-APC das Warten kurz anhebt, und Microsoft selbst sagt ausdrücklich: „es ist unzuverlässig, nutzen Sie es nicht, nutzen Sie stattdessen eine Bedingungsvariable“.7

Was folgt, geht die Mechanismen durch, die diese Schlussfolgerung stützen, der Reihe nach.

2. Was ein Scheinwecken ist — Aufwachen bedeutet nicht, dass die Bedingung gilt

Eine Bedingungsvariable ist ein Synchronisierungsprimitiv zum „einen Thread schlafen legen, bis eine Bedingung gilt, und ihn aufwecken, wenn sie gilt“. Unter Win32 ist das die Struktur CONDITION_VARIABLE zusammen mit SleepConditionVariableCS / SleepConditionVariableSRW (warten) und WakeConditionVariable / WakeAllConditionVariable (benachrichtigen). Die Warte-API gibt atomar die Sperre frei, die Sie halten (eine Critical Section oder eine SRW-Sperre), und geht schlafen, und beim Aufwachen erwirbt sie die Sperre erneut, bevor sie zurückkehrt.1

Die Frage ist, was die Tatsache, „von wait zurückgekehrt zu sein“, tatsächlich bedeutet. Naiv möchten Sie denken „eine Benachrichtigung ist angekommen = die Bedingung gilt“, aber in Wirklichkeit gibt es drei Fälle, in denen wait zurückkehrt.

Fall Benachrichtigung Bedingung bei Rückkehr
Echtes Aufwachen Ja Oft erfüllt, aber nicht garantiert
Scheinwecken Keine, die an Sie gerichtet ist Noch unerfüllt
Gestohlenes Aufwachen Ja Ein anderer Thread hat sie zuerst verbraucht; unerfüllt
Drei Fälle, in denen wait zurückkehrtDas Warten einer Bedingungsvariable kann nicht nur von einer echten Benachrichtigung zurückkehren, sondern auch von einem Scheinwecken ohne Benachrichtigung und von einem gestohlenen Aufwachen, bei dem eine Benachrichtigung ankam, die Bedingung aber zuerst verbraucht wurde, sodass jeder Fall die Bedingung erneut prüfen mussVon wait zurückgekehrtEchte BenachrichtigungScheinwecken(keine Benachrichtigung)Gestohlenes Aufwachen(Bedingung bereits verbraucht)Die Bedingung erneut prüfen, dann fortfahren

Abbildung 1: Es gibt drei Wege zurück von wait, und der Aufrufer kann nicht sagen, welchen er genommen hat, also müssen Sie die Bedingung immer erneut prüfen.

Ein Scheinwecken ist dieser zweite Fall — das Phänomen, dass die Warte-API zurückkehrt, ohne an eine ausdrückliche Benachrichtigung gebunden zu sein, die Sie aufwecken sollte. Es ist nicht auf Situationen beschränkt, in denen WakeConditionVariable nirgendwo im System je aufgerufen wurde. Zum Beispiel kann die Umsetzung unter hoher Last, bei der Benachrichtigungen in einem kurzen Stoß ankommen, zusätzliche wartende Threads in einem Stapel aufwecken, und von der Seite, die keine entsprechende Benachrichtigung hat, ist das ebenfalls ein Scheinwecken. Die Bedingungsvariablen-Seite von Microsoft Learn sagt das deutlich: „Condition variables are subject to spurious wakeups (those not associated with an explicit wake) and stolen wakeups (another thread manages to run before the woken thread). Therefore, you should recheck a predicate (typically in a while loop) after a wait operation returns.“1

Der wichtige Punkt ist, dass der Aufrufer nicht sagen kann, durch welchen der drei Fälle er zurückgekehrt ist. Wenn Sie es nicht sagen können, gibt es nur eine verfügbare Strategie: jedes Mal, wenn Sie zurückkehren, prüfen Sie die Bedingung selbst, auf die Sie gewartet haben, und gehen Sie wieder schlafen, wenn sie nicht gilt. Das ist der wirkliche Inhalt der eisernen Regel „wickeln Sie wait in ein while“. Andersherum: Solange Sie diese Regel halten, ist der Code korrekt, egal welcher der drei Fälle Sie aufgeweckt hat.

3. Warum die Spezifikation es zulässt — Präzise Benachrichtigung ist teuer

„Aufwachen ohne Benachrichtigtwerden ist doch nur schlampige Umsetzung, oder?“ ist eine faire Frage. Tatsächlich ist es theoretisch möglich, eine Umsetzung zu bauen, die nie scheinweckt. Trotzdem sind POSIX, Windows und der C++-Standard alle auf der Seite „es kann geschehen“ gelandet. Der Grund steht offen in der Rationale für pthread_cond_wait in POSIX (The Open Group Base Specifications).3

Der erste Grund ist Leistung. Zu versuchen, Benachrichtigung, die „zuverlässig genau einen Thread weckt“, streng umzusetzen, fügt besonders auf Multiprozessoren extra Synchronisierungskosten zu jeder Bedingungsvariablen-Operation hinzu. Zwischen Benachrichtigung und Aufwachen sitzt ein Scheduler, und je nach Zeitpunkt von Unterbrechungen und Verdrängung können Sie „einen anderen Thread, der läuft, bevor der, den Sie wecken wollten“ nicht vermeiden. Allen die Kosten zu zahlen, das vollständig abzudichten, ist schlechter, um Bedingungsvariablen schnell zu halten, als hinzunehmen, dass „Sie gelegentlich extra wecken dürfen“.

Der zweite Grund ist die Beobachtung, dass dieser Kompromiss Anwendungen nicht bricht — er macht sie tatsächlich robuster. Weil Scheinwecken erlaubt sind, schreibt korrekter Code immer eine Schleife, die das Prädikat prüft (die Bedingung, auf die gewartet wird). Die POSIX-Rationale sagt, dass das Erzwingen dieser Schleife den Code selbst-dokumentierend und robuster macht.3 Sobald die Schleife da ist, wird die Bedeutung einer Benachrichtigung von „einer Garantie, dass die Bedingung gilt“ zu „einem Hinweis, dass sich die Bedingung geändert haben kann“ herabgestuft, und die wartende Seite wird tolerant gegenüber bescheidenen Entwurfsänderungen auf der Benachrichtigerseite (zu viele wecken, in einem Stapel wecken und so weiter).

Gestohlene Aufwachen sind eine noch strukturellere Sache. Es gibt immer eine Zeitlücke zwischen dem Aufruf von WakeConditionVariable durch den Benachrichtiger und dem erneuten Erwerben der Sperre und der Rückkehr von wait durch den aufgeweckten Thread. Wenn ein dritter Thread die Sperre in diesem Intervall nehmen kann, kann er die Bedingung (den Inhalt der Warteschlange und so weiter) zuerst verbrauchen. Das ist eine Lücke, die keine Politur der Umsetzung tilgen kann, weil sie aus der Form des Werkzeugs Bedingungsvariable selbst kommt.

Zeitachse eines gestohlenen AufwachensDer Produzent legt ein Element in die Warteschlange und weckt den wartenden Verbraucher A, aber bevor A die Sperre erneut erwirbt, erwirbt Verbraucher B die Sperre und nimmt das eine Element, sodass die Warteschlange leer ist, wenn A aufwachtVerbraucher BProduzentVerbraucher A(wartend)Verbraucher BProduzentVerbraucher A(wartend)Aufgeweckt, wartet auf erneuten SperrerwerbWarteschlange ist leer(gestohlen)Ein Element zur Warteschlange hinzufügenWakeConditionVariableDie Sperre erwerben und ein Element nehmenDie Sperre erneut erwerben und von wait zurückkehrenIn der while-Schleife erneut prüfen und wieder warten

Abbildung 2: Ein „gestohlenes Aufwachen“, in dem ein dritter Thread die Bedingung in der Zeitlücke zwischen Benachrichtigung und Aufwachen verbraucht, kann unter jeder Umsetzung geschehen.

Mit anderen Worten: Selbst wenn das Betriebssystem Scheinwecken vollständig ausmerzte, können Sie, solange gestohlene Aufwachen existieren, immer noch nicht „ich bin aufgewacht = die Bedingung gilt“ schreiben. Die erneute Prüfungsschleife des Wartenden ist sowieso nötig, und angesichts dessen ist es billiger, Scheinwecken zuzulassen und die Umsetzung schnell zu halten — das ist das Entwurfsurteil, das Bedingungsvariablen seit Jahrzehnten tragen.

4. In welchen Schichten es unter Windows auftaucht

Diese Eigenschaft zeigt ihr Gesicht, welche Schicht des Windows-Synchronisierungsprimitivs Sie auch nutzen. Um ein Gefühl dafür zu bekommen, dass Sie ihm nicht entkommen, gegen welche Schicht-API Sie auch schreiben, schauen wir uns die repräsentativen Schichten an.

Win32-Bedingungsvariablen (CONDITION_VARIABLE) sind, wie bereits angemerkt, auf SleepConditionVariableCS / SleepConditionVariableSRW als sowohl Scheinwecken als auch gestohlenen Aufwachen unterliegend dokumentiert, und Sie sind verpflichtet, das Prädikat in einer while-Schleife erneut zu prüfen.2 Das offizielle Nutzungsbeispiel (eine Produzent-Verbraucher-Warteschlange) schreibt das Warten ebenfalls innerhalb einer while-Schleife.8

Das noch tiefer liegende WaitOnAddress ist eine primitivere Warte-API als eine Bedingungsvariable: „warten, bis sich der Wert an einer gegebenen Adresse ändert“ (Windows 8 und später). Selbst diese nahezu unterste Schicht-API hat Dokumentation, die feststellt, „es ist garantiert, zurückzukehren, wenn die Adresse signalisiert wird, aber es ist auch erlaubt, aus anderen Gründen zurückzukehren“, und listet als Beispiele für frühes Aufwachen einen Speicherknappheitszustand, das Aufgeben eines vorherigen Weckens für dieselbe Adresse und das Ausführen eines Checked Builds. Deshalb ist das eigene Nutzungsbeispiel der Dokumentation in der Form „einer while-Schleife, die den Wert erneut vergleicht“.9

C++s std::condition_variable ist dasselbe. Die Dokumentation von MSVC sagt von der prädikatlosen wait, dass sie „blockiert, bis durch einen Aufruf von notify_one / notify_all signalisiert. Sie kann auch scheinwecken“, und erklärt, dass die Prädikatform wait(lock, pred) wirksam den folgenden Code ausführt.5

while (!Pred())
    wait(Lck);

Mit anderen Worten: Die in C++ empfohlene prädikatförmige wait ist nichts anderes, als dass die Bibliothek Ihnen „wickeln Sie es in ein while“, wie dieser Artikel beschreibt, abnimmt. cppreference stellt ebenfalls fest, dass die prädikatlose wait scheinbar entblockt werden kann.4

.NETs Monitor.Wait / Pulse hat seine eigene Warteschlangenstruktur aus einer Wartewarteschlange und einer Bereitschaftswarteschlange, aber die Disziplin ändert sich nicht. Ein durch Pulse / PulseAll aufgeweckter Thread wechselt in die Bereitschaftswarteschlange und kehrt von Wait in der Reihenfolge zurück, in der er die Sperre erneut erwerben kann. Dass ein anderer Thread die Bedingung in dem Intervall vor dem erneuten Erwerben der Sperre verbrauchen kann, ist dasselbe wie unter Win32, und die Dokumentation ist ebenfalls unter der Annahme geschrieben, dass „der aufgeweckte Thread die Bedingung erneut auswertet, die ihn ins Warten hat eintreten lassen, und Wait bei Bedarf erneut aufruft“.610

Jede Schicht verlangt, dass das Prädikat erneut geprüft wirdOffizielle Dokumentation verlangt, die Bedingung nach dem Aufwachen in jeder Schicht erneut zu prüfen — C++ std::condition_variable, .NET Monitor, Win32 CONDITION_VARIABLE und das tief liegende WaitOnAddressC++ std::condition_variableBeim Aufwachen die Bedingung erneut prüfen(while).NET Monitor.WaitWin32 CONDITION_VARIABLEWaitOnAddress

Abbildung 3: Wechseln Sie die Sprache oder das Framework, und die offizielle Anforderung ist in jeder Schicht des Warteprimitivs immer noch dieselbe: prüfen Sie nach dem Aufwachen erneut.

5. Die richtige Art zu warten — Mit while und einem Prädikat schreiben

Von hier an Umsetzung. Es gibt nur drei Prinzipien.

  1. Halten Sie das, worauf Sie warten, als Zustand (ein Prädikat), nicht als „Benachrichtigung“. Die Bedingung ist gemeinsam genutzter Zustand, der durch eine Sperre geschützt ist — „ist die Warteschlange nicht leer?“, „ist das Flag gesetzt?“ —, nicht „wurde ich aufgeweckt?“.
  2. Platzieren Sie wait immer innerhalb einer while-Schleife auf der Bedingung. Jedes Mal, wenn Sie aufwachen, prüfen Sie die Bedingung und gehen Sie wieder schlafen, wenn sie nicht gilt.
  3. Aktualisieren und prüfen Sie die Bedingung unter derselben Sperre. Der Benachrichtiger aktualisiert den Zustand und benachrichtigt dann.
Ablauf einer korrekten WarteschleifeErwerben Sie die Sperre und prüfen Sie die Bedingung; wenn sie nicht gilt, geben Sie die Sperre frei und schlafen Sie; beim Aufwachen erwerben Sie die Sperre erneut und kehren zur Bedingungsprüfung zurück. Fahren Sie mit gehaltener Sperre nur fort, wenn die Bedingung giltNeinJaDie Sperre erwerbenIst die Bedingung erfüllt?wait(die Sperre freigeben und schlafen)Aufwachen(die Sperre erneut erwerben)Mit noch gehaltener Sperre fortfahren

Abbildung 4: Ein korrektes Warten ist eine Schleife, und es gibt keine Lücke zwischen dem Prüfen der Bedingung und dem Verarbeiten (beides geschieht, während die Sperre gehalten wird).

Diese Form hat einen leicht zu übersehenden Nutzen. In dem Moment, in dem Sie die while-Schleife verlassen, ist festgestellt, während Sie die Sperre noch halten, dass „die Bedingung gilt“. Die Schleife, die gegen Scheinwecken verteidigt, ist so, wie sie ist, eine Garantie, dass es keine Race-Condition-Lücke zwischen dem Prüfen der Bedingung und dem Verarbeiten gibt.

Die Grundform in Win32 (C)

CRITICAL_SECTION cs;
CONDITION_VARIABLE cv;
int queueCount = 0;   // shared state protected by cs

// Initialise once at startup (for static initialisation, cv = CONDITION_VARIABLE_INIT)
InitializeCriticalSection(&cs);
InitializeConditionVariable(&cv);

// Waiter (consumer)
EnterCriticalSection(&cs);
while (queueCount == 0) {                       // always while, never if
    SleepConditionVariableCS(&cv, &cs, INFINITE);
}
// Here the lock is held and queueCount > 0 is guaranteed
--queueCount;
LeaveCriticalSection(&cs);

// Notifier (producer)
EnterCriticalSection(&cs);
++queueCount;                                    // update the state under the lock
LeaveCriticalSection(&cs);
WakeConditionVariable(&cv);                      // notifying after releasing the lock is fine

Sie können die Benachrichtigung (WakeConditionVariable) von innerhalb der Sperre oder von außerhalb aufrufen, aber die Dokumentation sagt, dass das Wecken nach dem Freigeben der Sperre gewöhnlich besser ist, um Kontextwechsel zu reduzieren.1 Andererseits muss die Zustandsaktualisierung selbst (++queueCount) immer unter der Sperre geschehen. Verwechseln Sie die zwei nicht.

Die Grundform in C++ — Machen Sie Prädikat-wait zur Vorgabe

std::mutex m;
std::condition_variable cv;
std::queue<Item> q;

// Waiter
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&] { return !q.empty(); });   // internally while(!pred) wait
Item item = std::move(q.front());
q.pop();
lk.unlock();

// Notifier
{
    std::lock_guard<std::mutex> lk(m);
    q.push(std::move(item));
}
cv.notify_one();

Weil die prädikatförmige wait die Schleife für Sie ausführt, ist ein handgeschriebenes while unnötig. Wenn Sie bestehenden Code reparieren, der noch eine handgeschriebene Schleife hat, ist while (q.empty()) cv.wait(lk); eine korrekte Form, also besteht keine Notwendigkeit, es eilig umzuschreiben. Die einzige inkorrekte Form ist if (q.empty()) cv.wait(lk);.

Die Grundform in C#

private readonly object _gate = new();
private readonly Queue<Item> _queue = new();

// Waiter
lock (_gate)
{
    while (_queue.Count == 0)          // always while, never if
    {
        Monitor.Wait(_gate);
    }
    var item = _queue.Dequeue();
}

// Notifier
lock (_gate)
{
    _queue.Enqueue(item);
    Monitor.Pulse(_gate);              // Monitor.Pulse can only be called inside the lock
}

Monitor.Wait / Pulse / PulseAll können nur von innerhalb einer Sperre (lock-Block) aufgerufen werden, was sich von Win32 unterscheidet. Sie außerhalb der Sperre aufzurufen wirft SynchronizationLockException.10

Warten mit einem Timeout — Verbleibende Zeit von einer Frist berechnen

Wenn Sie mit einem Timeout warten, dehnt das Übergeben „desselben Timeout-Werts“ bei jeder Schleifeniteration das Warten jedes Mal, wenn ein Scheinwecken geschieht. Die korrekte Form ist, die Frist zuerst festzulegen und die verbleibende Zeit neu zu berechnen.

ULONGLONG deadline = GetTickCount64() + timeoutMs;
EnterCriticalSection(&cs);
while (queueCount == 0) {
    ULONGLONG now = GetTickCount64();
    if (now >= deadline) {
        break;                          // timeout (condition still unsatisfied)
    }
    if (!SleepConditionVariableCS(&cv, &cs, (DWORD)(deadline - now)) &&
        GetLastError() != ERROR_TIMEOUT) {
        break;                          // on failure other than timeout, stop waiting and leave
    }
    // Confirm ERROR_TIMEOUT finally via the while condition and the deadline check
}
BOOL ready = (queueCount > 0);
if (ready) { --queueCount; }
LeaveCriticalSection(&cs);
Korrekter Ablauf eines Wartens mit TimeoutLegen Sie die Frist zuerst fest, und jedes Mal, wenn Sie aufwachen, prüfen Sie die Bedingung und die Frist; wenn noch Zeit ist, berechnen Sie die verbleibende Zeit neu und kehren zum Warten zurückJaNeinJaNeinDie Frist festlegenIst die Bedingung erfüllt?Zur Verarbeitung fortfahrenIst die Frist verstrichen?Das Timeout behandelnVerbleibende Zeit berechnen und warten

Abbildung 5: Ein Warten mit Timeout übergibt nicht erneut „dieselbe Wartedauer“; es berechnet die verbleibende Zeit von einer Frist neu.

In C++ können Sie diese Berechnung, Frist und alles, der Überladung wait_until (absolute Zeit) plus Prädikat überlassen. Selbst wenn sie bei Timeout zurückkehrt, gibt sie Ihnen den Endwert des Prädikats, sodass Sie auch „sind wir abgelaufen, oder haben wir es geschafft?“ am Prädikat entscheiden können.5

6. Ein Katalog der Muster, die Sie vermeiden sollten

Nur einmal mit if prüfen. Das ist der Star des Artikels. In dem Moment, in dem ein Scheinwecken oder ein gestohlenes Aufwachen geschieht, setzt die Verarbeitung mit unerfüllter Bedingung fort. Aus einer leeren Warteschlange nehmen, uninitialisierte Daten anfassen, ein Double Free — das Symptom wird zu „einem Absturz oder einer Datenverderbnis, die nur gelegentlich erscheint“.

Die Bedingung außerhalb der Sperre prüfen oder aktualisieren. Wenn der Wartende die Bedingung außerhalb der Sperre ansieht, entscheidet „noch nicht“ und in der Lücke vor dem Betreten von wait der Benachrichtiger den Zustand aktualisiert und eine Benachrichtigung sendet, wird die Benachrichtigung an eine Bedingungsvariable ohne Wartenden gefeuert und verschwindet. Der Wartende betritt dann wait und wartet weiter auf eine Benachrichtigung, die nie wieder kommt. Das ist ein verlorenes Aufwachen, das Spiegelbild eines Scheinweckens. Der Grund, warum die Warte-API einer Bedingungsvariable so entworfen ist, „atomar die Sperre freizugeben und schlafen zu gehen“, ist genau, diese Lücke zu schließen.1 Es geschieht nicht, solange Sie Sperrdisziplin halten.

Zeitachse eines verlorenen AufwachensWenn der Wartende die Bedingung außerhalb der Sperre prüft und der Benachrichtiger den Zustand aktualisiert und in der Lücke vor dem Betreten von wait benachrichtigt, wird die Benachrichtigung an eine Bedingungsvariable ohne Wartenden gesendet und verschwindet, und der Wartende wartet weiter auf eine Benachrichtigung, die nie kommtBenachrichtigerWartenderBenachrichtigerWartenderIn diesem Moment kein WartenderDie Benachrichtigung ist bereits weg und es wacht nie aufDie Bedingung außerhalb der Sperre prüfen(unerfüllt)Den Zustand aktualisieren und benachrichtigenwait betreten

Abbildung 6: Wenn Sie die Bedingung außerhalb der Sperre prüfen, rutscht die Benachrichtigung durch die Lücke zwischen Prüfung und wait — ein „verlorenes Aufwachen“.

Die „transiente Benachrichtigung“ einer Bedingungsvariable mit einem Pulse auf einem Ereignis nachbauen. Ereignisse selbst (CreateEvent + SetEvent) sind kein Anti-Muster. Ein Wecksignal in einem Aufbau, in dem ein einzelner Verbraucher die Warteschlange verarbeitet, bis sie leer ist, oder eine Stoppanweisung, die einmal erhoben nie gesenkt wird (ein Manual-Reset-Ereignis), sind korrekte Nutzungen eines Ereignisses; und wenn Sie es über WaitForMultipleObjects mit anderen Wartezielen verbinden oder eine Prozessgrenze überschreiten wollen, ist eine Bedingungsvariable — ein Benutzermodusobjekt, das nicht über Prozesse hinweg geteilt werden kann — die, die nicht genutzt werden kann.1 Was gefährlich ist, ist zu versuchen, mit Ereignisoperationen die transiente Benachrichtigung einer Bedingungsvariable nachzubauen, die „nur die Threads weckt, die in diesem Augenblick warten, und keinen Zustand hinterlässt“. Diese Idee führt fast immer zum nächsten Punkt, PulseEvent.

PulseEvent nutzen. Es ist eine API, die auf einem Manual-Reset-Ereignis „jeden derzeit Wartenden weckt und das Ereignis sofort in den nicht signalisierten Zustand zurücksetzt“, aber Microsoft selbst stellt in der Dokumentation fest, dass „diese Funktion unzuverlässig ist und nicht genutzt werden sollte. Sie existiert hauptsächlich für Abwärtskompatibilität. Nutzen Sie stattdessen eine Bedingungsvariable.“ Der Grund ist, dass ein wartender Thread vorübergehend durch einen Kernelmodus-APC aus dem Wartezustand genommen werden und nach Abschluss des APC zum Warten zurückkehren kann. Wenn PulseEvent in diesem kurzen Intervall aufgerufen wird, ist dieser Thread nicht unter „denen, die im Moment des Aufrufs warteten“ enthalten und wird nicht aufgeweckt.7 Kernel-APCs sind etwas, das das Betriebssystem intern nutzt; die App kann sie nicht steuern.11 Dieses Problem ist auch eine statische Analysewarnung (C28648).12 Wenn ein Scheinwecken das Problem von „extra wecken“ ist, ist dies das Problem von „verschlafen, wenn Sie hätten aufwachen sollen“, und eine while-Schleife kann Sie nicht retten — weil die Benachrichtigung selbst verloren ist.

Nur die Benachrichtigung zuerst senden, ohne die Sperre zu halten, bevor der Zustand aktualisiert wird. WakeConditionVariable aufzurufen, während der Zustand noch veraltet ist, und erst dann die Sperre zu nehmen und den Zustand zu aktualisieren — in dieser Reihenfolge sieht der aufgeweckte Thread die Bedingung beim Prüfen immer noch unerfüllt und geht wieder schlafen. Wenn keine weitere Benachrichtigung kommt, bleibt er dort. Beachten Sie, dass, wenn Sie „benachrichtigen → aktualisieren → freigeben“ schreiben, während Sie dieselbe Sperre noch halten, kein echter Schaden entsteht, weil der Wartende die Bedingung nicht prüfen kann, bis er die Sperre erneut erwirbt. Trotzdem, damit Leser diese Sicherheitsbedingung nicht jedes Mal prüfen müssen, ist es sicherer, sich auf die Reihenfolge „den Zustand unter der Sperre aktualisieren und danach benachrichtigen“ zu standardisieren.

7. Wie Sie untersuchen, wenn Sie darauf stoßen

Fehler, die Scheinwecken einbeziehen, sind durch „nur selten erscheinen“ gekennzeichnet. Vom Symptom rückwärts arbeitend teilen sie sich in die folgenden zwei Familien.

Familie 1: Die Verarbeitung setzt mit unerfüllter Bedingung fort. Eine Ausnahme oder ein Absturz durch das Nehmen aus einer leeren Warteschlange, fehlende Ergebnisse und so weiter. Verdächtigen Sie ein Warten ohne Prädikat. Sie können das in der Codeprüfung mechanisch auskämmen — suchen Sie nach Stellen, an denen cv.wait( nur ein Argument hat, und Stellen, an denen SleepConditionVariableCS / Monitor.Wait in if statt in while gewickelt ist. Diese Prüfung erfordert nicht, auf eine Reproduktion zu warten, und ist der Hebel mit dem höchsten Nutzen, den Sie haben.

Familie 2: Ein Thread, der aufwachen sollte, tut es nicht (ein Hänger). Verdächtigen Sie ein verlorenes Aufwachen (die Bedingung außerhalb der Sperre prüfen oder außerhalb der Sperre benachrichtigen, bevor der Zustand aktualisiert wird) und PulseEvent. Nehmen Sie einen Dump vom hängenden Prozess und schauen Sie auf den Stack jedes Threads, und Sie können identifizieren, welcher Thread in welcher Warte-API steckt. Von dort aus verfolgen Sie durch den Code „wer sollte diese Benachrichtigung senden, und in welcher Reihenfolge“.

Triage-Ablauf vom SymptomWenn die Verarbeitung mit unerfüllter Bedingung fortsetzt, kämmen Sie Warten ohne Prädikat durch Durchsuchen des Codes aus; wenn ein Thread nicht aufwacht, identifizieren Sie die Wartestelle aus einem Dump und verdächtigen Sie ein verlorenes Aufwachen oder PulseEventEin Fehler, der nur selten erscheintVerarbeitung setzt mit unerfüllter Bedingung fortEin Thread, der aufwachen sollte, tut es nichtDen Code nach Warten ohne Prädikat durchsuchenWartende Threads aus einem Dump identifizierenif in while ändern oder Prädikat-wait nutzenEin verlorenes Aufwachen oder PulseEvent verdächtigen

Abbildung 7: Ob das Symptom „zu weit fortfahren“ oder „nie aufwachen“ ist, teilt sowohl, was Sie verdächtigen, als auch, wie Sie untersuchen.

Wenn Sie es reproduzieren wollen, ist der Standardzug, das Race-Fenster zu erweitern. Erhöhen Sie Timing-Jitter, indem Sie mehr Threads als physische Kerne nutzen, ein absichtliches Sleep zwischen Warten und Benachrichtigen einfügen und sowohl Debug- als auch Release-Builds ausführen. Wenn Sie bestätigen, dass „es aufgehört hat, sich zu reproduzieren, nachdem wir das prädikatlose Warten repariert haben“, vergleichen Sie unter demselben Stress.

8. Zusammenfassung — Eine Checkliste

  • Die Wege zurück von wait sind drei — echte Benachrichtigung, Scheinwecken und gestohlenes Aufwachen — und der Aufrufer kann sie nicht unterscheiden. Also schreiben Sie das Warten immer als while-Schleife auf der Bedingung.
  • Ein Scheinwecken ist Verhalten, das Win32, C++ und POSIX bewusst als Kompromiss gegen Leistung zugelassen haben, und es wird mit einer Betriebssystemkorrektur oder einem Bibliothekstausch nicht weggehen. .NETs Monitor.Wait wird nicht als grundlos aufwachend angenommen, aber weil gestohlene Aufwachen und Timeouts existieren, ist dieselbe while-Disziplin trotzdem nötig.
  • In C++ nutzen Sie standardmäßig die Prädikatform wait(lock, pred). Die Bibliothek führt die Schleife aus.
  • Aktualisieren und prüfen Sie die Bedingung unter derselben Sperre. Senden Sie die Benachrichtigung „nach dem Aktualisieren des Zustands“. Win32/C++-Benachrichtigung darf nach dem Freigeben der Sperre geschehen; C#s Pulse ist nur innerhalb der Sperre.
  • Für ein Warten mit Timeout legen Sie eine Frist fest und berechnen Sie die verbleibende Zeit neu. In C++ wait_until plus ein Prädikat.
  • Bauen Sie die transiente Benachrichtigung einer Bedingungsvariable nicht mit einem Pulse auf einem Ereignis nach. PulseEvent insbesondere ist etwas, von dem offizielle Dokumentation ausdrücklich feststellt: „nutzen Sie es nicht, nutzen Sie stattdessen eine Bedingungsvariable“. Ereignisse selbst bleiben das richtige Werkzeug für eine Stoppanweisung, das Verbinden mit WaitForMultipleObjects und prozessübergreifende Synchronisierung.
  • In der Prüfung suchen Sie mechanisch nach „prädikatlosem Warten“ und „if + wait“. Sie können einen selten reproduzierbaren Fehler töten, ohne auf eine Reproduktion zu warten.

Ein Scheinwecken verdichtet sich, entgegen der Seltsamkeit des Namens, auf ein Einzeilen-Stichwort für die Reparatur — ändern Sie if in while. Und hinter dieser einen Zeile liegt die Entwurfsidee des Werkzeugs Bedingungsvariable: „präzise Benachrichtigung ist teuer, also ist das Prüfen die Verantwortung des Wartenden“. Verstehen Sie es als Mechanismus, und Sie sollten dieselbe Disziplin ohne Zögern anwenden können, wenn sich die Sprache oder das Framework ändert.

Weiterführende Artikel

Zugehörige Beratungsfelder

KomuraSoft LLC übernimmt Multithread-Entwurfsreviews, Ursachenuntersuchung (Dump-Analyse) von Abstürzen und Hängern, die „sich nur gelegentlich reproduzieren“, sowie die Migration von Legacy-Synchronisierungscode (ereignis- und PulseEvent-abhängig und Ähnliches) auf eine Bedingungsvariablen-Basis. Vom Triagieren des Symptoms zu beginnen ist in Ordnung — bitte zögern Sie nicht, uns zu erreichen.

Quellen

  1. Microsoft Learn, Condition Variables. Dazu, dass eine Bedingungsvariable ein Benutzermodusobjekt ist, das atomar eine Sperre freigibt und ins Warten geht; dazu, dass es Scheinwecken (Aufwachen, die nicht an ein ausdrückliches Wecken gebunden sind) und gestohlene Aufwachen (ein anderer Thread läuft vor dem aufgeweckten Thread) gibt, sodass Sie nach der Rückkehr von einem Warten das Prädikat in einer while-Schleife erneut prüfen sollten; sowie dazu, dass Benachrichtigung von innerhalb oder außerhalb der Sperre möglich ist, das Wecken nach dem Freigeben der Sperre aber besser ist, um Kontextwechsel zu reduzieren.  2 3 4 5 6 7 8

  2. Microsoft Learn, SleepConditionVariableCS function (synchapi.h). Dazu, atomar eine angegebene Critical Section freizugeben und auf einer Bedingungsvariable zu warten; dazu, dass der aufgeweckte Thread die Critical Section vor der Rückkehr erneut erwirbt; dazu, dass ERROR_TIMEOUT bei Timeout zurückgegeben wird; sowie dazu, dass es Scheinwecken und gestohlene Aufwachen gibt, sodass Sie nach der Rückkehr von einem Warten das Prädikat erneut prüfen sollten (typischerweise in einer while-Schleife).  2

  3. The Open Group Base Specifications, pthread_cond_timedwait, pthread_cond_wait. Dazu, dass Scheinwecken von pthread_cond_wait / pthread_cond_timedwait auftreten können; dazu, dass die Rückkehr von wait nichts über den Wert des Prädikats bedeutet, sodass das Prädikat erneut ausgewertet werden sollte; sowie dazu, dass die Rationale feststellt, eine Umsetzung, die „genau einen weckt“, Bedingungsvariablen-Operationen besonders auf Multiprozessoren verlangsamen kann, und dass das Zulassen von Scheinwecken eine Prädikatprüfungsschleife erzwingt und Anwendungen robuster macht.  2 3

  4. cppreference.com, std::condition_variable::wait. Dazu, dass die prädikatlose wait durch ein Scheinwecken entblockt werden kann; sowie dazu, dass die Prädikatüberladung äquivalent zu while (!pred()) wait(lock); ist und als Schleife definiert ist, die die Sperre erneut erwirbt und das Prädikat bei jeder Benachrichtigung oder jedem Scheinwecken prüft.  2

  5. Microsoft Learn, condition_variable Class. Dazu, dass die prädikatlose wait als bei notify_one / notify_all entblockend und auch als scheinwecken könnend angegeben ist; dazu, dass die Prädikatform wait(lock, pred) wirksam while (!Pred()) wait(Lck); ausführt; sowie dazu, dass wait_for / wait_until dieselbe Eigenschaft und eine Prädikatüberladung haben.  2 3

  6. Microsoft Learn, Monitor.Wait Method. Dazu, dass Wait die Sperre freigibt und in die Wartewarteschlange eintritt; dazu, dass es nach dem Aufgewecktwerden durch Pulse / PulseAll nicht zurückkehrt, bis die Sperre erneut erworben ist; sowie zur beabsichtigten Nutzung, dass der aufgeweckte Thread die Bedingung erneut auswertet, die ihn ins Warten hat eintreten lassen, und Wait bei Bedarf erneut aufruft.  2

  7. Microsoft Learn, PulseEvent function (winbase.h). Dazu, dass ein wartender Thread vorübergehend durch einen Kernelmodus-APC aus dem Wartezustand genommen werden und nach Abschluss des APC zurückkehren kann, sodass der Thread nicht freigegeben wird, wenn PulseEvent in diesem Intervall aufgerufen wird; sowie dazu, dass PulseEvent daher unzuverlässig ist und in neuen Anwendungen nicht genutzt werden sollte, stattdessen eine Bedingungsvariable.  2

  8. Microsoft Learn, Using Condition Variables. Zum offiziellen Beispiel, das eine Produzent-Verbraucher-Warteschlange mit einer Critical Section und zwei Bedingungsvariablen (BufferNotEmpty und BufferNotFull) umsetzt. Das Warten wird innerhalb einer Schleife ausgeführt, die das Prädikat prüft. 

  9. Microsoft Learn, WaitOnAddress function (synchapi.h). Dazu, dass die Funktion, die auf die Änderung des Werts einer Adresse wartet, garantiert zurückkehrt, wenn signalisiert, aber auch aus anderen Gründen zurückkehren darf; zu Beispielen für frühes Aufwachen einschließlich eines Speicherknappheitszustands, des Aufgebens eines vorherigen Weckens für dieselbe Adresse und des Ausführens eines Checked Builds; sowie dazu, dass Sie den Wert nach der Rückkehr daher erneut vergleichen müssen, wobei das offizielle Beispiel selbst eine while-Schleife ist. 

  10. Microsoft Learn, Monitor.PulseAll Method. Dazu, dass PulseAll Threads von der Wartewarteschlange in die Bereitschaftswarteschlange bewegt und der nächste Thread auf der Bereitschaftswarteschlange die Sperre erwirbt, wenn die Sperre freigegeben wird; sowie dazu, dass Pulse / PulseAll / Wait nur von innerhalb eines Synchronisierungsblocks aufgerufen werden können.  2

  11. Microsoft Learn, Waits and APCs. Dazu, dass Kernel-APCs verdrängend ausgeführt werden und das System intern ein Warten unterbricht und fortsetzt, ohne von der Warte-API zurückzukehren, sodass ein transientes Signal wie KePulseEvent in diesem Intervall verfehlt werden kann. 

  12. Microsoft Learn, C28648: PulseEvent is an unreliable function. Zur statischen Analysewarnung bei der Nutzung von PulseEvent; dazu, dass ein Thread, der wegen eines APC außerhalb des Wartens war, nicht freigegeben wird und ewig hängen kann; sowie zur Leitlinie, es durch SetEvent oder ein anderes Synchronisierungsobjekt zu ersetzen. 

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Ist ein Scheinwecken ein Fehler im Betriebssystem oder in der Bibliothek?
Nein — es ist Verhalten, das in der Spezifikation ausgeschrieben ist. Win32s SleepConditionVariableCS, C++s std::condition_variable und POSIXs pthread_cond_wait haben alle offizielle Dokumentation oder einen Standard, der ausdrücklich feststellt, dass ein Aufwachen, das nicht an eine Benachrichtigung gebunden ist, auftreten kann. Eine Umsetzung, die es verböte, ist theoretisch möglich, aber sie würde jede Bedingungsvariablen-Operation verlangsamen (besonders die Benachrichtigung auf Multiprozessoren), also ist der Kompromiss, es zuzulassen unter dem Verständnis, dass „die Korrektheit erhalten bleibt, wenn der Wartende die Bedingung erneut prüft“. Das Heilmittel ist daher nicht, auf eine Betriebssystemkorrektur zu warten, sondern wait immer innerhalb einer while-Schleife zu schreiben (oder eine prädikatförmige wait zu nutzen).
Schadet das Einwickeln von wait in eine while-Schleife der Leistung?
In der Praxis sind die Kosten vernachlässigbar. Alles, was die while-Schleife hinzufügt, ist eine zusätzliche Bedingungsprüfung jedes Mal, wenn Sie aufwachen, und das ist ein billiger Vergleich, während Sie die Sperre bereits halten. Scheinwecken selbst sind selten, also geschieht die zusätzliche Schleifeniteration nur in Ausnahmefällen. Die Kosten, die Prüfung als if zu lassen, sind dagegen ein „Fehler, der sich nur selten reproduziert“, in dem die Verarbeitung mit unerfüllter Bedingung fortsetzt — da gibt es keinen Vergleich. Was die Wartekosten einer Bedingungsvariable tatsächlich beherrscht, ist Sperrkonkurrenz und wie oft Sie benachrichtigen, nicht ob das while da ist.
Kann ich Scheinwecken vergessen, wenn ich die prädikatförmige wait von C++ nutze?
Für die Warteschleife ja: cv.wait(lock, pred) ist wirksam while (!pred()) wait(lock);, sodass sowohl Scheinwecken als auch gestohlene Aufwachen automatisch aufgenommen werden. Neuer C++-Code sollte standardmäßig die Prädikatüberladung nutzen. Sie müssen Aktualisierungen des gemeinsam genutzten Zustands, den das Prädikat liest, trotzdem mit demselben Mutex schützen, und der Benachrichtigende muss diesen Zustand trotzdem aktualisieren, bevor er notify aufruft. Prädikat-wait nimmt Ihnen die Schleife ab; es nimmt Ihnen die Sperrdisziplin nicht ab.
Geschieht dasselbe Problem mit C#s Monitor.Wait?
Ja. Ein Thread, der in Monitor.Wait wartet, wird durch Pulse/PulseAll aufgeweckt und erwirbt dann die Sperre erneut, bevor er von Wait zurückkehrt, aber in diesem Intervall kann ein anderer Thread die Sperre zuerst erworben und die Bedingung verbraucht haben (ein gestohlenes Aufwachen). Die Dokumentation von Microsoft ist unter der Annahme geschrieben, dass der aufgeweckte Thread die Bedingung, die ihn warten ließ, erneut auswertet und Wait bei Bedarf erneut aufruft. Also ist die Grundform in C# ebenfalls while (!condition) Monitor.Wait(gate);. Eine Einschränkung, die sich von Win32 unterscheidet, ist, dass Sie Wait/Pulse nur von innerhalb einer lock-Anweisung aufrufen können.
Geschieht Scheinwecken auch, wenn Sie mit WaitForSingleObject auf ein Ereignis warten?
Bei einem gewöhnlichen (nicht alertable) Warten wird WAIT_OBJECT_0 nur zurückgegeben, wenn das Objekt tatsächlich signalisiert wird; es gibt kein „grundloses Aufwachen“ der Art, die Bedingungsvariablen haben. Allerdings sind „das Ereignis wurde signalisiert“ und „die Bedingung Ihrer Anwendung gilt“ verschiedene Dinge. Wenn mehrere Verbraucher durch dasselbe Ereignis aufgeweckt werden, verbraucht der Thread, der die Sperre zuerst nimmt, die Bedingung, also müssen Sie die Bedingung nach dem Aufwachen trotzdem erneut prüfen. Entwürfe, die versuchen, die transiente Benachrichtigung einer Bedingungsvariable „wecke nur, wer in diesem Augenblick wartet“ mit einem Ereignis nachzubauen, stoßen außerdem oft auf das Zuverlässigkeitsproblem von PulseEvent, also ist für das Warten auf eine Bedingung innerhalb eines Prozesses eine Bedingungsvariable das sicherere Werkzeug.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog