Warum Sie unter Windows Ereigniswarten gegenüber Sleep(1) bevorzugen sollten

· · Windows-Entwicklung, Synchronisierung, Ereignisse, Timer, Design

Im vorherigen Artikel, Praxisleitfaden für Windows Soft Realtime, ging es darum, periodische Schleifen zu vermeiden, die sich auf Sleep verlassen. Diesmal greifen wir daraus einen einzelnen Punkt heraus und ordnen ihn ein: warum man ein kurzes Timer-Warten gegenüber Event-Warten meiden sollte.

Dieser Artikel lässt sich eigenständig lesen. Das Fazit des vorherigen Artikels lässt sich auf eine Zeile bringen: „Eine Schleife, die das Nachschauen Sleep als Zeitgeber überlässt, garantiert weder die Wartezeit noch den Aufwachzeitpunkt und sollte deshalb nicht die Grundlage periodischer Verarbeitung sein.“ Hat man das im Kopf, lässt sich der Rest auch ohne den vorherigen Artikel nachvollziehen.

Unter Windows ist ein Design, das mit Sleep(1) oder einem Wait mit kurzem Timeout „von Zeit zu Zeit nachschaut“, unweigerlich von der Granularität der Systemuhr und der anschließenden Scheduling-Verzögerung betroffen. Bei üblichen Einstellungen wird oft eine platform timer resolution in der Größenordnung von 15,6 ms zugrunde gelegt, sodass selbst die Absicht „gleich noch mal nach 1 ms nachsehen“ in der Praxis zu einem ziemlich groben Warten wird.

Ist das, worauf man eigentlich wartet, dagegen nicht „Zeit“, sondern ein „Ereignis“ – das Eintreffen von Arbeit, der Abschluss von I/O, eine Stopp-Anforderung, eine Zustandsänderung –, muss man nicht in festen Abständen nachsehen. Die Seite, bei der das Ereignis eintritt, signalisiert, und die wartende Seite wartet auf das Event – das ist bei Latenz, CPU und Energieverbrauch gleichermaßen die naheliegendere Lösung.

Diesem Artikel geht es um diese vier Fragen:

  • Warum sind Sleep(1) und kurzes Timer-Warten weniger genau, als man annimmt?
  • Warum ist Event-Warten von dieser Einschränkung weniger betroffen?
  • In welchen Fällen sollte man ein Event statt eines Timers wählen?
  • In welchen Fällen sollte man dennoch einen Timer verwenden?

In diesem Artikel verwendete Begriffe

Nur die Abkürzungen, die im Text ohne weitere Erklärung auftauchen, seien hier vorab zusammengefasst.

Begriff Bedeutung
platform timer resolution / system clock resolution Das Intervall, in dem das Betriebssystem die Uhrzeit aktualisiert. Die Timeout-Bestimmung eines timed wait wird von dieser Granularität gezogen
ISR (Interrupt Service Routine) Die Verarbeitung, die bei einem Interrupt mit höchster Priorität läuft. Solange sie läuft, muss der eigene Thread warten
DPC (Deferred Procedure Call) Eine hochpriorisierte verzögerte Verarbeitung, die eine ISR aufsetzt, um „später weiterzumachen“. Für diesen Artikel genügt es, DPC zusammen mit ISR als Verzögerungsfaktor im Zusammenhang mit der Interrupt-Verarbeitung zu lesen
IOCP (I/O Completion Port) Ein Windows-Mechanismus, der Abschlussbenachrichtigungen asynchroner I/O in einer Queue sammelt und von einer dedizierten Gruppe von Threads entgegennehmen lässt
WaitOnAddress Eine Synchronisations-API, um „zu warten, bis sich der Wert an einer bestimmten Speicheradresse ändert“. Nur innerhalb desselben Prozesses nutzbar (behandelt in 5.3)
signalisieren Die Bedingung der wartenden Seite erfüllen. Bei einem Event bedeutet das, SetEvent aufzurufen

1. Zuerst das Fazit

  • Wartet man auf das Eintreffen von Arbeit oder den Abschluss von I/O, sollte man besser auf ein Event als auf einen Timer warten.
  • Timed waits unter Windows sind unweigerlich von der Granularität der Systemuhr betroffen.
  • Sleep(1) bedeutet nicht „wacht exakt nach 1 ms auf“.
  • Und selbst nach Ablauf des Timeouts wird der Thread zunächst nur ready – die sofortige Ausführung ist nicht garantiert.
  • Deshalb ist ein Design, das „eigentlich auf ein Ereignis wartet, aber mit einem Timer nachsieht“, sowohl bei Latenz als auch bei Energieverbrauch im Nachteil.
  • Timer sollte man sauber nur dort einsetzen, wo die Zeit selbst wirklich die Bedingung ist.

In der Praxis läuft das ungefähr auf Folgendes hinaus:

  • „Alle 5 Sekunden Metriken senden“ -> Aufgabe eines Timers
  • „Sobald Arbeit in die Queue kommt, sofort loslegen“ -> Aufgabe von Event / Semaphore / Condition Variable / WaitOnAddress
  • „Nach Abschluss von I/O weitermachen“ -> Aufgabe von Completion / Event
  • „Bei Stopp-Anforderung anhalten“ -> Aufgabe von stop event / Cancellation

2. Worin das Problem besteht

2.1 Timed waits sind an die Granularität der Systemuhr gebunden

Die Timeout-Präzision der Windows-Wait-Funktionen hängt von der Auflösung der Systemuhr ab. Dasselbe gilt für Sleep: Die angegebenen Millisekunden sind nicht garantiert „genau in dieser Länge“.

Wichtig ist hier: Dass man 1 ms angegeben hat, bedeutet nicht, dass man 1 ms später aufwacht.

Die Granularität der eigenen Umgebung prüfen

„In der Größenordnung von 15,6 ms“ ist eine allgemeine Aussage – es lohnt sich, den eigenen Wert selbst zu prüfen. Dafür gibt es zwei Möglichkeiten.

Die eine ist, GetSystemTimeAdjustment aufzurufen. Im zweiten Parameter lpTimeIncrement wird das Intervall, in dem das System die time-of-day clock aktualisiert, in Einheiten von 100 Nanosekunden zurückgegeben. Bei einer Größenordnung von 15,6 ms ergibt sich ein Wert im Bereich von 150.000.

#include <windows.h>
#include <cstdio>

int main()
{
    DWORD adjustment = 0;
    DWORD increment = 0;
    BOOL adjustmentDisabled = FALSE;

    if (!GetSystemTimeAdjustment(&adjustment, &increment, &adjustmentDisabled))
    {
        std::printf("GetSystemTimeAdjustment fehlgeschlagen. GetLastError=%lu\n", GetLastError());
        return 1;
    }

    // increment ist in 100ns-Einheiten, deshalb zur Anzeige in ms umrechnen
    std::printf("time increment = %lu (100ns) = %.4f ms\n",
                increment,
                increment / 10000.0);
    return 0;
}

Die andere Möglichkeit ist, ClockRes von Sysinternals auszuführen. Dieses kleine Tool ruft intern dieselbe Funktion GetSystemTimeAdjustment auf und zeigt lediglich die Auflösung der Systemuhr an, also die maximale timer resolution, die eine Anwendung erhalten kann. Wenn man es ohne eigenen Code prüfen möchte, geht das damit am schnellsten.

Laut Dokumentation ist lpTimeIncrement übrigens ein beim Start vom System festgelegter fester Wert, der sich während der Laufzeit nicht ändert. Dieser Wert dient also dazu, „die native Granularität dieser Umgebung“ zu ermitteln, nicht dazu, das Ergebnis eines Aufrufs von timeBeginPeriod zu messen. Darauf gehen wir in 6.3 ein.

2.2 Auch wenn die Frist erreicht ist, heißt das nicht sofortige Ausführung

Verzwickter wird es dadurch, dass der Thread nicht in dem Moment sofort läuft, in dem das Timeout abläuft.

Wie auch die Dokumentation zu Sleep festhält, wird der Thread nach Ablauf der Wartezeit lediglich ready, aber es gibt keine Garantie, dass er sofort CPU-Zeit bekommt und läuft. Er wird von anderen Threads, der Priorität, dem Idle-Zustand der CPU, DPC/ISR, Lock-Konkurrenz und Ähnlichem beeinflusst.

Ein kurzes Timer-Warten hat also mindestens zwei Stufen von Unsicherheit.

  1. Schon die Timeout-Bestimmung selbst wird von der Timer-Granularität gezogen
  2. Auch nach dem Timeout hängt der Ausführungsbeginn vom Scheduler ab

2.3 Sleep(1) bedeutet nicht ein 1-ms-Intervall

Sieht man Sleep(1), wirkt das schnell wie „eine Schleife, die alle 1 ms durchläuft“. Tatsächlich darf man es aber nicht so lesen.

while (!g_stop)
{
    Step();
    Sleep(1);
}

Die tatsächliche Wirkung dieser Schleife ist:

  • Die Ausführungszeit von Step() kommt jedes Mal hinzu
  • Die Wartezeit von Sleep(1) selbst wird von der Granularität gezogen
  • Auch nach dem Aufwachen läuft der Thread nicht garantiert sofort

3. Warum Event-Warten im Vorteil ist

3.1 Das Ende des Wartens wird nicht „Zeitablauf“, sondern „Signal“

Event-Warten ist deshalb im Vorteil, weil sich die Bedeutung des Wartens ändert.

Timer-Warten funktioniert so:

  • Auch wenn noch nichts passiert ist
  • wacht man auf, wenn eine feste Zeit vergangen ist
  • und prüft erst nach dem Aufwachen, „ob etwas passiert ist“

Event-Warten funktioniert so:

  • Die Seite, bei der etwas passiert ist, signalisiert
  • Sobald signalisiert wurde, ist das Warten erfüllt
  • Zum Zeitpunkt des Aufwachens steht der Grund schon fest

Im Diagramm wird sichtbar, dass sich die Art, wie das Warten endet, grundlegend unterscheidet.

event wait: wird geweckt, weil etwas passiert istdie Seite, bei der es passiert ist, signalisiertwartenbeim Aufwachen steht der Grund bereits festverarbeitentimer wait: wacht auf, weil die Zeit gekommen istneinjaAufwachen in Timer-GranularitätwartenIst etwas passiert?verarbeiten

Nur beim timer wait gibt es eine Schleife, die vergeblich zurückkehrt. Genau das macht sich sowohl bei der Latenz als auch beim Energieverbrauch bemerkbar.

3.2 Das Werkzeug danach wählen, worauf man wirklich wartet

Welches Werkzeug man tatsächlich wählt: Als ersten Anhaltspunkt reicht meist diese Tabelle.

Worauf man warten möchte Schlechtes Beispiel Erste Wahl
Dass Arbeit in die Queue kommt TryPop mit Sleep(1) event / semaphore
Dass I/O abgeschlossen wird Den Status mit einem Timer abfragen Event von overlapped I/O / IOCP
Dass eine Stopp-Anforderung eintrifft Alle 100 ms ein Stopp-Flag prüfen stop event / cancellation
Eine Wertänderung im selben Prozess while (flag == 0) Sleep(1) WaitOnAddress
Dass ein Zeitpunkt erreicht wird Das mit Gewalt auf ein Event abbilden timer / waitable timer

3.3 Auch ein Event ist keine Zauberei

Event-Warten ist in dem Sinn im Vorteil, dass man nicht in Timer-Granularität aufwachen muss, bedeutet aber nicht, dass der Thread in dem Moment, in dem signalisiert wird, garantiert ohne jede Verzögerung läuft.

Auch Event-Warten unterliegt folgenden Einflüssen:

  • Scheduler-Latenz
  • Thread-Priorität
  • Energiezustand der CPU
  • Lock-Konkurrenz
  • Page Faults
  • DPC/ISR

Zumindest aber lässt sich damit die unnötige Art des Wartens abschaffen, bei der der Thread „bis zum nächsten Timer Tick schläft“.

4. Typische Antipattern

4.1 Eine Queue mit Sleep(1) abfragen

Am häufigsten sieht man Folgendes.

for (;;)
{
    if (g_stop)
    {
        break;
    }

    WorkItem item;
    if (TryPop(item))
    {
        Process(item);
        continue;
    }

    Sleep(1);
}

Diese Schreibweise wirkt auf den ersten Blick einfach, hat aber drei Probleme.

  1. Der Thread wacht periodisch auf, auch wenn die Queue leer ist
  2. Die Latenz wird von der Timer-Granularität gezogen
  3. Auch beim Energieverbrauch ist das ein Nachteil

4.2 Den Zustand mit Thread.Sleep(1) / Task.Delay(1) überwachen

Denselben Geruch gibt es auch in C# / .NET.

while (!stoppingToken.IsCancellationRequested)
{
    if (_queue.TryDequeue(out WorkItem? item))
    {
        await ProcessAsync(item, stoppingToken);
        continue;
    }

    await Task.Delay(1, stoppingToken);
}

Auch wenn es dank async auf den ersten Blick sanft wirkt, ist der Kern des Designs Polling.

5. So repariert man es

5.1 Der Producer signalisiert beim Eintreffen

Wartet man auf das Eintreffen von Arbeit in einer Queue, ändert man das Design so, dass statt Polling der Producer signalisiert.

  • Der Producer fügt ein Element in die Queue ein
  • Unmittelbar nach dem Einfügen ruft er SetEvent auf
  • Der Consumer wartet mit WaitForSingleObject oder WaitForMultipleObjects
  • Nach dem Aufwachen leert er die Queue

5.2 Mit WaitForMultipleObjects gleichzeitig auf work und stop warten

Für einen einfachen Worker ist diese Form gut nachvollziehbar.

HANDLE waits[2] = { _stopEvent, _workEvent };  // index 0 = stop, index 1 = work

for (;;)
{
    // bWaitAll = FALSE, deshalb ist der Rückgabewert "der Index des zuerst signalisierten Handles"
    DWORD rc = WaitForMultipleObjects(2, waits, FALSE, INFINITE);

    // Fehler ist WAIT_FAILED ((DWORD)0xFFFFFFFF). Der Grund lässt sich nur über GetLastError ermitteln
    if (rc == WAIT_FAILED)
    {
        throw std::system_error(
            static_cast<int>(GetLastError()),
            std::system_category(),
            "WaitForMultipleObjects fehlgeschlagen.");
    }

    if (rc == WAIT_OBJECT_0)  // stop
    {
        return;
    }

    if (rc == WAIT_OBJECT_0 + 1)  // work
    {
        DrainQueue();
        continue;
    }

    // Bei einem INFINITE-Wait sollte man hier nicht ankommen
    // (WAIT_TIMEOUT oder WAIT_ABANDONED_0-Fälle). Nicht verschlucken, sondern abbrechen
    throw std::runtime_error("WaitForMultipleObjects returned an unexpected value.");
}

Die wichtigsten Punkte dieses Beispiels sind drei.

  • Sleep(1) ist verschwunden
  • Der Producer ruft beim Eintreffen eines Elements SetEvent auf
  • Der Worker wartet gleichzeitig auf stop und work

Zum Umgang mit dem Rückgabewert noch eine praktische Ergänzung, an der man leicht scheitert.

  • Ist bWaitAll gleich FALSE, liegt der Rückgabewert bei Erfolg im Bereich von WAIT_OBJECT_0 bis WAIT_OBJECT_0 + nCount - 1, und zieht man WAIT_OBJECT_0 davon ab, erhält man den Index im Array. Schreibt man nur die zwei Verzweigungen == WAIT_OBJECT_0 und != WAIT_OBJECT_0 + 1, bricht das in dem Moment, in dem man auf drei Handles erweitert
  • Werden mehrere gleichzeitig signalisiert, wird der Handle mit dem kleineren Index zurückgegeben. Im obigen Beispiel steht stop deshalb an Index 0, damit eine Stopp-Anforderung nicht übersehen wird
  • Ein Fehler wird nicht als Ausnahme, sondern über den Rückgabewert WAIT_FAILED ((DWORD)0xFFFFFFFF) signalisiert. Die Ursache lässt sich nur über GetLastError ermitteln. Fasst man rc != erwarteter Wert pauschal als „Fehler“ zusammen, gehen Ursachen wie ein bereits geschlossenes Handle oder fehlende SYNCHRONIZE-Rechte verloren
  • Mischt man einen Mutex unter die wartenden Objekte, kann auch WAIT_ABANDONED_0 zurückkommen. In diesem Beispiel wird nur auf Events gewartet, deshalb wird das als unerwartet behandelt

5.3 Innerhalb desselben Prozesses ist auch WaitOnAddress eine Option

Will man innerhalb desselben Prozesses lediglich darauf warten, „dass sich ein Wert ändert“, ist WaitOnAddress eine ziemlich starke Option. Der Aufwand, ein Event anzulegen, zu initialisieren und dafür zu sorgen, dass es nicht aus dem Takt mit dem Wert gerät, entfällt.

Als Gefühl für die Entscheidung lässt sich das etwa so zusammenfassen.

Aspekt event / semaphore / waitable object WaitOnAddress
Reichweite des Wartens Auch prozessübergreifend möglich. Kann benannt werden Nur innerhalb desselben Prozesses
Aufwecken durch SetEvent / ReleaseSemaphore usw. WakeByAddressSingle / WakeByAddressAll
Vorbereitung Erstellung eines Kernel-Objekts und Handle-Verwaltung nötig Es genügt eine Variable, auf die man wartet
Verfügbare Version Seit Langem verfügbar Ab Windows 8 / Windows Server 2012
Link Kernel32.lib Synchronization.lib

Bei der Verwendung gibt es drei Punkte, die man nicht übersehen sollte.

  1. Unbedingt im Paar mit WakeByAddressSingle oder WakeByAddressAll verwenden. Ruft die Seite, die den Wert ändert, das nicht auf, wacht der wartende Thread nicht auf. Weckt man nur einen Thread, nimmt man Single, weckt man alle, nimmt man All
  2. WaitOnAddress kann auch zurückkehren, ohne dass signalisiert wurde. Die Dokumentation nennt ausdrücklich die Möglichkeit eines vorzeitigen Aufwachens etwa bei niedrigem Speicher. Nach der Rückkehr muss man den Wert daher unbedingt erneut lesen, um zu prüfen, ob er sich wirklich geändert hat – geschrieben als while-Schleife
  3. Warten lässt sich auf eine Größe von 1, 2, 4 oder 8 Byte
  4. Das Flag muss atomar sein. WakeByAddressSingle weckt lediglich den wartenden Thread, macht den unmittelbar vorangegangenen Schreibvorgang aber weder atomar noch sichtbar. Liest und schreibt man eine schlichte Variable von beiden Seiten, ist das in C++ ein Datenwettlauf (undefiniertes Verhalten), und in einem optimierten Build kann es passieren, dass die Aktualisierung nicht sichtbar wird und der Thread trotz Aufweckens weiter blockiert bleibt. Die schreibende Seite gleicht man mit release, die lesende mit acquire ab
// Die wartende und die weckende Seite laufen auf verschiedenen Threads,
// deshalb muss das Flag unbedingt atomar sein.
// Liest und schreibt man ein schlichtes ULONG von beiden Seiten, ist das in C++
// ein Datenwettlauf (undefiniertes Verhalten); in einem optimierten Build
// kann der Wert im Register verbleiben, sodass die Aktualisierung nicht sichtbar wird
// und der Thread trotz Aufweckens weiter blockiert bleibt
std::atomic<ULONG> g_ready{ 0 };
static_assert(std::atomic<ULONG>::is_always_lock_free,
              "Wird an WaitOnAddress übergeben, muss also lock-free sein");

// Minimalform von "warten, bis g_ready ungleich 0 wird"
ULONG undesired = 0;
ULONG captured = g_ready.load(std::memory_order_acquire);

while (captured == undesired)
{
    // Kann vorzeitig zurückkehren, deshalb nach der Rückkehr immer erneut lesen
    WaitOnAddress(&g_ready, &undesired, sizeof(ULONG), INFINITE);
    captured = g_ready.load(std::memory_order_acquire);
}

Die Seite, die den Wert ändert, aktualisiert ihn und weckt anschließend.

// Mit release schreiben. So geschrieben, ist auch die vor dieser Zeile
// vorbereitete Daten (payload unten) für die mit acquire lesende Seite garantiert sichtbar.
// Die Reihenfolge garantiert dieser Store, nicht WakeByAddressSingle
// -- jenes weckt nur den wartenden Thread, macht den unmittelbar
// vorangegangenen Schreibvorgang aber weder atomar noch sichtbar
g_payload = ...;                                  // Daten, die zusammen übergeben werden sollen
g_ready.store(1, std::memory_order_release);
WakeByAddressSingle(&g_ready);

6. Fälle, in denen man dennoch einen Timer verwendet

6.1 Wenn die Zeit selbst die Bedingung ist

Natürlich gibt es Fälle, in denen ein Timer angebracht ist.

  • Alle 5 Sekunden Metriken senden
  • Nach 200 ms einen Retry versuchen
  • Jede Minute den Cache aufräumen
  • Bis zu einem Fristzeitpunkt warten und dann als Timeout behandeln

Hier ist das, worauf man warten möchte, wirklich Zeit.

6.2 Einen waitable timer verwenden

Möchte man unter Windows auf „die Zeit selbst“ warten, ist die Verwendung eines waitable timer klarer in der Absicht, als Sleep-Aufrufe achtlos aneinanderzureihen.

6.3 timeBeginPeriod nicht zur Gewohnheit machen

Stört einen die Präzision kurzer Timer-Waits, ist die Versuchung groß, timeBeginPeriod(1) hinzuzufügen. Das sollte man aber nicht zur Standardlösung machen.

Dafür gibt es drei Gründe.

  1. Es hat Kosten bei Energieverbrauch und Leistung
  2. Unter aktuellen Windows-Versionen ist das Verhalten etwas komplexer
  3. Es behebt oft nicht die eigentliche Ursache

7. Checkliste für die Code-Review

  • Baut man mit Sleep(1) / Thread.Sleep(1) / Task.Delay(1) eine Nachschau-Schleife?
  • Fragt man mit einem Timer ab, obwohl man eigentlich auf das Eintreffen einer Queue, den Abschluss von I/O oder eine Stopp-Anforderung wartet?
  • Ist das Design so gestaltet, dass die Producer- bzw. Completion-Seite signalisieren kann?
  • Lassen sich stop und work in einem einzigen Wait zusammen abwarten?
  • Lässt sich eine Wertänderung im selben Prozess mit WaitOnAddress schreiben?
  • Ist an den Stellen, an denen ein Timer verwendet wird, das, worauf man wirklich warten möchte, tatsächlich „Zeit“?

8. Zusammenfassung

Ein Design, das unter Windows mit einem kurzen Timer-Warten „von Zeit zu Zeit nachschaut“, ist unweigerlich von der Timer-Granularität und dem Scheduler betroffen. Deshalb ist Sleep(1) oder ein kurzes Timeout kein so präzises Warten, wie es aussieht.

Ist das, worauf man wirklich warten möchte, dagegen ein „Ereignis“ – das Eintreffen von Arbeit, der Abschluss von I/O, eine Stopp-Anforderung, eine Zustandsänderung –, ist Event-Warten die natürlichere Wahl.

Zusammengefasst läuft alles auf diese eine Zeile hinaus:

Wartet man auf Zeit, nimmt man einen Timer; wartet man auf ein Ereignis, nimmt man ein Event.

Allein diese klare Abgrenzung wirkt sich so aus:

  • Die Latenz wird besser vorhersehbar
  • Unnötige periodische Aufwachvorgänge nehmen ab
  • Auch beim Energieverbrauch wird es besser
  • Die Absicht des Codes wird deutlicher

9. Quellen

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.

Windows-App-Entwicklung

Timer-Polling in Windows-Anwendungen und -Diensten durch ein Event-Driven-Design zu ersetzen, ist ein Entwurf, der direkt die Implementierungsqualität in der Windows-Anwendungsentwicklung betrifft.

Häufige Fragen

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

Warum wacht Sleep(1) unter Windows nicht exakt nach 1 Millisekunde auf?
Die Timeout-Präzision der timed waits unter Windows hängt von der Auflösung der Systemuhr ab, und unter typischen Einstellungen wird oft eine platform timer resolution in der Größenordnung von 15,6 Millisekunden zugrunde gelegt. Zudem wird ein Thread nach Ablauf der Wartezeit nur ready, ohne Garantie, sofort CPU-Zeit zu bekommen und zu laufen. Er wird von anderen Threads, der Priorität, dem Idle-Zustand der CPU, DPC/ISR und Lock-Konkurrenz beeinflusst. Ein kurzes Timer-Warten hat also mindestens zwei Stufen von Unsicherheit: Die Timeout-Bestimmung selbst wird von der Timer-Granularität gezogen, und der tatsächliche Ausführungsbeginn nach dem Timeout hängt vom Scheduler ab.
Was ist das Problem daran, eine Queue mit Sleep(1) oder Task.Delay(1) abzufragen?
Es gibt drei Probleme: Der Thread wacht periodisch auf, auch wenn die Queue leer ist, die Latenz wird von der Timer-Granularität gezogen, und es kostet unnötig Energie. Auch die await-Task.Delay(1)-Schleife in C# wirkt auf den ersten Blick sanft, ist aber im Kern Polling. Die Lösung besteht darin, dass der Producer unmittelbar nach dem Einfügen eines Elements in die Queue SetEvent aufruft und der Consumer mit WaitForSingleObject oder WaitForMultipleObjects wartet. Wartet man stop- und work-Event gemeinsam in einem einzigen Wait ab, reagiert man auch auf Stopp-Anforderungen sofort.
Wie unterscheidet man, wann man Timer- und wann Event-Warten einsetzt?
Die Faustregel lautet: Wartet man auf Zeit, nimmt man einen Timer; wartet man auf ein Ereignis, nimmt man ein Event. Verarbeitung, bei der die Zeit selbst die Bedingung ist – etwa alle 5 Sekunden Metriken senden –, ist die Aufgabe eines waitable timer. Das Eintreffen von Arbeit in einer Queue eignet sich für Event oder Semaphore, der Abschluss von I/O für das Event von Overlapped I/O oder IOCP, eine Stopp-Anforderung für ein stop event oder Cancellation, und eine Wertänderung innerhalb desselben Prozesses für WaitOnAddress. Ist diese Abgrenzung klar, wird die Latenz besser vorhersehbar, unnötige periodische Aufwachvorgänge nehmen ab, und die Absicht des Codes wird deutlicher.
Löst timeBeginPeriod(1) das Problem nicht einfach, indem es die Timer-Präzision erhöht?
Man sollte es nicht zur Standardlösung machen. Es gibt drei Gründe: Es hat Kosten bei Energieverbrauch und Leistung, das Verhalten ist unter aktuellen Windows-Versionen etwas komplexer geworden, und es behebt oft nicht die eigentliche Ursache. Wenn das, worauf man wirklich wartet, ein Ereignis wie das Eintreffen von Arbeit oder der Abschluss von I/O ist, ist es sinnvoller, das Design in ein Event-Driven-Design zu ändern, bei dem die auslösende Seite signalisiert, statt die Timer-Präzision zu erhöhen – das ist bei Latenz, CPU und Energieverbrauch gleichermaßen die direktere Lösung.

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