MSDN Magazin > Home > Ausgaben > 2007 > June >  CONCURRENCY: Neue Synchronisierungsprimitive in...
CONCURRENCY
Neue Synchronisierungsprimitive in Windows Vista
Robert Saccone and Alexander Taskov
Codedownload verfügbar unter: VistaSynchronization2007_06.exe (174 KB)
Browse the Code Online

Themen in diesem Artikel:
  • Bedingungsvariable
  • SRW-Sperre (Slim Reader/Writer)
  • Einmalige Initialisierung
  • Sicherheitsprüfungssperre
In diesem Artikel werden folgende Technologien verwendet:
Windows Vista, C++
Windows Vista umfasst eine Vielzahl von neuen und aufregenden Technologien für Entwickler, einschließlich Windows® Presentation Foundation, Windows Communication Foundation und Windows Workflow Foundation. In Windows Vista™ wurden so viele neue .NET-freundliche Technologien eingeführt, dass es schwierig ist, all die neuen Features und Funktionen, die Entwickler systemeigener C/C++-Anwendungen nutzen können, zu überblicken.
In diesem Artikel werden einige der neuen Funktionen in Windows Vista besprochen, die für Entwickler systemeigener C/C++-Anwendungen von Bedeutung sind. Der Schwerpunkt liegt dabei auf verschiedenen Features zur Threadsynchronisierung, die im neuen Betriebssystem enthalten sind: Bedingungsvariable, SRW-Sperren und einmalige Initialisierung.

Bedingungsvariable
Die Bedingungsvariable ist bereits seit einiger Zeit in anderen Threadingbibliotheken verfügbar und wird im Windows SDK schmerzlich vermisst. Eine Bedingungsvariable wird zur Synchronisierung einer Gruppe von Threads verwendet, basierend auf dem Ergebnis eines Bedingungstests. Dies kann mithilfe einer Kombination aus vorhandenen Synchronisierungskonstrukten ausgeführt werden. Die Bedingungsvariable ist jedoch in der Lage, eine angeforderte Sperre aufzuheben und in einem einzigen atomaren Vorgang in den Ruhezustand zu wechseln. Außerdem bietet sie eine viel klarere und weniger fehleranfällige Möglichkeit zur Implementierung des gewünschten Verhaltens.
Das Windows SDK für Windows Vista (verfügbar als Download) macht Bedingungsvariable mit der Struktur CONDITION_VARIABLE verfügbar. Sie können die Struktur mithilfe der InitializeConditionVariable-Funktion einrichten. Es gibt keine Funktion zur Bereinigung oder Zerstörung der Struktur CONDITION_VARIABLE, da sie von der zugrunde liegenden Implementierung nicht benötigt wird.
Mithilfe der Funktionen „SleepConditionVariableCS“ (bei Verwendung eines kritischen Abschnitts) oder „SleepConditionVariableSRW“ (bei Verwendung einer Slim Reader-/Writer-Sperre) können Sie festlegen, dass Threads auf eine Bedingungsvariable warten. Diese Threads im Ruhezustand werden freigegeben, wenn ein anderer Thread die WakeConditionVariable oder WakeAllConditionVariable aufruft, je nachdem, ob der aufrufende Thread einen Thread freigeben will oder alle Threads, die auf die Bedingungsvariable warten.
Ein Szenario, in dem Bedingungsvariable nützlich sind, ist das häufig anzutreffende Erzeuger-Verbraucher-Problem. Dieses klassische Beispiel bezieht sich auf ein Szenario, in dem ein Erzeuger Daten erzeugt und in einem Puffer ablegt, während ein Verbraucher Teile der Daten zur Verarbeitung aus dem Puffer abruft. Das Problem besteht darin, sicherzustellen, dass der Erzeuger nicht versucht, einem vollständig gefüllten Puffer Daten hinzuzufügen, während der Verbraucher nicht versuchen darf, Daten aus einem leeren Puffer zu ziehen. Wir werden dieses Szenario durchgehen, um zu demonstrieren, wie die Bedingungsvariable von Nutzen sein kann.
Für dieses Beispiel erstellen wir einen einzelnen Erzeugerthread, der numerische Daten an eine freigegebene Warteschlange sendet. Im Anschluss erstellen wir fünf Verbraucherthreads. Jeder Verbraucherthread entfernt ein Element aus der Warteschlange und verarbeitet es. Nach Abschluss der Verarbeitung des aktuellen Datenelements durchläuft der Verbraucherthread eine Schleife und wiederholt den Prozess unbegrenzt.
In früheren Versionen von Windows konnte das Erzeuger-Verbraucher-Problem mit einer Kombination aus Win32-Ereignissen und einem kritischen Abschnitt gelöst werden. Der kritische Abschnitt schützt die freigegebene Ressource vor gleichzeitigem Zugriff und ein Ereignis signalisiert, wann die Ressource für den Verbraucher verfügbar ist.
In unserem ersten Versuch zur Lösung dieses Problems verwenden wir eine STL-Liste (Standardvorlagenbibliothek) von Ganzzahlen als freigegebene Ressource. Da die Liste sich dynamisch erweitert, benötigen wir kein Ereignis, das signalisiert, wenn die Liste nicht voll ist; wir müssen lediglich wissen, wenn sie nicht leer ist, damit der Verbraucher weiß, dass es etwas zu verbrauchen gibt. (Wenn Sie ein Array fester Größe für die freigegebene Warteschlange verwenden würden, benötigten Sie ein „Nicht-voll-Ereignis“, um sicherzustellen, dass Sie die Grenzen des Puffers nicht überschreiben.) Anschließend deklarieren und initialisieren wir ein Objekt CRITICAL_SECTION und ein automatisches Zurücksetzungsereignis, mit dessen Hilfe angezeigt wird, wenn die Liste nicht leer ist.
Der Erzeugerthread (siehe Abbildung 1) versucht zunächst, den kritischen Abschnitt anzufordern, und fügt im Erfolgsfall einen Ganzzahlwert am Ende der freigegebenen Liste ein. Der Thread gibt dann den kritischen Abschnitt frei und legt das Nicht-leer-Ereignis fest. Nur einer der Threads, die auf dieses Ereignis warten, wird freigegeben, da wir ein automatisches Zurücksetzungsereignis verwenden. Der Verbraucherthread (siehe Abbildung 1) prüft dann, ob die Warteschlange leer ist. Wenn die Warteschlange nicht leer ist, entfernt der Thread ein Element und gibt den kritischen Abschnitt frei. Ist die Warteschlange leer, wechselt der Verbraucherthread wieder in den Ruhezustand und wartet auf das Nicht-leer-Ereignis. Während der erste Verbraucherthread mit der Verarbeitung des aus der Warteschlange entfernten Elements beschäftigt ist, kann der Erzeuger einen weiteren Verbraucherthread reaktivieren, um die nächste Arbeitskomponente zu erfassen und die Warteschlange in Bewegung zu halten.

Erzeuger
unsigned _stdcall ProducerFunc(void *pParam)
{
    for (unsigned int i = 0;i < g_uiIterations;i++)
    {
        EnterCriticalSection(&g_csLock);
        // Produce work
        g_listWork.push_back(i++);
        LeaveCriticalSection(&g_csLock);
        SetEvent(g_hNotEmpty);
        Sleep(g_uiProducerDelay); // Simulating work
    }
    return 0;
}

Empfänger
while (true)
{
    EnterCriticalSection(&g_csLock);

    if (g_listWork.empty())
    {
        LeaveCriticalSection(&g_csLock);
        WaitForSingleObject(g_hNotEmpty,INFINITE);
    }
    else
    {
        i = g_listWork.front();
        g_listWork.pop_front();

        LeaveCriticalSection(&g_csLock);

        wcout << L"Thread " << iThread << L" Consumed: " << i << endl;
        Sleep(g_uiConsumerDelay); // Simulating work
    }
}
Diese Lösung sollte im Allgemeinen funktionieren, obgleich für diese Implementierung einige Einschränkungen gelten. Zum einen kann es knifflig sein, die Synchronisierungsobjekte ordnungsgemäß zu initialisieren. Wir mussten beispielsweise entscheiden, ob der Erzeuger nur einen Verbraucherthread oder alle reaktivieren soll, wenn Daten in die Liste verschoben werden. Dies wird davon gesteuert, wie wir das Ereignis initialisieren. Wenn wir ein automatisches Zurücksetzungsereignis verwenden, wird nur ein Thread freigegeben. Wenn wir jedoch alle Threads reaktivieren wollen, würden wir ein manuelles Zurücksetzungsereignis verwenden und daran denken müssen, ResetEvent zum richtigen Zeitpunkt aufzurufen.
Es ist unbedingt sicherzustellen, dass der Verbraucherthread, wenn die Warteschlange leer ist, den kritischen Abschnitt freigibt, bevor er auf das Nicht-leer-Ereignis wartet. Außerdem müssen wir sicherstellen, dass wir nicht irrtümlicherweise PulseEvent im Erzeugerthread verwenden, um das Nicht-leer-Ereignis zu signalisieren, da hierdurch eine Racebedingung entstehen würde. Das Problem tritt dann auf, wenn der Verbraucherthread sofort nach der Freigabe des kritischen Abschnitts verdrängt wird, jedoch bevor er WaitForSingleObject aufruft und der Erzeugerthread dann PulseEvent aufruft. PulseEvent ist nicht „klebrig“ und gibt nur die Threads frei, die derzeit auf das Ereignis warten. Wenn der verdrängte Thread wiederaufgenommen wird, wird das Ereignis nicht signalisiert und die Reaktivierung geht verloren.
Mit Bedingungsvariablen ist das Erstellen der richtigen Lösung wesentlich einfacher. Der kritische Abschnitt wird zwar nach wie vor verwendet, das Nicht-leer-Ereignis wird jedoch durch eine Bedingungsvariable ersetzt. Wir initialisieren die Bedingungsvariable in der Hauptfunktion durch Aufrufen von InitializeConditionVariable mit einem Zeiger auf unsere Struktur CONDITION_VARIABLE.
Der Verbraucherthread in Abbildung 2 betritt den kritischen Abschnitt und prüft, ob die Warteschlange leer ist. Ist dies der Fall, wird SleepConditionVariableCS aufgerufen. Diese Funktion gibt den kritischen Abschnitt frei und versetzt den Thread in einem atomaren Vorgang in den Ruhezustand. So wird die Racebedingung eliminiert, die eintreten kann, wenn der Verbraucher zwischen diesen zwei Aufgaben verdrängt wird. Die SleepConditionVariableCS-Funktion übernimmt auch einen Timeout-Parameter, der es uns ermöglicht, andere Aufgaben auszuführen, wenn wir nicht zu lange warten möchten.

Erzeuger

for (unsigned int i = 0;i < g_uiIterations;i++)
{
    EnterCriticalSection(&g_csLock);

    // Produce work
    g_listWork.push_back(i);

    LeaveCriticalSection(&g_csLock);

    WakeConditionVariable(&g_condNotEmpty);

    Sleep(g_uiProducerDelay);
}

Empfänger
while (true)
{
    EnterCriticalSection(&g_csLock);

    while (g_listWork.empty())
    {
        SleepConditionVariableCS(&g_condNotEmpty,&g_csLock,INFINITE);
    }

    i = g_listWork.front();
    g_listWork.pop_front();

    LeaveCriticalSection(&g_csLock);

    wcout << L"Thread " << iThread << L" Consumed: " << i << endl;
    Sleep(g_uiConsumerDelay); // Simulating work
}
Nach Signalisierung der Bedingungsvariable wird der Thread reaktiviert und sperrt erneut automatisch den kritischen Abschnitt vor der Rückgabe der SleepConditionVariableCS-Funktion. Der Verbraucherthread durchläuft dann eine Schleife und prüft erneut, ob die Warteschlange leer ist. Es ist sehr wichtig, die Bedingung erneut zu testen, wenn der Thread freigegeben wurde, da es möglich ist, dass ihr Status unmittelbar vor der Freigabe des Verbrauchers von einem anderen Thread geändert wurde. Darüber hinaus neigt die Bedingungsvariable zu falschen Reaktivierungen und könnte daher ausgeführt werden, bevor sich die Bedingung geändert hat. Wir entfernen dann das Arbeitselement aus der Warteschlange und geben den kritischen Abschnitt frei.
Der Erzeugerthread in Abbildung 2 betritt als Erstes den kritischen Abschnitt. Ist der Erzeugerthread im Besitz der Sperre, verschiebt er ein neues Arbeitselement in die Warteschlange und gibt dann den kritischen Abschnitt frei. Jetzt kann er einen Verbraucherthread freigeben, indem er WakeConditionVariable aufruft. Diese Funktion gibt nur einen Thread frei, genau wie die Lösung, die keine Bedingungsvariable verwendet. Wenn wir alle Verbraucherthreads freigeben möchten, müssten wir die WakeAllConditionVariable-Funktion aufrufen. Die Syntax der Bedingungsvariablen verdeutlicht, wie der Thread übergeben wird, da die Namen der Funktionen auf ihren Zweck hinweisen. Durch die Verwendung von Ereignissen wächst die Fehleranfälligkeit, da das gewünschte Verhalten nicht mit Funktionen zum Signalisieren von Ereignissen angegeben wird, sondern wenn das Ereignis initialisiert wird.
Wenn der Erzeugerthread unmittelbar nach der Freigabe des kritischen Abschnitts, jedoch vor dem Aufruf der Wake-Funktion verdrängt wird, wird einem anderen Thread die Möglichkeit eröffnet, den Status der Warteschlange zu ändern, bevor der Verbraucherthread freigegeben wird. Aus diesem Grund ist es erforderlich, dass der Verbraucherthread die Bedingung nochmals prüft, wenn er freigegeben wird.
Bedingungsvariable sind in einigen Szenarios außerdem effizienter. Die APIs „SleepConditionVariableCS“ und „SleepConditionVariableSRW“ versuchen, Trips in den Kernelmodus nach Möglichkeit zu vermeiden. Bei dem Beispiel, in dem keine Bedingungsvariablen verwendet werden, fällt jedoch mindestens ein Roundtrip in den Kernelmodus an, wenn WaitForSingleObject aufgerufen wird.
Daher bietet die Implementierung der systemeigenen Bedingungsvariablen viele Vorteile gegenüber Implementierungen der Marke „Eigenbau“, und das ohne Zugeständnisse an die Flexibilität. Der resultierende Code ist einfacher und leichter lesbar. Außerdem kann sich die Leistung verbessern. Daher sinkt die Wahrscheinlichkeit der Einschleusung vieler kleiner Bugs, die mitunter sehr schwer zu entdecken sind.

SRW-Sperre (Slim Reader/Writer)
Eine Reader-/Writer-Sperre wird dazu verwendet, Datenelemente zu schützen, auf die mehrere Leser gleichzeitig zugreifen können sollen, im Fall eines Updates jedoch nur der Schreiber. Diese Sperren eignen sich normalerweise am besten für Szenarios, in denen Daten oft gelesen und selten aktualisiert werden müssen. Die korrekte Verwendung von Reader-/Writer-Sperren sollte zu besserer Skalierbarkeit führen.
Reader-/Writer-Sperren sind bereits seit einiger Zeit bekannt. Vor der Veröffentlichung von Windows Vista gab es jedoch beim Schreiben von systemeigenem Code im Benutzermodus in Windows nur eine Möglichkeit, wenn Sie eine Reader-/Writer-Sperre benötigten: Sie mussten Ihre eigene Sperre schreiben oder eine Implementierung aus einem Lehrbuch anpassen. Windows Vista enthält eine Art Reader-/Writer-Sperre, die als SRW-Sperre (Slim Reader/Writer) bezeichnet wird. Wir wollen uns mit den Features dieses neuen Synchronisierungsprimitivs befassen, die verfügbaren APIs untersuchen und die Leistung mit einer alternativen Implementierung vergleichen, die mithilfe von standardmäßigen kritischen Abschnitten in Win32®, Semaphoren und Ereignissen erstellt wird.
Die SRW-Sperre ist schnell und (wie der Name andeutet) von geringer Größe und behält bei der Verwendung ihre Ressourceneffizienz bei. Sie baut auf dem Windows-Kernel-Ereignismechanismus auf, der eingeführt wurde, um Probleme der Ressourcenauslastung zu lösen, die auftreten können, wenn Anwendungen zahlreiche Objekte vom Typ CRITICAL_SECTION verwenden. Einige Reader-/Writer-Sperren sind so konstruiert, dass sie Lesern den Vorzug vor Schreibern geben (oder umgekehrt). Bei der SRW-Sperre ist dies jedoch nicht der Fall. Wenn Ihre Anwendung daher erfordert, dass Datenupdates Priorität gegenüber Datenlesevorgängen haben, sollten Sie erwägen, eine andere Reader-/Writer-Sperre einzusetzen, die Schreibern den Vorzug gibt. Bevor Sie jedoch Ihre eigene Sperre schreiben, sollten Sie die SRW-Sperre ausprobieren, um zu prüfen, wie sie sich im Kontext Ihrer Anwendung verhält.
Zwei weitere Features, die mitunter von Reader-/Writer-Sperren unterstützt werden, sind die Fähigkeit, die Sperre rekursiv anzufordern, und die Fähigkeit zur Heraufstufung (oder Herabstufung) des Zugriffs, der einem Thread bereits für die Sperre gewährt wurde. Zunächst eine Anmerkung im Hinblick auf rekursive Anforderungen: Wenn die Sperrungsrichtlinie, die Sie für Ihre Anwendung entworfen haben, erfordert, dass Synchronisierungsobjekte rekursiv angefordert werden, handelt es sich vermutlich um ein Warnzeichen dafür, dass Sie Ihre Sperrungsrichtlinie überprüfen sollten, um die Rekursion zu eliminieren. Zu dieser Überzeugung sind wir gekommen, da die mehrfache Ausführung der Sperrenanforderung und des Versionscodes zusätzlichen Overhead verursacht. Ein vielleicht noch wichtigeres Argument ist, dass die Gewährleistung des richtigen Gleichgewichts zwischen Sperrenfreigaben und Sperrenanforderungen sich oft als schwierig erweist.
Die SRW-Sperre bietet keine Unterstützung für die rekursive Anforderung. Diese Unterstützung würde zusätzlichen Overhead verursachen, da die Threadzählung nachverfolgt werden müsste, um die Fehlerfreiheit zu gewährleisten. SRW-Sperren bieten ebenfalls keine Unterstützung für das Heraufstufen vom gemeinsamen Zugriff auf den exklusiven Zugriff. Gleiches gilt für das Herabstufen (ein seltenerer Vorgang) vom exklusiven auf den gemeinsamen Zugriff. Die Unterstützung der Fähigkeit zum Heraufstufen hätte wahrscheinlich eine unannehmbare Erhöhung der Komplexität und zusätzlichen Overhead zur Folge, der sich sogar auf die gebräuchlichen Fälle des Codes für gemeinsamen und exklusiven Zugriff in der Sperre auswirken würde. Dafür wäre es ebenfalls erforderlich, die Richtlinie in Bezug darauf zu definieren, wie die wartenden Leser, Schreiber und die auf die Heraufstufung wartenden Leser ausgewählt werden sollen, was gegen das grundlegende Designziel der Nichtbevorzugung verstoßen würde.
Der erste Schritt bei der Verwendung einer SRW-Sperre besteht darin, eine Struktur SRWLOCK zu deklarieren und mithilfe von InitializeSRWLock zu initialisieren:
VOID WINAPI InitializeSRWLock(PSRWLOCK SRWLock);
Die SRW-Sperre bricht mit dem üblichen Win32-Muster, bei dem jedes Objekt über eine Initialisierungs- und Bereinigungsfunktion verfügt. Nach Verwendung einer SRW-Sperre gibt es keine Bereinigungsfunktion, die aufgerufen werden muss.
Nach der Initialisierung sind die folgenden grundlegenden APIs verfügbar:
VOID WINAPI AcquireSRWLockExclusive(PSRWLOCK SRWLock);

VOID WINAPI ReleaseSRWLockExclusive(PSRWLOCK SRWLock);

VOID WINAPI AcquireSRWLockShared(PSRWLOCK SRWLock);

VOID WINAPI ReleaseSRWLockShared(PSRWLOCK SRWLock);
Die AcquireSRWLockExclusive-Funktion fordert (wie der Name impliziert) die Sperre exklusiv für den Aufrufer an. Wenn der exklusive Zugriff gewährt wurde, werden alle anderen Threads, die den Zugriff (gleich welcher Art) fordern, so lange blockiert, bis die Sperre mithilfe der komplementären ReleaseSRWLockExclusive-Funktion freigegeben wird. Mit AcquireSRWLockShared wird die Sperre dagegen mit gemeinsamem Zugriff angefordert. In diesem Fall müssen andere Threads, die ebenfalls den gemeinsamen Zugriff fordern, nicht warten, bis die Sperre freigegeben oder von einem anderen Thread mit gemeinsamem Zugriff angefordert wurde.
Slim Reader-/Writer-Sperren können mit Bedingungsvariablen verwendet werden. Hierzu wird die SleepConditionVariableSRW-Funktion verwendet:
BOOL WINAPI SleepConditionVariableSRW(
  PCONDITION_VARIABLE ConditionVariable,
  PSRWLOCK SRWLock,
  DWORD dwMilliseconds,
  ULONG Flags
);
SleepConditionVariableSRW gibt eine SRW-Sperre frei und wartet auf die angegebene Bedingungsvariable als atomarer Vorgang. Die Funktion gibt nichts zurück, bis die Bedingungsvariable signalisiert wurde oder der unter dwMilliseconds angegebene Timeoutwert abgelaufen ist. Wenn für dwMilliseconds der Wert INFINITE festgelegt wurde, findet kein Timeout für die Funktion statt. Wenn für den Flags-Parameter CONDITION_VARIABLE_LOCKMODE_SHARED angegeben ist, nimmt die Funktion an, dass für die SRW-Sperre der gemeinsame Zugriff gilt; andernfalls wird der exklusive Zugriff angenommen, und nach erfolgreicher Rückgabe wird die Sperre wieder mit dem angegebenen Zugriff angefordert.
Beim Experimentieren mit diesen APIs entdeckten wir einige Probleme, die zusätzliche Aufmerksamkeit beim Programmieren erfordern. Beachten Sie, dass keine der APIs für das Anfordern oder Freigeben einer Sperre ein Ergebnis zurückgibt. Wenn die SRW-Sperre derzeit nicht im Besitz eines Threads ist und es erfolgt ein Aufruf an ReleaseSRWLockShared oder ReleaseSRWLockExclusive, wird eine strukturierte Ausnahme STATUS_RESOURCE_NOT_OWNED zurückgegeben. Dies ist praktisch, da der Fehler in einer für den Entwickler offensichtlichen Weise deutlich wird.
An früherer Stelle wurde erwähnt, dass das rekursive Anfordern der Sperre nicht unterstützt wird. Der Versuch, rekursiv erneut exklusiven Zugriff anzufordern, führt dazu, dass der zweite Aufruf an AcquireSRWExclusive nie zurückgegeben wird, da er sich selbst blockiert, wobei der Thread darin blockiert bleibt und auf die Freigabe der Sperre wartet. Sie müssten wahrscheinlich einen Debugger an den Prozess anhängen, um die Bedingung zu sehen und herauszufinden, was vor sich geht. Der Aufruf von AcquireSRWShared von einem Thread, der bereits gemeinsamen Zugriff angefordert hat, führt ebenfalls zu einem Deadlock, wenn ein anderer Thread zwischen den Aufrufen von AcquireSRWShared versucht hat, die Sperre exklusiv anzufordern.
Insbesondere ist darauf zu achten, dass die korrekten Funktionspaare für Anfordern/Freigeben verwendet werden – durch falsches Zuordnen von AcquireSRWLockExclusive zu ReleaseSRWLockShared (oder umgekehrt) werden keine Ausnahmen ausgelöst. Die Auslösung von Ausnahmen in diesen Fehlerszenarios wäre nützlich gewesen, die Entdeckung der Fehler hätte jedoch möglicherweise einen unerwünschten Ressourcen- und Performance-Overhead verursacht.
Der Beispielcode für diesen Artikel umfasst die Quelle für ein Programm namens ReaderWriterExample, das das Experimentieren mit verschiedenen Implementierungen von Reader-/Writer-Sperren ermöglicht. Die Sperrentypen, die vom Programm unterstützt werden, sind die SRW-Sperre, zwei Varianten einer Reader-/Writer-Sperre, die wir implementiert haben, und ein kritischer Abschnitt. Die benutzerdefinierten Reader-/Writer-Sperren wurden erstellt mithilfe eines kritischen Abschnitts in Win32, um die Daten der Sperre zu schützen, eines Ereignisses zur Signalisierung wartender Leser und eines Semaphors für wartende Schreiber. Der Unterschied zwischen den beiden benutzerdefinierten Sperren besteht darin, dass die eine Schreiber und die andere weder Leser noch Schreiber bevorzugt.
Alle unsere Einzeltests wurden unter Verwendung einer 64-Bit-Version von Windows Vista auf einem System mit zwei Intel Xeon-Dual-Core-Prozessoren mit 3,20 GHz durchgeführt. Für diese Tests wurde Hyperthreading im System deaktiviert.
Test 1 umfasste 2.000.000 Iterationen jedes Threads, wobei die Arbeit während des Haltens der Sperren auf ein Minimum beschränkt war. Daher spiegeln die Ergebnisse den tatsächlichen Aufwand für Anforderung und Freigabe der Sperre wider. Die Ergebnisse sind in Abbildung 3 zusammengefasst.

Sperrentyp 1 Leser 1 Schreiber 2 Leser/ 2 Schreiber 3 Leser/ 1 Schreiber 4 Leser 4 Schreiber
SRW-Sperre 0.126 0.119 0.589 0.667 0.871 0.517
Benutzerdefinierte R/W-Sperre – Keine Bevorzugung 1.238 0.257 27.095 4.076 2.466 53.567
Benutzerdefinierte R/W-Sperre – Bevorzugt Schreiber 1.228 0.260 31.306 6.083 2.307 53.567
Kritischer Abschnitt 0.134 0.133 1.084 1.021 1.036 1.009
Einige Ergebnisse sind sehr interessant. Bei Verwendung nur eines Leserthreads oder eines Schreiberthreads (die ersten zwei Spalten der Ergebnisse) gibt es keine Sperrenkonflikte, und die Leistung der SRW-Sperre kommt der des kritischen Abschnitts sehr nahe. Werden vier Schreiberthreads verwendet, die alle um den exklusiven Zugriff auf die Sperre konkurrieren, benötigt die SRW-Sperre nur etwa halb so viel Zeit wie der kritische Abschnitt. Wird die SRW-Sperre im exklusiven Modus verwendet, scheint sie besser zu funktionieren als der kritische Abschnitt und könnte als Ersatz in Betracht gezogen werden. Beachten Sie, dass die SRW-Sperre in den Spalten, die mit „2 Leser/2 Schreiber“ und „3 Leser/1 Schreiber“ bezeichnet sind, im Vergleich zu einem kritischen Abschnitt eine wesentlich schnellere Leistung zeigt. Dies veranschaulicht den Vorteil einer Reader-/Writer-Sperre, die Datenlesern das parallele Arbeiten ermöglicht.
Was ist nun über unsere eigenen Reader-/Writer-Sperren zu sagen? Ihre Leistung fällt gegenüber den beiden integrierten Windows-Sperren erheblich ab; besonders gilt dies für den Betrieb unter starker Auslastung aufgrund der Konkurrenz von Lesern und Schreibern. Werfen Sie jedoch einen Blick auf die Ergebnisse für „2 Leser/2 Schreiber“ und „3 Leser/1 Schreiber“. Beachten Sie, dass die Richtlinie der Sperre das Gesamtverhalten und die Leistung beeinflusst. Die Sperre, die Schreibern den Vorzug gab, arbeitete langsamer als die Sperre, die weder Leser noch Schreiber bevorzugte. Dies liegt daran, dass der Parallelismus verringert wurde, um Schreibern den Vorzug zu geben, wenn sie auf die Aktualisierung warteten. Selbstverständlich besteht das Ziel oft darin sicherzustellen, dass die Leser die neuesten Daten sehen, daher ist die Verwendung einer Sperre, die Schreiber bevorzugt, in diesem Fall die richtige Entscheidung.
Ein weiteres Problem mit den benutzerdefinierten Sperren wird offensichtlich, wenn vier Schreiber um die Sperre konkurrieren. Warum fällt die Leistung derart stark ab, wenn die Konkurrenz um den exklusiven Zugriff groß ist? Das Problem besteht darin, dass jeder Thread um den Zugriff auf den kritischen Abschnitt konkurriert, der die internen Daten schützt. Konkurrenz um den kritischen Abschnitt führt zu einem Trip in den Kernelmodus, wobei der Thread auf dem Ereignis in den Ruhezustand versetzt wird. Wenn der kritische Abschnitt betreten wurde, wird der Status der Sperre untersucht, um festzustellen, ob Leser sich den Zugriff teilen oder ob ein anderer Schreiber bereits über exklusiven Zugriff verfügt. In beiden Fällen muss der Thread, der den exklusiven Zugriff will, den kritischen Abschnitt verlassen und dann auf das Semaphor warten, das signalisiert wird, wenn der Thread exklusiven Zugriff hat und weiter ausgeführt werden kann. Wie Sie sehen, beeinträchtigt das Warten auf mehrere Synchronisierungsobjekte, um exklusiven Zugriff zu erlangen, die Leistung auf empfindliche Weise.
Die Anwendung ReaderWriterExample umfasst eine Reihe von Befehlszeilenschaltern. Damit kann ihr Verhalten so angepasst werden, dass es möglich ist, mit verschiedenen Szenarios und der Verwendung verschiedener Arten von Reader-/Writer-Sperren zu experimentieren. Die Daten, die von der Sperre geschützt werden, sind vom einfachen Typ LONG. Die Anwendung ermöglicht es jedoch festzulegen, dass während des Haltens der Sperre zusätzliche Arbeit ausgeführt wird, um den Zugriff auf oder die Aktualisierung einer komplexeren Datenstruktur zu simulieren. Der zusätzliche Arbeitsparameter kann individuell für Leser und Schreiber angegeben werden, so dass die Simulation der Datenstrukturen ermöglicht wird, bei denen durch Updates ein größerer Overhead entsteht als durch Lesevorgänge. Ebenso gibt es Parameter, mit denen festgelegt werden kann, wie viel Arbeit ein Leser/Schreiber zwischen den einzelnen Zugriffen auf die Sperre ausführen sollte. Die Arbeit wird durch das Durchlaufen einer Schleife und Durchführen einer Berechnung simuliert.
Wir wissen bereits, dass die eigenen Sperren keine gute Leistung aufweisen, wenn es unter hoher Auslastung zu starker Konkurrenz zwischen Lesern und Schreibern kommt. Außerdem zeigte die SRW-Sperre in allen Fällen im ersten Beispiel eine gute Leistung. Bedeutet dies nun, dass das Erstellen von eigenen Reader-/Writer-Sperren vollkommen überflüssig wird, da ja nun SRW-Sperren verfügbar sind? Nicht unbedingt. Untersuchen wir, was geschieht, wenn die Sperren in Szenarios verwendet werden, die unseren Erwartungen von ihrer Verwendung in einer Anwendung eher entsprechen.
In diesen Szenarios beträgt die Zahl der gemeinsamen Zugriffe durch einen Leser 1.000.000 und die Zahl der exklusiven Zugriffe durch die Schreiber 150.000. Dies entspricht dem zuvor Gesagten, dass nämlich die Reader-/Writer-Sperre sinnvoll ist, wenn das Verhältnis zwischen gemeinsamen Zugriffen und exklusiven Updates hoch ist. Darüber hinaus halten die Leser die Sperre über 2000 Arbeitseinheiten, um eine Leseanforderung zu simulieren, während Schreiber die Sperre über 3000 Arbeitseinheiten halten, um den zusätzlichen Aufwand eines Updates der Daten zu simulieren. Ein Leserthread führt zwischen den einzelnen Zugriffen auf die Sperre 100 Arbeitseinheiten aus, so dass der Abstand zwischen den Zugriffen kurz ist, während der Writer 10.000 Arbeitseinheiten ausführt, bevor er versucht, exklusiven Zugriff zu erlangen, sodass eine längere Zeitspanne zwischen den Updates entsteht. Dadurch wird die Konkurrenz um die Sperren insgesamt verringert.
Wir haben diesen Test mit 2 Lesern/2 Schreibern und 3 Lesern/1 Schreiber ausgeführt. Die Ergebnisse sind in den Abbildungen 4 und 5 zusammengefasst. Die Zahlen in den Tabellen stehen für die verstrichene Zeit (gemessen in Sekunden), die jeder Thread benötigt, um seine Arbeit abzuschließen. Bei Leserthreads ist die zweite Zahl die Anzahl der Updates, die der Leserthread für die Daten beobachtet hat.

Szenario mit 3 Lesern / 1 Schreiber Slim R/W Kritischer Abschnitt Benutzerdefinierte R/W-Sperre – Keine Bevorzugung Benutzerdefinierte R/W-Sperre – Bevorzugt Schreiber
Lesethread 1 2,345 (761 Updates) 7,650 (21.266 Updates) 6,313 (97.997 Updates) 8,365 (132.290 Updates)
Lesethread 2 2,282 (631 Updates) 7,486 (11.466 Updates) 6,267 (102.102 Updates) 8,170 (140.144 Updates)
Lesethread 3 2,324 (633 Updates) 7,581 (20.013 Updates) 6,321 (98.100 Updates) 8,134 (126.568 Updates)
Schreibthread 1 7.970 11.990 8.010 8.446
Gesamtausführungszeit 7.970 11.990 8.010 8.446

Szenario mit 2 Lesern / 2 Schreibern Slim R/W Kritischer Abschnitt Benutzerdefinierte R/W-Sperre – Keine Bevorzugung Benutzerdefinierte R/W-Sperre – Bevorzugt Schreiber
Lesethread 1 1,892 (1.185 Updates) 5,044 (19.689 Updates) 1,868 (7.728 Updates) 5,664 (133.215 Updates)
Lesethread 2 1,920 (1.177 Updates) 3,906 (16.223 Updates) 1,861 (7.402 Updates) 5,559 (139.283 Updates)
Schreibthread 1 7.575 9.996 7.372 7.321
Schreibthread 2 7.574 10.250 7.378 7.317
Gesamtausführungszeit 7.575 10.250 7.378 7.321
Diese Ergebnisse zeigen eine größere Gleichwertigkeit zwischen unseren eigenen Sperren und der SRW-Sperre. Beachten Sie, wie die verschiedenen Richtlinien die Ergebnisse beeinflussen. Die Sperre, die Schreiber bevorzugt, bewirkt, dass die Leser warten müssen, während den Schreibern der Zugriff ermöglicht wird. Dies bedeutet, dass Updates höhere Priorität erhielten und es wiederum den Lesern ermöglicht wird, mehr von den Updates zu sehen. Die Priorisierung von Updates kann in einer Anwendung von entscheidender Bedeutung sein, daher ist es wichtig, die Richtlinie der Sperre und ihre Leistungsmerkmale unter der erwarteten Auslastung zu verstehen.
SRW-Sperren sind ein hervorragendes neues Synchronisierungsprimitiv auf der Windows-Plattform. Erstmals bietet Windows eine integrierte Reader-/Writer-Sperre für Programmierer systemeigener Anwendungen. Die Sperre zeigt unter vielen verschiedenen Bedingungen eine gute Leistung und sollte die erste Reader-/Writer-Sperre sein, die Sie verwenden. In bestimmten Situationen kann Ihre Anwendung sicher von einer anderen Sperrungsrichtlinie profitieren. Wie wir gesehen haben, ist das Erstellen einer benutzerdefinierten Reader-/Writer-Sperre, die in vielfältigen Szenarios eine gute Leistung zeigt, jedoch keine ganz einfache Aufgabe.

Einmalige Initialisierung
Beim Erstellen von Multithreadsystemen tritt immer wieder das Problem auf, wie die korrekte Initialisierung eines Objekts oder einer Ressource sichergestellt werden kann, das bzw. die von mehreren Threads gemeinsam genutzt wird. C und C++ bieten keine Hilfe bei der Lösung dieses Problems, da Multithreadsupport im Sprachstandard nicht erwähnt wird. Betrachten Sie ein Beispiel, in dem eine Instanz eines Protokollierungsobjekts für die Protokollierung von Nachrichten verwendet werden soll und eine der Anforderungen darin besteht, dass das Objekt bei Bedarf instanziiert wird, statt zu Beginn der Programmausführung erstellt zu werden. Der Singlethread-Initialisierungscode in Abbildung 6 wird nicht korrekt ausgeführt bei Anwesenheit mehrerer Threads, die gleichzeitig innerhalb der GetLogger-Funktion ausgeführt werden, um auf das Protokollierungsobjekt in unserem System zuzugreifen.

Singlethread-Initialisierung
Logger* GetLogger()
{
    // C++ will execute the Logger’s constructor
    // only the first time this method is called.
    static Logger logger;

    return &logger;
}

Threadsichere Initialisierung
Logger* GetLogger()
{
    static Logger* pLogger = 0;

    EnterCriticalSection(&cs);

    if (pLogger == 0)
    {
        try
        {
            pLogger = new Logger();
        }
        catch (...)
        {
            // Something went wrong.
        }
    }

    LeaveCriticalSection(&cs);

    return pLogger;
}
Die Initialisierung des statischen Protokollierungsobjekts kann tatsächlich mehr als einmal erfolgen, da der Compiler keine Synchronisierung an seine Konstruktion bindet. Dies kann zur Beschädigung des Protokollierungsobjekts führen, das im günstigsten Fall bei der Verwendung eine Ausnahme generiert – es gibt jedoch keine Garantie dafür, dass der Fehler offensichtlich wird, und es ist möglich, dass das System über einen langen Zeitraum ausgeführt wird, bevor jemand das Problem bemerkt. Eine Möglichkeit zur Behebung dieses Problems besteht darin, dass die Funktion umgeschrieben und Synchronisierung eingeführt wird, damit die korrekte Funktionsweise bei Anwesenheit mehrerer Threads gewährleistet ist, wie von dem threadsicheren Initialisierungscode in Abbildung 6 gezeigt.
Nun versucht jeder Thread, der in die GetLogger-Funktion eintritt, den kritischen Abschnitt zu betreten, was bedeutet, dass jeweils nur ein Thread den Block mit geschütztem Code betreten darf. Ein Thread, der im kritischen Abschnitt ausgeführt wird, überprüft den Wert von pLogger. Nur wenn dieser NULL lautet, wird eine Instanz des Protokollierungsobjekts erstellt. Dies geschieht nur einmal, wenn der erste Thread den kritischen Abschnitt betritt. Alle weiteren Threads, die den Abschnitt im Anschluss daran betreten, erkennen, dass der Wert für pLogger ungleich NULL ist, und verlassen den kritischen Abschnitt, ohne weitere Arbeiten auszuführen. Bis die Rückgabeanweisung erreicht ist, ist der Wert von pLogger ungleich NULL und kann an den Aufrufer zurückgegeben werden.
Oberflächlich betrachtet, scheint der threadsichere Initialisierungscode eine vernünftige Lösung zu sein. Diese Lösung ist jedoch mit Nachteilen verbunden, besonders dann, wenn viele Threads die GetLogger-Funktion gleichzeitig aufrufen. Wenn der erste Thread die Zuordnung, Erstellung und Einrichtung des pLogger-Zeigers abgeschlossen hat, besteht für nachfolgende Threads keine Notwendigkeit mehr, das Objekt des kritischen Abschnitts zu betreten. Der Zeiger ist immer gültig. Diese Erkenntnis hat ein Entwurfsmuster bei der C++-Programmierung hervorgebracht, das als Sicherheitsprüfungssperre (Double-Checked Locking) bezeichnet wird.
Abbildung 7 zeigt eine Implementierung von GetLogger, bei der das Muster der Sicherheitsprüfungssperre zur Anwendung kommt. Das Muster schreibt vor, dass die pLogger-Variable bis zu zweimal überprüft wird. Beim ersten Mal wird die Variable außerhalb des kritischen Abschnitts überprüft. Wenn festgestellt wird, dass pLogger gleich NULL ist, betritt der Thread den kritischen Abschnitt. Innerhalb des Abschnitts überprüft der Thread nochmals, ob pLogger den Wert NULL hat, bevor er die pLogger-Variable instanziiert und einrichtet, da es möglich ist, dass der Thread beim Warten auf den kritischen Abschnitt aufgrund eines Race mit einem anderen Thread blockiert wurde.
Logger* GetLogger()
{
    volatile static Logger* pLogger = 0;

    if (pLogger == NULL)
    {
        EnterCriticalSection(&cs);

        if (pLogger == NULL)
        {
            try
            {
                pLogger = new Logger();
            }
            catch (...)
            {
                // Something went wrong.
            }
        }

        LeaveCriticalSection(&cs);
    }

    return pLogger;
}
Die Version Sicherheitsprüfungssperre scheint also nur Vorteile zu bieten. Nur für die wenigen Threads, die GetLogger vor der Instanziierung des Objekts betreten, wird die Synchronisierung erzwungen; alle nachfolgenden müssen den kritischen Abschnitt gar nicht betreten. Bleiben da noch Wünsche offen?
Das Sicherheitsprüfungssperrenmuster beruht zwar auf einem einfachen Konzept, die richtige Programmierung erweist sich jedoch bislang als äußerst schwierig. Dies liegt daran, dass der C++-Standard kein Threadmodell definiert. Er nimmt nur einen Ausführungsthread an und definiert für Entwickler keine Möglichkeit, Einschränkungen hinsichtlich der relativen Anweisungsreihenfolge auszudrücken, was dem Compiler Spielraum für die Neuordnung von Lese- und Schreibvorgängen in den Speicher lässt. In einer Multithreadumgebung kann die Neuordnung dazu führen, dass ein Thread einen Schreibvorgang in den Speicher beachtet, bevor alle Anweisungen, die davor im Quellcode auftreten, tatsächlich ausgeführt wurden. Im Fall des Sicherheitsprüfungssperrencodes ist es möglich, dass die pLogger-Variable mit der Adresse des dem Protokollierungsobjekt zugeordneten Speicherbereichs aktualisiert wird, bevor der Protokollierungskonstruktor ausgeführt wurde. Ein zweiter Thread, der den Nicht-NULL-Wert beachtet, würde den kritischen Abschnitt niemals betreten und die Adresse eines nicht vollständig konstruierten Objekts zurückgeben. (Informationen über ähnliche Probleme in verwaltetem Code finden Sie im Artikel von Vance Morrison: „Auswirkungen von Low-Lock-Techniken in Multithreadanwendungen“.) In früheren Versionen des Visual Studio® C++-Compilers reichte selbst die Verwendung des volatile-Qualifizierers in der pLogger-Variablen nicht aus, um das richtige Verhalten in einem Multithreadszenario zu gewährleisten. In Visual Studio 2005 kann jedoch die zuverlässige Ausführung des Sicherheitsprüfungssperrenmusters auf der Windows-Plattform sichergestellt werden, sofern die pLogger-Variable mit dem Schlüsselwort „volatile“ qualifiziert wird.
Die korrekte Implementierung des Sicherheitsprüfungssperrenmusters in Visual Studio 2005 ist zwar möglich, es besteht jedoch die Gefahr, dass Fehler gemacht werden, zum Beispiel einfach durch Auslassen des volatile-Qualifizierers in der Variablen, die den Instanzenzeiger enthält, oder durch Auslassen der Überprüfung innerhalb des kritischen Bereichs. Das Programm lässt sich auch dann noch kompilieren und scheint zu funktionieren. Ein Mechanismus, der tatsächlich funktioniert, wäre eine wertvolle Ergänzung für C++. Statt jedoch auf eine neue Revision der Standardisierungsorganisation zu warten, stellt Windows Vista eine Funktion für die einmalige Initialisierung bereit. Ganz gleich, auf welcher Hardwareplattform Sie Windows verwenden – dies funktioniert zuverlässig. Einmalige Initialisierung ermöglicht sowohl synchrone als auch asynchrone Initialisierung. Zunächst befassen wir uns mit der synchronen Initialisierung.
Vom Konzept her funktioniert die synchrone Initialisierung auf dieselbe Weise wie das Sicherheitsprüfungssperrenmuster. Von den ersten n Threads, die gleichzeitig versuchen, die Initialisierung auszuführen, instanziiert nur einer die Ressource – die anderen bleiben so lange blockiert, bis die Initialisierung abgeschlossen ist. Nach Abschluss der Initialisierung werden nachfolgende Threads, die versuchen, auf die Ressource zuzugreifen, nicht blockiert; die gespeicherte Ressource wird einfach zurückgegeben.
Die erste Aufgabe besteht darin, eine Instanz der Struktur INIT_ONCE zu deklarieren und zu initialisieren. Zum Ausführen der Initialisierung wird die InitOnceInitialize-Funktion verwendet. Dies ist sowohl für die synchrone als auch die asynchrone einmalige Initialisierung erforderlich und muss erfolgen, bevor die Struktur mit einer anderen Funktion der einmaligen Initialisierung verwendet wird. Die Definition lautet:
VOID WINAPI InitOnceInitialize(
  PINIT_ONCE InitOnce
);
Die folgende Funktion wird zur Ausführung der synchronen einmaligen Initialisierung verwendet:
BOOL WINAPI InitOnceExecuteOnce(
  PINIT_ONCE InitOnce,
  PINIT_ONCE_FN InitFn,
  PVOID Parameter,
  LPVOID* Context
);
Der erste Parameter, InitOnce, ist ein Zeiger auf die Instanz der Struktur INIT_ONCE. Alle Threads, die dieselbe Adresse einer Struktur INIT_STRUCTURE übergeben, werden während der Initialisierung in Bezug aufeinander synchronisiert. Der zweite Parameter, InitFn, ist ein Zeiger auf eine Funktion, die vom Entwickler geschrieben wird; diese Funktion führt die eigentliche Initialisierung aus. Der dritte Parameter, praktischerweise mit dem Namen Parameter, ist ein optionaler Wert, der zurück an InitFn übergeben wird, wenn er aufgerufen wird. Hier können Sie beliebige Informationen angeben, die zur Ausführung der Initialisierung erforderlich sind. Der letzte Parameter, Context, ist ein Zeiger auf einen ungültigen Zeiger. Hier wird die Adresse des initialisierten Objekts gespeichert, wenn die Funktion erfolgreich ausgeführt wird. Beachten Sie, dass ich das Wort „Objekt“ verwende, um das Ergebnis der einmaligen Initialisierung zu beschreiben. Es muss sich nicht unbedingt um einen Zeiger auf ein C++-Objekt handeln. Möglich ist alles, das in einen Wert von Zeigergröße auf der Plattform passt, auf der die Ausführung stattfindet. InitOnceExecuteOnce gibt bei Erfolg TRUE zurück, im Falle eines Fehlers wird FALSE zurückgegeben.
Sehen wir uns nun die Funktion an, auf die InitFn zeigt. Die Definition der Funktion hat folgende Signatur:
BOOL CALLBACK InitOnceCallback(
  PINIT_ONCE InitOnce,
  PVOID Parameter,
  PVOID* Context
);
Die Parameter sind identisch mit den Parametern in der InitOnceExecuteOnce-Funktion. Die InitOnceCallback-Funktion soll die Adresse des initialisierten Objekts in Context speichern. Abbildung 8 enthält die GetLogger-Funktion, die für die Verwendung der synchronen einmaligen Initialisierung umgeschrieben wurde. Beachten Sie, dass die Rückruffunktion einen Fehler anzeigt, indem sie FALSE zurückgibt. Durch Rückgabe von FALSE durch die Rückruffunktion ermöglicht das System einem anderen Thread, der die Initialisierung versucht, die Rückruffunktion auszuführen, in der Hoffnung, dass diesmal kein Fehler auftritt. Dies wird so lange fortgesetzt, bis es entweder keine Initialisierungsversuche mehr gibt oder der Rückruf TRUE zurückgibt.
BOOL WINAPI InitLoggerFunction(PINIT_ONCE intOncePtr,
                               PVOID Parameter,
                               PVOID* contextPtr)
{
    try
    {
        Logger* pLogger = new Logger();
        *contextPtr = pLogger;
        return TRUE;
    }
    catch (...)
    {
        // Something went wrong.
        return FALSE;
    }
}

Logger* GetLogger()
{
    static INIT_ONCE initOnce;

    PVOID contextPtr;
    BOOL status;

    status = InitOnceExecuteOnce(&initOnce,
                                 InitLoggerFunction,
                                 NULL,
                                 &contextPtr);

    if (status)
    {
        return (Logger*)contextPtr;
    }

    return NULL;
}
Die asynchrone einmalige Initialisierung ist komplizierter als die synchrone Version, ermöglicht jedoch die einmalige Initialisierung, ohne dass Threads, die die Initialisierung ausführen, blockiert werden, während sie auf den Abschluss der Initialisierung warten. Folglich können bis zum Abschluss der Initialisierung alle Threads, die eine Initialisierung versuchen, gleichzeitig ausgeführt werden und die Initialisierungsfunktion durchlaufen, wobei jeder seine eigene private Kopie des Objekts initialisiert und anschließend versucht, das Objekt als das einzige initialisierte Objekt zu registrieren. Nur ein Thread ist der so genannte Gewinner der Objektregistrierung, die übrigen Threads (die Verlierer) müssen ihre privaten Objektinstanzen zerstören und anschließend das siegreiche Objekt beim System abfragen.
Die folgenden beiden APIs werden für die asynchrone einmalige Initialisierung verwendet:
BOOL WINAPI InitOnceBeginInitialize(
  LPINIT_ONCE InitOnce,
  DWORD dwFlags,
  PBOOL fPending,
  LPVOID* Context
);

BOOL WINAPI InitOnceComplete(
  LPINIT_ONCE lpInitOnce,
  DWORD dwFlags,
  LPVOID lpContext
);
Abbildung 9 zeigt dieselbe GetLogger-Funktion, die weiter oben verwendet wurde, sie wurde jedoch für die Verwendung der asynchronen einmaligen Initialisierung umgeschrieben. Im Folgenden wird diese Routine Schritt für Schritt betrachtet. Als Erstes ruft ein Thread InitOnceBeginInitialize auf. Der Thread muss den Zeiger auf die Struktur INIT_ONCE bereitstellen, die verwendet werden soll, und für den Parameter dwFlags muss INIT_ONCE_ASYNC festgelegt werden, um anzugeben, dass dieser Thread versucht, mit der asynchronen einmaligen Initialisierung zu beginnen. Der Status der Initialisierung wird an den Aufrufer zurückgegeben. Hierbei werden die verbleibenden zwei Parameter fPending und Context verwendet, die als Zeiger angegeben wurden, damit sie von der Funktion aktualisiert werden können. InitOnceBeginInitialize gibt bei Erfolg TRUE zurück; andernfalls wird FALSE zurückgegeben, was anzeigt, dass ein Fehler aufgetreten ist und die Initialisierung nicht fortgesetzt werden kann. Der Parameter fPending muss untersucht werden, um zu bestimmen, ob die Initialisierung bereits von einem anderen Thread abgeschlossen wurde. Wenn fPending FALSE ist, wurde die Initialisierung bereits abgeschlossen und das initialisierte Objekt ist im Parameter Context gespeichert. In diesem Fall muss GetLogger lediglich Context in einen Logger* konvertieren und an den Aufrufer zurückgeben.
Logger* GetLogger()
{
    static INIT_ONCE initOnce;

    PVOID contextPtr;
    BOOL    fStatus;
    BOOL    fPending;

    fStatus = InitOnceBeginInitialize(&initOnce,INIT_ONCE_ASYNC, 
        &fPending, &contextPtr);

    // Failed?
    if (!fStatus)
    {
        return NULL;
    }

    // Initialization already completed?
    if (!fPending)
    {
        // Pointer to the logger is contained context pointer.
        return (Logger*)contextPtr;
    }

    // Reaching this point means that the logger needs to be created.

    try
    {
        Logger* pLogger = new Logger();

        fStatus = InitOnceComplete(
            &initOnce,INIT_ONCE_ASYNC,(PVOID)pLogger);

        if (fStatus)
        {
            // The Logger that was created was successfully 
            // set as the logger instance to be used.
            return pLogger;
        }

        // Reaching this point means fStatus is FALSE and the object this
        // thread created is not the sole instance. 
        // Clean up the object this thread created.
        delete pLogger;
    }
    catch (...) 
    {
        // Instantiating the logger failed.
        // Fall through and see if any of 
        // the other threads were successful.
    }

    // Call again but this time ask only for 
    // the result of one-time initialization.
    fStatus = InitOnceBeginInitialize(&initOnce,INIT_ONCE_CHECK_ONLY, 
        &fPending,contextPtr);

    // Function succeeded and initialization is complete.
    if (fStatus)
    {
        // The pointer to the logger is in the contextPtr.
        return (Logger*) contextPtr;
    }

    // InitOnceBeginInitialize failed, return NULL.
    return NULL;
}
Wenn fPending TRUE zurückgibt, wird es interessant. Dadurch wird angezeigt, dass die Initialisierung fortgesetzt werden sollte und dass es möglicherweise andere Threads gibt, die den Initialisierungscode zur gleichen Zeit wie der Thread des Aufrufers ausführen. In Abbildung 9 führt dies zur Erstellung eines neuen Protokollierungsobjekts, gefolgt von einem Versuch, die Instanz als die einzige Instanz festzulegen, die aus der asynchronen einmaligen Initialisierung resultiert. Dies lässt sich im InitOnceComplete-Aufruf beobachten, der nach der neuen Anweisung stattfindet. Der erste Parameter gibt den Zeiger auf die gleiche Struktur INIT_ONCE an, die zuvor verwendet wurde, das Kennzeichen INIT_ONCE_ASYNC wird als zweiter Parameter übergeben, und der Zeiger auf die Protokollierungsinstanz wird im dritten Parameter angegeben. Wenn InitOnceComplete TRUE zurückgibt, ist diese Instanz diejenige, die an alle Aufrufer von GetLogger zurückgegeben wird.
Falls InitOnceComplete FALSE zurückgibt, wurde eine von einem anderen Thread erstellte Protokollierung gespeichert, und der Aufrufer muss die erstellte Instanz zerstören, damit davon genutzte Ressourcen nicht festliegen. Im Anschluss wird InitOnceBeginInitialize erneut aufgerufen, diesmal wird jedoch statt des Kennzeichens INIT_ONCE_ASYNC das Kennzeichen INIT_ONCE_CHECK_ONLY verwendet. Das Kennzeichen INIT_ONCE_CHECK_ONLY wird verwendet, um zu überprüfen, ob die Initialisierung abgeschlossen ist. Ist dies der Fall, wird der gespeicherte Initialisierungswert in den angegebenen Zeigerparameter von Context kopiert. Wird ein gültiger Initialisierungswert zurückgegeben, gibt die Funktion den Wert TRUE zurück, und der Parameter fPending wird auf den Wert FALSE gesetzt.
Wie treffen Sie nun also die Wahl zwischen synchroner und asynchroner einmaliger Initialisierung? In einem Einprozessorsystem ist es sinnvoll, die synchrone Version zu verwenden, da jeweils nur ein Thread ausgeführt werden kann. In einem Multiprozessor- oder Multikernsystem müssen Sie versuchen, die Kosten zu quantifizieren, die anfallen können, wenn mehrere Objektinstanzen erstellt werden. Zu den Kosten können Zeit, Speicher (oder andere Ressourcentypen, die u. U. knapp sind) und die Anzahl der Threads gehören, die normalerweise gleichzeitig den Initialisierungscode ausführen, bevor es zur erfolgreichen Initialisierung eines Objekts kommt. Die synchrone Initialisierung empfiehlt sich vielleicht eher, wenn die Erstellung des Objekt einen nicht unerheblichen Zeitaufwand bedeutet oder wenn große Mengen Speicher benötigt werden, entweder pro Objekt oder für alle Objekte zusammen, die gleichzeitig erstellt werden. Wenn der Prozess der Objekterstellung außerdem dazu führen kann, dass die Threads, die die Initialisierung durchführen, blockiert werden, dann ist die synchrone Initialisierung die bessere Wahl, da die Möglichkeiten für Parallelität geringer sind und die asynchrone Initialisierung vermutlich kaum Vorteile bringen würde.
Der Quellcode für diesen Artikel umfasst ein Beispiel namens OneTimeInit.exe., das sowohl die synchrone als auch die asynchrone einmalige Initialisierung eines gemeinsam genutzten Ereignishandles demonstriert. Das Programm übernimmt die Anzahl der auszuführenden Threads und ein Kennzeichen, das anzeigt, ob die synchrone oder die asynchrone Initialisierung ausgeführt werden soll. Die angegebene Anzahl Threads wird erzeugt und jeder Thread versucht, ein Handle für ein Win32-Ereignisobjekt abzurufen, mit dem signalisiert wird, wann der Thread beendet werden soll. Der Status jedes Threads wird an stdout gesendet, damit Sie den Fortschritt der Initialisierung genau verfolgen können. Jeder Thread führt einige arrangierte Arbeitsvorgänge aus, bis die Beendigung des Ereignisses vom Hauptthread signalisiert wird. Um Informationen zur Programmnutzung abzurufen, führen Sie einfach das Programm ohne Parameter aus.
Im Hinblick auf die einmalige Initialisierung sollen noch ein paar ungeklärte Fragen angeschnitten werden. Wir haben festgestellt, dass die Rückruffunktion bei der synchronen Initialisierung so lange aufgerufen wird, bis sie erfolgreich ist. Angenommen, Ihr Anwendungsszenario schreibt vor, dass der Initialisierungsversuch nur ein einziges Mal ausgeführt und im Fehlerfall nicht wiederholt werden sollte. Eine Möglichkeit zur Lösung besteht darin, die Rückruffunktion so zu konfigurieren, dass sie TRUE zurückgibt, und zusätzlich einen besonderen Wert in lpContext festzulegen, den andere Threads überprüfen können und der anzeigt, ob ein Fehler aufgetreten ist. Eine weitere Möglichkeit besteht darin, statt der InitOnceExecuteOnce-API die APIs „InitiOnceBeginInitialize“ bzw. „InitOnceComplete“ für die synchrone Initialisierung zu verwenden. Hierzu lassen Sie das Kennzeichen INIT_ONCE_ASYNC aus dem dwFlags-Parameter weg und plazieren den Initialisierungscode zwischen die Aufrufe der APIs „InitOnceBeginInitialize“ und „InitOnceComplete“ statt in einer separaten Rückruffunktion. Im Falle eines Fehlers wird das Kennzeichen INIT_ONCE_INIT_FAILED mit der InitOnceComplete-API verwendet, um anzuzeigen, dass keine weiteren Initialisierungsversuche stattfinden sollten. Mithilfe von lpContext kann ein optionaler Fehlerwert festgelegt werden, den andere Threads überprüfen können.
Die zweite ungeklärte Frage betrifft die Tatsache, dass die einmalige Initialisierung die Bereinigung des erstellten Objekts nicht unterstützt. Es gibt eine Reihe von Möglichkeiten, dieses Problem zu lösen. Nicht jede Lösung eignet sich jedoch für jede Situation. Wenn das Objekt vom Betriebssystem nach Beendigung des Prozesses bereinigt wird, sollten Sie einen Verlust in Erwägung ziehen. Dies kann jedoch als Problem angezeigt werden, wenn Sie Tools verwenden, die Ihre Programme auf Ressourcenverluste überwachen. In Fällen, in denen die Bereinigungsfunktion für das Objekt aufgerufen werden muss, um das korrekte Verhalten sicherzustellen, bleibt Ihnen nichts anderes übrig, als den Aufruf der Funktion zu koordinieren, nachdem alle Threads, die das Objekt verwenden, beendet wurden. Möglicherweise können Sie jedoch ein Verfahren anwenden, das dem im Beispiel verwendeten ähnelt. Im Beispiel wird ein statisches Objekt verwendet, bei dem das initialisierte HANDLE registriert ist. So kann es automatisch am Ende der Programmausführung zerstört werden, nachdem alle Threads beendet wurden.

Schlussbemerkung
In diesem Artikel wird veranschaulicht, dass Windows Vista für C/C++-Entwickler systemeigener Anwendungen zahlreiche Verbesserungen der Threadsynchronisierungsprimitive bietet. Diese Verbesserungen erleichtern es Entwicklern, Probleme der Threadsynchronisierung zu lösen. In diesem Artikel konnte das Thema jedoch nur oberflächlich behandelt werden. Es wird empfohlen, diese und andere Änderungen näher zu untersuchen. So könnten Sie sich zum Beispiel über die API-Verbesserungen für Threadpools informieren. Leider würde ihre Erörterung den Rahmen dieses Artikels sprengen, sie sind jedoch eine nützliche Ergänzung, die eine sorgfältige Überlegung verdienen.
Wir danken Neill Clift und Arun Kishan für die Beantwortung unserer Fragen und für das hilfreiche Feedback. Außerdem danken wir Jerry Albro für die Durchsicht des Inhalts und das wertvolle Feedback.

Robert Saccone ist Architekt in der Forefront Server Security-Gruppe. Seine Interessensgebiete sind Design umfangreicher Softwareanwendungen, verteilte Systeme und Betriebssystemimplementierungen.

Alexander Taskov ist als leitender Softwareentwickler im Forefront Server Security-Team bei Microsoft tätig. Er ist verantwortlich für das Design und die Entwicklung der Sicherheitsfeatures für Forefront Server Security.

Page view tracker