Thread-Zustände. Kritische Abschnitte Abschluss der Synchronisierung im Windows-Betriebssystem

Dieses Synchronisationsobjekt kann nur lokal innerhalb des Prozesses verwendet werden, der es erstellt hat. Der Rest der Objekte kann verwendet werden, um die Threads verschiedener Prozesse zu synchronisieren. Der Name des Objekts „kritischer Abschnitt“ ist mit einer abstrakten Auswahl eines Teils des Programmcodes (Abschnitt) verbunden, der einige Operationen durchführt, deren Reihenfolge nicht verletzt werden kann. Das heißt, ein Versuch von zwei verschiedenen Threads, den Code dieses Abschnitts gleichzeitig auszuführen, führt zu einem Fehler.

Beispielsweise kann es sinnvoll sein, Writer-Funktionen mit einem solchen Abschnitt zu schützen, da ein gleichzeitiger Zugriff mehrerer Writer ausgeschlossen werden soll.

Für den kritischen Abschnitt werden zwei Operationen eingeführt:

geben Sie den Abschnitt ein Während sich ein Thread im kritischen Abschnitt befindet, hören alle anderen Threads automatisch auf zu warten, wenn sie versuchen, ihn zu betreten. Ein Thread, der diesen Abschnitt bereits betreten hat, kann ihn mehrmals betreten, ohne darauf zu warten, dass er freigegeben wird.

Sektion verlassen Wenn ein Thread einen Abschnitt verlässt, wird der Zähler der Anzahl der Einträge dieses Threads in den Abschnitt dekrementiert, so dass der Abschnitt nur dann für andere Threads freigegeben wird, wenn der Thread den Abschnitt so oft verlässt, wie er in ihn eingetreten ist. Wenn ein kritischer Abschnitt freigegeben wird, wird nur ein Thread aufgeweckt und wartet auf die Erlaubnis, diesen Abschnitt zu betreten.

Im Allgemeinen wird in anderen Nicht-Win32-APIs (z. B. OS/2) der kritische Abschnitt nicht als Synchronisationsobjekt behandelt, sondern als ein Stück Programmcode, das nur von einem Anwendungsthread ausgeführt werden kann. Das heißt, das Betreten des kritischen Abschnitts wird als vorübergehendes Herunterfahren des Thread-Umschaltmechanismus bis zum Verlassen dieses Abschnitts betrachtet. Die Win32-API behandelt kritische Abschnitte als Objekte, was zu einiger Verwirrung führt -- sie sind in ihren Eigenschaften unbenannten exklusiven Objekten sehr ähnlich ( mutex, siehe unten).

Bei der Verwendung von kritischen Abschnitten ist darauf zu achten, dass keine zu großen Codefragmente im Abschnitt allokiert werden, da dies zu erheblichen Verzögerungen bei der Ausführung anderer Threads führen kann.

Beispielsweise macht es in Bezug auf die bereits betrachteten Heaps keinen Sinn, alle Heap-Funktionen mit einem kritischen Abschnitt zu schützen, da Reader-Funktionen parallel ausgeführt werden können. Darüber hinaus scheint die Verwendung eines kritischen Abschnitts selbst zum Synchronisieren von Writern eigentlich unpraktisch zu sein - da letztere, um einen Writer mit Readern zu synchronisieren, diesen Abschnitt noch betreten müssen, was praktisch zum Schutz aller Funktionen durch einen einzigen führt Sektion.

Es gibt mehrere Fälle für die effektive Verwendung kritischer Abschnitte:

Leser stehen nicht in Konflikt mit Schreibern (nur Schreiber müssen geschützt werden);

alle Threads haben ungefähr die gleichen Zugriffsrechte (z. B. können Sie keine reinen Autoren und Leser herausgreifen);

beim Aufbau zusammengesetzter Synchronisierungsobjekte, die aus mehreren Standardobjekten bestehen, um sequentielle Operationen an einem zusammengesetzten Objekt zu schützen.

In den vorherigen Teilen des Artikels habe ich darüber gesprochen allgemeine Grundsätze und spezifische Methoden zum Erstellen von Multithread-Anwendungen. Verschiedene Threads müssen fast immer periodisch miteinander interagieren, und die Notwendigkeit einer Synchronisierung entsteht zwangsläufig. Heute werfen wir einen Blick auf das wichtigste, leistungsfähigste und vielseitigste Windows-Synchronisierungstool: Kernel Sync Objects.

WaitForMultipleObjects und andere Wartefunktionen

Wie Sie sich erinnern, müssen Sie zum Synchronisieren von Threads normalerweise die Ausführung eines der Threads vorübergehend aussetzen. Es muss jedoch mit übersetzt werden Betriebssystem in einen Wartezustand, in dem es keine CPU-Zeit beansprucht. Wir kennen bereits zwei Funktionen, die das können: SuspendThread und ResumeThread . Aber wie ich im vorherigen Teil des Artikels gesagt habe, sind diese Funktionen aufgrund einiger Features nicht für die Synchronisation geeignet.

Heute werden wir uns eine andere Funktion ansehen, die den Thread ebenfalls in den Wartezustand versetzt, aber im Gegensatz zu SuspendThread/ResumeThread speziell für die Organisation der Synchronisation entwickelt wurde. Es ist WaitForMultipleObjects . Da diese Funktion so wichtig ist, werde ich ein wenig von meiner Regel abweichen, nicht auf die Details der API einzugehen, und ausführlicher darüber sprechen, sogar ihren Prototyp angeben:

DWORD WaitForMultipleObjects (

DWORD nAnzahl , // Anzahl der Objekte im lpHandles-Array

KONST-GRIFF * lpHandles , // Zeiger auf ein Array von Kernel-Objektdeskriptoren

BOOL bWaitAll , // Flag, das angibt, ob auf alle Objekte gewartet werden soll oder ob nur eines ausreicht

DWORD dwMillisekunden // Auszeit

Der Hauptparameter dieser Funktion ist ein Zeiger auf ein Array von Kernel-Objekthandles. Wir werden weiter unten darüber sprechen, was diese Objekte sind. Im Moment ist es für uns wichtig zu wissen, dass jedes dieser Objekte in einem von zwei Zuständen sein kann: neutral oder "signalisierend" (signalisierter Zustand). Wenn das bWaitAll-Flag FALSE ist, kehrt die Funktion zurück, sobald mindestens eines der Objekte ein Signal gibt. Und wenn das Flag TRUE ist, geschieht dies nur, wenn alle Objekte gleichzeitig mit der Signalisierung beginnen (wie wir sehen werden, ist dies die wichtigste Eigenschaft dieser Funktion). Im ersten Fall können Sie anhand des zurückgegebenen Werts herausfinden, welches der Objekte das Signal gegeben hat. Sie müssen die WAIT_OBJECT_0-Konstante davon subtrahieren, und Sie erhalten einen Index im lpHandles-Array. Wenn das Timeout das im letzten Parameter angegebene Timeout überschreitet, hört die Funktion auf zu warten und gibt den Wert WAIT_TIMEOUT zurück. Als Timeout kann man die Konstante INFINITE angeben, dann wartet die Funktion "bis sie aufhört", oder umgekehrt 0, dann wird der Thread gar nicht angehalten. Im letzteren Fall kehrt die Funktion sofort zurück, aber ihr Ergebnis teilt Ihnen den Zustand der Objekte mit. Die letztere Technik wird sehr oft verwendet. Wie Sie sehen können, hat diese Funktion umfangreiche Möglichkeiten. Es gibt mehrere andere WaitForXXX-Funktionen, aber sie sind alle Variationen des Hauptthemas. Insbesondere WaitForSingleObject ist nur eine vereinfachte Version davon. Der Rest hat jeweils seine eigene zusätzliche Funktionalität, wird aber im Allgemeinen seltener verwendet. Sie ermöglichen es beispielsweise, nicht nur auf Signale von Kernel-Objekten zu reagieren, sondern auch auf das Eintreffen neuer Fenstermeldungen in der Warteschlange des Threads. Deren Beschreibung sowie ausführliche Informationen zu WaitForMultipleObjects finden Sie wie gewohnt in MSDN.

Nun zu dem, was diese mysteriösen „Kernel-Objekte“ sind. Dazu gehören zunächst die Threads und Prozesse selbst. Sie gehen unmittelbar nach Beendigung in den Meldezustand. Dies ist ein sehr wichtiges Merkmal, da es häufig erforderlich ist, nachzuverfolgen, wann ein Thread oder Prozess beendet wurde. Angenommen, unsere Serveranwendung sollte mit einer Reihe von Worker-Threads abgeschlossen werden. Gleichzeitig muss der Steuer-Thread die Worker-Threads auf irgendeine Weise darüber informieren, dass es Zeit ist, die Arbeit zu beenden (z. B. durch Setzen eines globalen Flags), und dann warten, bis alle Threads abgeschlossen sind, und alles Notwendige für den korrekten Abschluss tun der Aktion: Ressourcen freigeben, Clients über das Herunterfahren informieren, Netzwerkverbindungen schließen usw.

Die Tatsache, dass Threads am Ende der Arbeit ein Signal einschalten, macht es extrem einfach, das Problem der Synchronisation mit der Beendigung des Threads zu lösen:

// Lassen Sie uns der Einfachheit halber nur einen Worker-Thread haben. Lassen Sie es uns ausführen:

HANDLE hWorkerThread = :: Erstelle einen Thread (...);

// Vor dem Ende der Arbeit müssen wir dem Worker-Thread irgendwie mitteilen, dass es Zeit zum Hochladen ist.

// Warten Sie, bis der Thread beendet ist:

DWORD dwWaitResult = :: WaitForSingleObject ( hWorkerThread , UNENDLICH );

wenn( dwWaitResult != WAIT_OBJECT_0 ) { /* Fehlerbehandlung */ }

// Das "Handle" des Streams kann geschlossen werden:

VERIFIZIEREN (:: CloseHandle ( hWorkerThread );

/* Wenn CloseHandle fehlschlägt und FALSE zurückgibt, werfe ich keine Ausnahme aus. Erstens, selbst wenn dies aufgrund eines Systemfehlers geschehen wäre, hätte dies keine direkten Auswirkungen auf unser Programm, denn da wir das Handle schließen, ist in Zukunft keine Arbeit damit zu erwarten. In Wirklichkeit kann der Ausfall von CloseHandle nur einen Fehler in Ihrem Programm bedeuten. Daher fügen wir hier das VERIFY-Makro ein, um es beim Debuggen der Anwendung nicht zu übersehen. */

Der Code, der auf das Ende des Prozesses wartet, sieht ähnlich aus.

Wenn es keine solche eingebaute Fähigkeit gäbe, müsste der Worker-Thread irgendwie Informationen über seinen Abschluss an den Haupt-Thread selbst weitergeben. Selbst wenn er dies zuletzt tat, konnte der Haupt-Thread nicht sicher sein, dass der Worker nicht mindestens ein paar Assembler-Anweisungen zur Ausführung übrig hatte. BEI individuelle Situationen(wenn sich der Thread-Code beispielsweise in einer DLL befindet, die entladen werden muss, wenn sie endet) kann dies fatal sein.

Ich möchte Sie daran erinnern, dass selbst nach Beendigung eines Threads (oder Prozesses) seine Handles noch in Kraft bleiben, bis sie explizit durch die CloseHandle-Funktion geschlossen werden. (Vergessen Sie dies übrigens nicht!) Dies geschieht nur, damit Sie jederzeit den Status des Threads überprüfen können.

Mit der WaitForMultipleObjects-Funktion (und ihren Analoga) können Sie also die Ausführung eines Threads mit dem Status von Synchronisierungsobjekten, insbesondere anderen Threads und Prozessen, synchronisieren.

Spezielle Kernel-Objekte

Kommen wir nun zur Betrachtung von Kernel-Objekten, die speziell für die Synchronisation ausgelegt sind. Dies sind Ereignisse, Semaphore und Mutexe. Werfen wir einen kurzen Blick auf jeden von ihnen:

Veranstaltung

Vielleicht das einfachste und grundlegendste Synchronisierungsobjekt. Dies ist nur ein Flag, das mit den Funktionen SetEvent / ResetEvent gesetzt werden kann: signalisierend oder neutral. Ein Ereignis ist die bequemste Art, einem wartenden Thread zu signalisieren, dass ein Ereignis eingetreten ist (so heißt es) und Sie weiterarbeiten können. Mit einem Event können wir das Synchronisationsproblem beim Initialisieren eines Worker-Threads einfach lösen:

// Lassen Sie uns das Event-Handle der Einfachheit halber in einer globalen Variablen belassen:

HANDLE g_hEventInitComplete = NULL ; // lasse niemals eine Variable uninitialisiert!

{ // Code im Hauptthread

// Ereignis erstellen

g_hEventInitComplete = :: Ereignis erstellen ( NULL,

FALSCH , // über diesen Parameter sprechen wir später

FALSCH , // Anfangszustand - neutral

wenn(! g_hEventInitComplete ) { /* Fehlerbehandlung nicht vergessen */ }

// Worker-Thread erstellen

DWORD idWorkerThread = 0 ;

HANDLE hWorkerThread = :: Erstelle einen Thread ( NULL , 0 , & WorkerThreadProc , NULL , 0 , & idWorkerThread );

wenn(! hWorkerThread ) { /* Fehlerbehandlung */ }

// Auf ein Signal vom Worker-Thread warten

DWORD dwWaitResult = :: WaitForSingleObject ( g_hEventInitComplete , UNENDLICH );

wenn( dwWaitResult != WAIT_OBJECT_0 ) { /* Error */ }

// Jetzt können Sie sicher sein, dass der Worker-Thread die Initialisierung abgeschlossen hat.

VERIFIZIEREN (:: CloseHandle ( g_hEventInitComplete )); // Vergessen Sie nicht, unnötige Objekte zu schließen

g_hEventInitComplete = NULL ;

// Workflow-Funktion

DWORD WINAPI WorkerThreadProc ( LPVOID_parameter )

InitializeWorker (); // Initialisierung

// signalisieren, dass die Initialisierung abgeschlossen ist

BOOL ist in Ordnung = :: SetEvent ( g_hEventInitComplete );

wenn(! ist in Ordnung ) { /* Error */ }

Es sollte beachtet werden, dass es zwei deutlich unterschiedliche Arten von Ereignissen gibt. Wir können eine davon mit dem zweiten Parameter der Funktion CreateEvent auswählen. Wenn es TRUE ist, wird ein Ereignis erstellt, dessen Zustand nur manuell gesteuert wird, dh durch die Funktionen SetEvent/ResetEvent. Wenn es FALSE ist, wird ein Auto-Reset-Ereignis generiert. Das bedeutet, sobald ein Thread, der auf ein bestimmtes Ereignis wartet, durch ein Signal von diesem Ereignis freigegeben wird, wird er automatisch wieder in einen neutralen Zustand zurückgesetzt. Ihr Unterschied ist am deutlichsten in einer Situation, in der mehrere Threads gleichzeitig auf ein Ereignis warten. Ein manuell gesteuertes Event ist wie ein Startschuss. Sobald es in den signalisierten Zustand versetzt wird, werden alle Threads auf einmal freigegeben. Ein Auto-Reset-Ereignis hingegen ist wie ein U-Bahn-Drehkreuz: Es gibt nur einen Durchfluss frei und kehrt in einen neutralen Zustand zurück.

Mutex

Im Vergleich zu einer Veranstaltung ist dies ein spezielleres Objekt. Es wird normalerweise verwendet, um ein allgemeines Synchronisationsproblem zu lösen, z. B. den Zugriff auf eine Ressource, die von mehreren Threads gemeinsam genutzt wird. In vielerlei Hinsicht ähnelt es einem Auto-Reset-Ereignis. Der Hauptunterschied besteht darin, dass es eine spezielle Bindung an einen bestimmten Thread hat. Wenn sich der Mutex im signalisierten Zustand befindet, bedeutet dies, dass er frei ist und zu keinem Thread gehört. Sobald ein bestimmter Thread auf diesen Mutex gewartet hat, wird dieser in einen neutralen Zustand zurückgesetzt (hier ist es wie bei einem Auto-Reset-Event), und der Thread wird sein Besitzer, bis er den Mutex explizit mit der Funktion ReleaseMutex freigibt, oder beendet. Um also sicherzustellen, dass immer nur ein Thread mit gemeinsam genutzten Daten arbeitet, sollten alle Stellen, an denen eine solche Arbeit stattfindet, von einem Paar umgeben werden: WaitFor - ReleaseMutex :

HANDLE g_hMutex ;

// Lassen Sie das Mutex-Handle in einer globalen Variablen speichern. Natürlich muss es vor dem Start von Worker-Threads erstellt werden. Nehmen wir an, dass dies bereits geschehen ist.

int ich warte = :: WaitForSingleObject ( g_hMutex , UNENDLICH );

Schalter( ich warte ) {

Fall WAIT_OBJECT_0 : // Alles ist gut

Unterbrechung;

Fall WAIT_ABANDONED : /* Irgendein Thread endete und vergaß, ReleaseMutex aufzurufen. Höchstwahrscheinlich bedeutet dies einen Fehler in Ihrem Programm! Daher werden wir hier für alle Fälle ASSERT einfügen, aber in der endgültigen Version (Release) werden wir diesen Code als erfolgreich betrachten. */

BEHAUPTEN ( FALSCH );

Unterbrechung;

Ursprünglich:

// Fehlerbehandlung sollte hier sein.

// Ein Stück Code, der durch einen Mutex geschützt ist.

ProcessCommonData ();

VERIFIZIEREN (:: ReleaseMutex ( g_hMutex ));

Warum ist ein Mutex besser als ein Auto-Reset-Ereignis? Im obigen Beispiel könnte es auch verwendet werden, nur müsste ReleaseMutex durch SetEvent ersetzt werden. Es kann jedoch die folgende Schwierigkeit auftreten. Meistens müssen Sie an mehreren Stellen mit gemeinsam genutzten Daten arbeiten. Was passiert, wenn ProcessCommonData in unserem Beispiel eine Funktion aufruft, die mit denselben Daten arbeitet und die bereits ein eigenes Paar WaitFor - ReleaseMutex hat (in der Praxis sehr häufig)? Wenn wir ein Ereignis verwenden würden, würde das Programm offensichtlich hängen bleiben, da sich das Ereignis innerhalb des geschützten Blocks in einem neutralen Zustand befindet. Der Mutex ist komplizierter. Es bleibt immer im Signalisierungszustand für den Master-Thread, obwohl es für alle anderen Threads im neutralen Zustand ist. Wenn ein Thread den Mutex erworben hat, wird daher das erneute Aufrufen der WaitFor-Funktion nicht blockiert. Darüber hinaus ist auch ein Zähler in den Mutex eingebaut, sodass ReleaseMutex genauso oft aufgerufen werden muss wie Aufrufe von WaitFor . Somit können wir jeden Codeabschnitt, der mit gemeinsam genutzten Daten arbeitet, sicher mit einem WaitFor - ReleaseMute x -Paar schützen, ohne uns Sorgen machen zu müssen, dass dieser Code rekursiv aufgerufen werden kann. Dies macht den Mutex zu einem sehr einfach zu verwendenden Werkzeug.

Semaphor

Ein noch spezifischeres Synchronisationsobjekt. Ich muss gestehen, dass es in meiner Praxis noch keinen Fall gab, wo es nützlich wäre. Ein Semaphor dient dazu, die maximale Anzahl von Threads zu begrenzen, die gleichzeitig an einer Ressource arbeiten können. Im Wesentlichen ist ein Semaphor ein Ereignis mit einem Zähler. Solange dieser Zähler größer Null ist, befindet sich die Semaphore im Meldezustand. Jeder Aufruf von WaitFor verringert diesen Zähler jedoch um eins, bis er Null wird und die Semaphore in den neutralen Zustand übergeht. Wie ein Mutex hat ein Semaphor eine ReleaseSemaphor-Funktion, die einen Zähler erhöht. Im Gegensatz zu einem Mutex ist ein Semaphor jedoch nicht Thread-gebunden, und ein erneuter Aufruf von WaitFor/ReleaseSemaphor verringert/erhöht den Zähler.

Wie kann eine Semaphore verwendet werden? Beispielsweise kann es verwendet werden, um Multithreading künstlich einzuschränken. Wie bereits erwähnt, können zu viele gleichzeitig aktive Threads durch häufige Kontextwechsel die Performance des Gesamtsystems merklich mindern. Und wenn wir zu viele Worker-Threads erstellen mussten, können wir die Anzahl der gleichzeitig aktiven Threads auf eine Zahl in der Größenordnung der Anzahl der Prozessoren begrenzen.

Was kann man sonst noch über Kernel-Synchronisationsobjekte sagen? Es ist sehr praktisch, ihnen Namen zu geben. Alle Funktionen, die Synchronisationsobjekte erstellen, haben die entsprechenden Parameter: CreateEvent , CreateMutex , CreateSemaphore . Wenn Sie beispielsweise CreateEvent zweimal aufrufen und beide Male denselben nicht leeren Namen angeben, gibt die Funktion beim zweiten Mal, anstatt ein neues Objekt zu erstellen, das Handle eines vorhandenen Objekts zurück. Dies geschieht auch dann, wenn der zweite Aufruf von einem anderen Prozess stammt. Letzteres ist sehr praktisch, wenn Sie Threads synchronisieren möchten, die zu verschiedenen Prozessen gehören.

Wenn Sie das Synchronisationsobjekt nicht mehr benötigen, vergessen Sie nicht, die CloseHandle-Funktion aufzurufen, die ich bereits erwähnt habe, als ich über Threads gesprochen habe. Tatsächlich wird das Objekt nicht unbedingt sofort gelöscht. Der Punkt ist, dass ein Objekt mehrere Griffe haben kann und dann erst gelöscht wird, wenn der letzte geschlossen ist.

Daran möchte ich Sie erinnern Der beste Weg Um sicherzustellen, dass CloseHandle oder eine ähnliche "Aufräum"-Funktion sicher aufgerufen wird, selbst im Falle einer anormalen Situation, müssen Sie sie in einen Destruktor einfügen. Das wurde übrigens mal gut und ausführlich im Artikel von Kirill Pleshivtsev „Smart Destructor“ beschrieben. In den obigen Beispielen habe ich diese Technik nicht nur für Bildungszwecke verwendet, damit die Arbeit der API-Funktionen visueller war. In echtem Code sollten Sie immer Wrapper-Klassen mit intelligenten Destruktoren zur Bereinigung verwenden.

Bei der ReleaseMutex-Funktion und Co. tritt übrigens ständig das gleiche Problem auf wie bei CloseHandle . Es muss am Ende der Arbeit mit gemeinsam genutzten Daten aufgerufen werden, unabhängig davon, wie erfolgreich diese Arbeit abgeschlossen wurde (schließlich könnte eine Ausnahme geworfen werden). Die Folgen des „Vergessens“ sind hier gravierender. Wenn CloseHandle nicht aufgerufen wird, werden nur Ressourcen verloren gehen (was ebenfalls schlecht ist!). Dann verhindert ein nicht freigegebener Mutex, dass andere Threads mit der gemeinsam genutzten Ressource arbeiten, bis der fehlgeschlagene Thread beendet ist, wodurch die Anwendung höchstwahrscheinlich nicht normal funktionieren kann. Um dies zu vermeiden, hilft uns wieder eine speziell geschulte Klasse mit einem smarten Destruktor.

Zum Abschluss der Überprüfung der Synchronisierungsobjekte möchte ich ein Objekt erwähnen, das nicht in der Win32-API enthalten ist. Viele meiner Kollegen wundern sich, warum Win32 kein spezielles „Einer schreibt, viele liest“-Objekt hat. Eine Art „erweiterter Mutex“, der sicherstellt, dass nur ein Thread gleichzeitig auf gemeinsame Daten zum Schreiben zugreifen kann und mehrere Threads gleichzeitig nur lesen können. Ein ähnliches Objekt findet sich in UNIX "ah. Einige Bibliotheken, zum Beispiel von Borland, bieten an, es auf der Grundlage von Standard-Synchronisationsobjekten zu emulieren. Der wirkliche Nutzen solcher Emulationen ist jedoch sehr zweifelhaft. Ein solches Objekt kann nur bei effektiv implementiert werden die Ebene des Betriebssystem-Kernels, aber im Windows-Kernel gibt es kein solches Objekt.

Warum haben sich die Entwickler des Windows-NT-Kernels nicht darum gekümmert? Warum sind wir schlechter als UNIX? Meines Erachtens lautet die Antwort, dass es für Windows einfach noch keinen wirklichen Bedarf für ein solches Objekt gegeben hat. Auf einer normalen Einprozessormaschine, auf der Threads physisch immer noch nicht gleichzeitig arbeiten können, entspricht dies praktisch einem Mutex. Auf einem Computer mit mehreren Prozessoren kann es davon profitieren, dass die Reader-Threads parallel ausgeführt werden können. Gleichzeitig wird dieser Gewinn erst dann greifbar, wenn die Wahrscheinlichkeit einer „Kollision“ von Lese-Threads hoch ist. Zweifellos ist ein solches Kernel-Objekt beispielsweise auf einem 1024-Prozessor-Computer von entscheidender Bedeutung. Es gibt ähnliche Maschinen, aber es handelt sich um spezialisierte Systeme, auf denen spezialisierte Betriebssysteme ausgeführt werden. Oft sind solche Betriebssysteme auf der Basis von UNIX aufgebaut, wahrscheinlich gelangte daher ein Objekt wie „einer schreibt, viele lesen“ in häufiger verwendete Versionen dieses Systems. Aber auf den uns gewohnten x86-Rechnern sind in der Regel nur ein und nur vereinzelt zwei Prozessoren verbaut. Und nur die fortschrittlichsten Modelle von Prozessoren wie Intel Xeon unterstützen 4 oder noch mehr Prozessorkonfigurationen, aber solche Systeme bleiben immer noch exotisch. Aber selbst auf einem so "fortgeschrittenen" System kann ein "fortgeschrittener Mutex" nur in ganz bestimmten Situationen einen spürbaren Leistungsgewinn bringen.

Daher ist die Implementierung eines "fortgeschrittenen" Mutex einfach nicht der Mühe wert. Auf einer Maschine mit "niedrigem Prozessor" kann es aufgrund der Komplexität der Objektlogik im Vergleich zu einem Standard-Mutex sogar weniger effizient sein. Bitte beachten Sie, dass die Implementierung eines solchen Objekts nicht so einfach ist, wie es auf den ersten Blick scheinen mag. Wenn es bei einer erfolglosen Implementierung zu viele Lese-Threads gibt, kommt der Schreib-Thread einfach nicht zu den Daten „durch“. Aus diesen Gründen empfehle ich auch nicht, dass Sie versuchen, ein solches Objekt zu emulieren. In realen Anwendungen auf realen Maschinen wird ein regulärer Mutex oder kritischer Abschnitt (der im nächsten Teil des Artikels besprochen wird) die Aufgabe der Synchronisierung des Zugriffs auf gemeinsam genutzte Daten perfekt bewältigen. Obwohl ich vermute, dass mit der Entwicklung des Windows-Betriebssystems früher oder später das Kernel-Objekt "one writes, many reads" erscheinen wird.

Notiz. Tatsächlich existiert das Objekt „Einer schreibt – viele lesen“ in Windows NT immer noch. Ich wusste es nur nicht, als ich diesen Artikel schrieb. Dieses Objekt wird "Kernel-Ressourcen" genannt und ist für Programme im Benutzermodus nicht zugänglich, weshalb es wahrscheinlich nicht bekannt ist. Ähnlichkeiten dazu finden sich im DDK. Danke an Konstantin Manurin für den Hinweis.

Sackgasse

Kommen wir nun zurück zur WaitForMultipleObjects-Funktion, genauer gesagt zu ihrem dritten Parameter, bWaitAll. Ich habe versprochen, Ihnen zu sagen, warum die Fähigkeit, auf mehrere Objekte gleichzeitig zu warten, so wichtig ist.

Warum eine Funktion benötigt wird, um auf eines von mehreren Objekten zu warten, ist verständlich. In Ermangelung einer speziellen Funktion könnte dies erfolgen, außer durch sequentielles Überprüfen des Zustands von Objekten in einer leeren Schleife, was natürlich nicht akzeptabel ist. Die Notwendigkeit einer speziellen Funktion, mit der Sie auf den Moment warten können, in dem mehrere Objekte gleichzeitig in den Signalzustand wechseln, ist jedoch nicht so offensichtlich. Stellen Sie sich die folgende typische Situation vor: Zu einem bestimmten Zeitpunkt benötigt unser Thread gleichzeitig Zugriff auf zwei Sätze gemeinsam genutzter Daten, von denen jeder für seinen eigenen Mutex verantwortlich ist, nennen wir sie A und B. Es scheint, dass der Thread dies kann Warten Sie zuerst, bis Mutex A freigegeben wird, erfassen Sie es, und warten Sie dann, bis Mutex B freigegeben wird ... Es scheint, dass wir mit ein paar Aufrufen von WaitForSingleObject auskommen. Das funktioniert in der Tat, aber nur so lange, wie alle anderen Threads die Mutexe in der gleichen Reihenfolge erwerben: zuerst A, dann B. Was passiert, wenn ein bestimmter Thread versucht, das Gegenteil zu tun: zuerst B, dann A erwerben? Früher oder später wird eine Situation entstehen, in der ein Thread Mutex A erfasst hat, ein anderer B, der erste wartet darauf, dass B freigegeben wird, der zweite A. Es ist klar, dass sie niemals darauf warten werden und das Programm hängen bleibt.

Diese Art von Deadlock ist ein sehr häufiger Fehler. Wie alle Synchronisationsfehler tritt er nur ab und zu auf und kann einem Programmierer viele Nerven kosten. Gleichzeitig ist fast jedes Schema, das mehrere Synchronisationsobjekte umfasst, mit Deadlocks behaftet. Daher sollte diesem Problem beim Entwurf einer solchen Schaltung besondere Aufmerksamkeit geschenkt werden.

In dem angegebenen einfachen Beispiel lässt sich das Blockieren ziemlich leicht vermeiden. Es ist erforderlich, dass alle Threads Mutexe in einer bestimmten Reihenfolge erwerben: zuerst A, dann B. In einem komplexen Programm, in dem viele Objekte auf verschiedene Weise miteinander in Beziehung stehen, ist dies jedoch normalerweise nicht so einfach zu erreichen. Nicht zwei, sondern viele Objekte und Threads können an einer Sperre beteiligt sein. Daher die meisten zuverlässiger Weg Um Deadlocks in einer Situation zu vermeiden, in der ein Thread mehrere Synchronisierungsobjekte gleichzeitig benötigt, müssen sie alle mit einem Aufruf der Funktion WaitForMultipleObjects mit dem Parameter bWaitAll=TRUE erfasst werden. Ehrlich gesagt verlagern wir das Problem der Deadlocks in diesem Fall nur in den Kernel des Betriebssystems, aber die Hauptsache ist, dass wir uns nicht mehr darum kümmern werden. In einem komplexen Programm mit vielen Objekten ist es jedoch oft nicht einfach, alle WaitFor-Aufrufe an einem Ort zusammenzuführen und zu kombinieren, wenn es nicht immer möglich ist, sofort zu sagen, welche von ihnen für eine bestimmte Operation erforderlich sind.

Somit gibt es zwei Möglichkeiten, Deadlocks zu vermeiden. Sie müssen entweder sicherstellen, dass Synchronisierungsobjekte immer in genau derselben Reihenfolge von Threads erfasst werden, oder dass sie durch einen einzigen Aufruf von WaitForMultipleObjects erfasst werden. Das letztere Verfahren ist einfacher und bevorzugt. In der Praxis treten jedoch bei der Erfüllung beider Anforderungen immer wieder Schwierigkeiten auf, es ist notwendig, diese beiden Ansätze zu kombinieren. Das Entwerfen komplexer Zeitschaltkreise ist oft eine höchst nicht triviale Aufgabe.

Synchronisationsbeispiel

In den meisten typischen Situationen, wie den oben beschriebenen, ist es nicht schwierig, die Synchronisation zu organisieren, ein Ereignis oder ein Mutex reichen aus. Aber von Zeit zu Zeit gibt es komplexere Fälle, in denen die Lösung des Problems nicht so offensichtlich ist. Dies möchte ich an einem konkreten Beispiel aus meiner Praxis veranschaulichen. Wie Sie sehen werden, stellte sich die Lösung als überraschend einfach heraus, aber bevor ich sie fand, musste ich mehrere erfolglose Optionen ausprobieren.

Also die Aufgabe. Fast alle modernen Download-Manager oder einfach „Schaukelstühle“ haben die Möglichkeit, den Datenverkehr einzuschränken, sodass der im Hintergrund laufende „Schaukelstuhl“ den Benutzer beim Surfen im Internet nicht stark stört. Ich war dabei, ein ähnliches Programm zu entwickeln, und mir wurde die Aufgabe übertragen, genau ein solches „Feature“ zu implementieren. Mein Schaukelstuhl funktionierte nach dem klassischen Multithreading-Schema, bei dem jede Aufgabe, in diesem Fall das Herunterladen einer bestimmten Datei, von einem separaten Thread behandelt wird. Das Verkehrslimit sollte für alle Flüsse kumulativ sein. Das heißt, es musste sichergestellt werden, dass während eines bestimmten Zeitintervalls alle Streams nicht mehr als eine bestimmte Anzahl von Bytes von ihren Sockets lesen. Dieses Limit einfach gleichmäßig auf die Streams aufzuteilen, ist offensichtlich ineffizient, da das Herunterladen von Dateien sehr ungleichmäßig sein kann, eine wird schnell heruntergeladen, die andere langsam. Daher brauchen wir einen gemeinsamen Zähler für alle Threads, wie viele Bytes gelesen wurden und wie viele noch gelesen werden können. Hier kommt die Synchronisierung ins Spiel. Eine zusätzliche Komplexität der Aufgabe ergab sich aus der Anforderung, dass jeder der Worker-Threads jederzeit gestoppt werden konnte.

Lassen Sie uns das Problem genauer formulieren. Ich habe mich entschieden, das Synchronisationssystem in eine spezielle Klasse einzuschließen. Hier ist seine Schnittstelle:

Klasse CQuote {

Öffentlichkeit: // Methoden

Leere einstellen ( unsigned int _nQuote );

unsigned int Anfrage ( unsigned int _nBytesToRead , HANDLE_hStopEvent );

Leere Veröffentlichung ( unsigned int _nBytesRevert , HANDLE_hStopEvent );

In regelmäßigen Abständen, beispielsweise einmal pro Sekunde, ruft der Steuerthread die Set-Methode auf und legt das Download-Kontingent fest. Bevor der Worker-Thread die vom Netzwerk empfangenen Daten liest, ruft er die Request-Methode auf, die überprüft, ob das aktuelle Kontingent nicht null ist, und falls ja, die Anzahl der Bytes zurückgibt, die weniger als das aktuelle Kontingent gelesen werden können. Das Kontingent reduziert sich entsprechend um diese Zahl. Wenn das Kontingent null ist, wenn Request aufgerufen wird, muss der aufrufende Thread warten, bis das Kontingent verfügbar ist. Manchmal kommt es vor, dass tatsächlich weniger Bytes empfangen werden als angefordert, in diesem Fall gibt der Thread einen Teil des ihm von der Release-Methode zugewiesenen Kontingents zurück. Und wie gesagt, der Benutzer kann jederzeit den Befehl geben, den Download zu stoppen. In diesem Fall muss das Warten unabhängig vom Vorhandensein eines Kontingents unterbrochen werden. Dazu wird ein spezielles Event verwendet: _hStopEvent. Da Tasks unabhängig voneinander gestartet und gestoppt werden können, hat jeder Worker-Thread sein eigenes Stoppereignis. Sein Handle wird an die Request- und Release-Methoden übergeben.

Bei einer der erfolglosen Optionen habe ich versucht, eine Kombination aus einem Mutex zu verwenden, der den Zugriff auf die CQuota-Klasse synchronisiert, und einem Ereignis, das das Vorhandensein einer Quote signalisiert. Das Stoppereignis passt jedoch nicht in dieses Schema. Wenn ein Thread eine Quote erwerben möchte, muss sein Wartezustand durch einen komplexen booleschen Ausdruck gesteuert werden: ((Mutex UND Quotenereignis) ODER Stoppereignis). WaitForMultipleObjects erlaubt dies aber nicht, man kann mehrere Kernel-Objekte entweder mit einer AND- oder OR-Operation kombinieren, aber nicht mischen. Der Versuch, die Wartezeit durch zwei aufeinanderfolgende Aufrufe von WaitForMultipleObjects aufzuteilen, führt unweigerlich zu einem Deadlock. Insgesamt entpuppte sich dieser Weg als Sackgasse.

Ich lasse den Nebel nicht mehr herein und verrate euch die Lösung. Wie gesagt, ein Mutex ist einem Auto-Reset-Ereignis sehr ähnlich. Und hier haben wir genau den seltenen Fall, in dem es bequemer ist, es zu verwenden, aber nicht eins, sondern zwei auf einmal:

Klasse CQuote {

Privatgelände: // Daten

unsigned int m_nQuota ;

CEvent m_eventHasQuota ;

CEvent m_eventNoQuota ;

Es kann jeweils nur eines dieser Ereignisse eingestellt werden. Jeder Thread, der das Kontingent manipuliert, muss das erste Ereignis setzen, wenn das verbleibende Kontingent ungleich Null ist, und das zweite, wenn das Kontingent erschöpft ist. Ein Thread, der ein Kontingent erhalten möchte, muss auf das erste Ereignis warten. Der Kontingent-erhöhende Thread muss nur auf eines dieser Ereignisse warten, denn wenn sich beide im zurückgesetzten Zustand befinden, bedeutet dies, dass ein anderer Thread gerade mit dem Kontingent arbeitet. Somit führen zwei Ereignisse gleichzeitig zwei Funktionen aus: Datenzugriffssynchronisation und Warten. Da der Thread schließlich auf eines der beiden Ereignisse wartet, kann das Ereignis, das das Stoppen signalisiert, problemlos eingeschlossen werden.

Ich werde ein Beispiel für die Implementierung der Request-Methode geben. Der Rest wird auf ähnliche Weise implementiert. Ich habe den im realen Projekt verwendeten Code leicht vereinfacht:

unsigned int CQuote :: Anfrage ( unsigned int _nAnfrage , HANDLE_hStopEvent )

wenn(! _nAnfrage ) Rückkehr 0 ;

unsigned int nBereitstellen = 0 ;

HANDLE hEvents [ 2 ];

hEreignisse [ 0 ] = _hStopEvent ; // Stop-Event hat höhere Priorität. Wir stellen es an die erste Stelle.

hEreignisse [ 1 ] = m_eventHasQuota ;

int iWaitResult = :: WaitForMultipleObjects ( 2 , hEreignisse , FALSCH , UNENDLICH );

Schalter( iWaitResult ) {

Fall WAIT_FAILED :

// ERROR

neu werfen CWin32Exception ;

Fall WAIT_OBJECT_0 :

// Ereignis stoppen. Ich habe es mit einer benutzerdefinierten Ausnahme behandelt, aber nichts hindert mich daran, es auf andere Weise zu implementieren.

neu werfen CStopException ;

Fall WAIT_OBJECT_0 + 1 :

// Ereignis "Kontingent verfügbar"

BEHAUPTEN ( m_nQuota ); // Wenn das Signal von diesem Event kam, aber eigentlich keine Quote vorhanden ist, dann haben wir irgendwo einen Fehler gemacht. Muss nach dem Fehler suchen!

wenn( _nAnfrage >= m_nQuota ) {

nBereitstellen = m_nQuota ;

m_nQuota = 0 ;

m_eventNoQuota . einstellen ();

anders {

nBereitstellen = _nAnfrage ;

m_nQuota -= _nAnfrage ;

m_eventHasQuota . einstellen ();

Unterbrechung;

Rückkehr nBereitstellen ;

Eine kleine Anmerkung. Die MFC-Bibliothek wurde in diesem Projekt nicht verwendet, aber wie Sie wahrscheinlich schon erraten haben, habe ich meine eigene CEvent-Klasse erstellt, einen Wrapper um das Kernel-Objekt "event", ähnlich dem MFC "schnoy". Wie gesagt, solche einfachen Wrapper-Klassen sind sehr nützlich, wenn es Ressourcen (in diesem Fall ein Kernel-Objekt) gibt, die am Ende der Arbeit freigegeben werden müssen. Im Übrigen spielt es keine Rolle, ob Sie SetEvent(m_hEvent) oder m_event.Set( ).

Ich hoffe, dieses Beispiel hilft Ihnen, Ihr eigenes Zeitschema zu entwerfen, wenn Sie auf eine nicht triviale Situation stoßen. Die Hauptsache ist, Ihr Schema so sorgfältig wie möglich zu analysieren. Könnte es eine Situation geben, in der es nicht richtig funktioniert, insbesondere kann es zu Blockierungen kommen? Solche Fehler im Debugger abzufangen ist meist ein aussichtsloses Geschäft, hier hilft nur eine detaillierte Analyse.

Also haben wir uns überlegt Notwendiges Werkzeug Thread-Synchronisation: Kernel-Synchronisationsobjekte. Es ist ein leistungsstarkes und vielseitiges Werkzeug. Damit können Sie selbst sehr komplexe Synchronisationsschemata erstellen. Glücklicherweise sind solche nicht trivialen Situationen selten. Zudem geht Vielseitigkeit immer auf Kosten der Leistung. Daher lohnt es sich in vielen Fällen, die anderen in Windows verfügbaren Thread-Synchronisationsfunktionen wie kritische Abschnitte und atomare Operationen zu verwenden. Sie sind nicht so universell, aber sie sind einfach und effektiv. Wir werden im nächsten Teil darüber sprechen.

Ein Prozess ist eine Instanz eines Programms, das in den Speicher geladen wird. Diese Instanz kann Threads erstellen, bei denen es sich um eine Abfolge von auszuführenden Anweisungen handelt. Es ist wichtig zu verstehen, dass keine Prozesse ausgeführt werden, sondern Threads.

Darüber hinaus hat jeder Prozess mindestens einen Thread. Dieser Thread wird als Haupt- (Haupt-)Thread der Anwendung bezeichnet.

Da es fast immer viel mehr Threads gibt, als physikalische Prozessoren für deren Ausführung vorhanden sind, werden die Threads tatsächlich nicht gleichzeitig ausgeführt, sondern der Reihe nach (die Verteilung der Prozessorzeit erfolgt genau zwischen den Threads). Aber zwischen ihnen wird so oft gewechselt, dass es scheint, als würden sie parallel laufen.

Threads können sich je nach Situation in drei Zuständen befinden. Erstens kann ein Thread ausgeführt werden, wenn ihm CPU-Zeit gegeben wird, d.h. es kann aktiv sein. Zweitens kann es inaktiv sein und auf die Zuweisung eines Prozessors warten, d. h. in Bereitschaft sein. Und es gibt einen dritten, auch sehr wichtiger Zustand- Sperrzustand. Wenn ein Thread blockiert ist, wird ihm überhaupt keine Zeit zugewiesen. Normalerweise wird eine Sperre platziert, während auf ein Ereignis gewartet wird. Wenn dieses Ereignis eintritt, wird der Thread automatisch vom blockierten Zustand in den bereiten Zustand überführt. Zum Beispiel, wenn ein Thread Berechnungen durchführt, während ein anderer darauf warten muss, dass die Ergebnisse auf der Festplatte gespeichert werden. Die zweite könnte eine Schleife wie "while(!isCalcFinished) Continue;" verwenden, aber es ist in der Praxis leicht zu erkennen, dass der Prozessor zu 100 % ausgelastet ist, während diese Schleife läuft (dies wird aktives Warten genannt). Solche Schlaufen sollten nach Möglichkeit vermieden werden, wobei der Verriegelungsmechanismus eine unschätzbare Hilfe leistet. Der zweite Thread kann sich selbst blockieren, bis der erste Thread ein Ereignis setzt, um zu signalisieren, dass der Lesevorgang abgeschlossen ist.

Thread-Synchronisierung im Windows-Betriebssystem

Windows implementiert preemptives Multitasking, was bedeutet, dass das System jederzeit die Ausführung eines Threads unterbrechen und die Steuerung an einen anderen übertragen kann. Zuvor wurde in Windows 3.1 eine Organisationsmethode namens kooperatives Multitasking verwendet: Das System wartete, bis der Thread selbst die Kontrolle an es übergab, und deshalb musste der Computer neu gestartet werden, wenn eine Anwendung einfriert.

Alle Threads, die zu demselben Prozess gehören, teilen sich einige gemeinsame Ressourcen, wie z. B. RAM-Adressraum oder offene Dateien. Diese Ressourcen gehören zum gesamten Prozess und damit zu jedem seiner Threads. Daher kann jeder Thread uneingeschränkt mit diesen Ressourcen arbeiten. Aber ... Wenn ein Thread die Arbeit mit einer gemeinsam genutzten Ressource noch nicht beendet hat und das System zu einem anderen Thread gewechselt ist, der dieselbe Ressource verwendet, kann das Ergebnis der Arbeit dieser Threads extrem von dem abweichen, was beabsichtigt war. Solche Konflikte können auch zwischen Threads entstehen, die zu unterschiedlichen Prozessen gehören. Dieses Problem tritt immer dann auf, wenn zwei oder mehr Threads eine gemeinsam genutzte Ressource verwenden.

Beispiel. Nicht synchrone Threads: Wenn Sie den Anzeigethread vorübergehend anhalten (pausieren), wird der Thread zum Füllen des Arrays im Hintergrund weiter ausgeführt.

#enthalten #enthalten int ein; GRIFF hThr; unsigned long uThrID; void Thread(void* pParams) ( int i, num = 0; while (1) ( for (i=0; i<5; i++) a[i] = num; num++; } } int main(void) { hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) printf("%d %d %d %d %d\n", a, a, a, a, a); return 0; }

Aus diesem Grund wird ein Mechanismus benötigt, der es Threads ermöglicht, ihre Arbeit mit gemeinsam genutzten Ressourcen zu koordinieren. Dieser Mechanismus wird als Thread-Synchronisationsmechanismus bezeichnet.

Dieser Mechanismus besteht aus einer Reihe von Betriebssystemobjekten, die von Software erstellt und verwaltet werden, die allen Threads im System gemeinsam sind (einige werden von Threads gemeinsam genutzt, die zum selben Prozess gehören) und zur Koordinierung des Zugriffs auf Ressourcen verwendet werden. Ressourcen können alles sein, was von zwei oder mehr Threads gemeinsam genutzt werden kann – eine Datei auf der Festplatte, ein Port, ein Datenbankeintrag, ein GDI-Objekt und sogar eine globale Programmvariable (auf die von Threads zugegriffen werden kann, die zu demselben Prozess gehören).

Es gibt mehrere Synchronisationsobjekte, von denen die wichtigsten Mutex, Critical Section, Event und Semaphore sind. Jedes dieser Objekte implementiert seine eigene Synchronisationsmethode. Außerdem können Prozesse und Threads selbst als Synchronisationsobjekte verwendet werden (wenn ein Thread auf die Beendigung eines anderen Threads oder Prozesses wartet); sowie Dateien, Kommunikationsgeräte, Konsoleneingaben und Änderungsbenachrichtigungen.

Jedes Synchronisationsobjekt kann sich im sogenannten signalisierten Zustand befinden. Für jeden Objekttyp hat dieser Zustand eine andere Bedeutung. Threads können den aktuellen Zustand eines Objekts überprüfen und/oder darauf warten, dass sich dieser Zustand ändert, und so ihre Aktionen koordinieren. Dadurch wird sichergestellt, dass, wenn ein Thread mit Synchronisierungsobjekten arbeitet (sie erstellt, den Status ändert), das System seine Ausführung nicht unterbricht, bis es diese Aktion abgeschlossen hat. Daher sind alle abschließenden Operationen an Synchronisationsobjekten atomar (unteilbar.

Arbeiten mit Synchronisationsobjekten

Um das eine oder andere Synchronisationsobjekt zu erstellen, wird eine spezielle WinAPI-Funktion vom Typ Create... aufgerufen (zB CreateMutex). Dieser Aufruf gibt ein Objekt-Handle (HANDLE) zurück, das von allen Threads verwendet werden kann, die zu dem gegebenen Prozess gehören. Es ist möglich, von einem anderen Prozess aus auf das Synchronisationsobjekt zuzugreifen, entweder durch Erben des Handles des Objekts oder vorzugsweise durch Aufrufen der Open...-Funktion des Objekts. Nach diesem Aufruf erhält der Prozess ein Handle, mit dem später mit dem Objekt gearbeitet werden kann. Einem Objekt muss ein Name gegeben werden, es sei denn, es soll innerhalb eines einzelnen Prozesses verwendet werden. Die Namen aller Objekte müssen unterschiedlich sein (auch wenn sie unterschiedlichen Typs sind). Sie können beispielsweise ein Ereignis und eine Semaphore nicht mit demselben Namen erstellen.

Durch den verfügbaren Deskriptor eines Objekts können Sie seinen aktuellen Zustand bestimmen. Dies geschieht mit Hilfe des sog. anstehende Funktionen. Die am häufigsten verwendete Funktion ist WaitForSingleObject. Diese Funktion benötigt zwei Parameter, der erste ist das Objekt-Handle, der zweite ist das Timeout in ms. Die Funktion gibt WAIT_OBJECT_0 zurück, wenn sich das Objekt im signalisierten Zustand befindet, WAIT_TIMEOUT, wenn die Zeitüberschreitung abgelaufen ist, und WAIT_ABANDONED, wenn der Mutex nicht freigegeben wurde, bevor der besitzende Thread beendet wurde. Wenn das Timeout als Null angegeben ist, kehrt die Funktion sofort zurück, andernfalls wartet sie die angegebene Zeitdauer. Wenn der Zustand des Objekts vor Ablauf dieser Zeit signalisiert wird, gibt die Funktion WAIT_OBJECT_0 zurück, andernfalls gibt die Funktion WAIT_TIMEOUT zurück. Wird als Zeit die symbolische Konstante INFINITE angegeben, wartet die Funktion unendlich lange, bis der Zustand des Objekts signalisiert wird.

Es ist sehr wichtig, dass der Aufruf der wartenden Funktion den aktuellen Thread blockiert, d.h. während ein Thread im Leerlauf ist, wird ihm keine Prozessorzeit zugewiesen.

Kritische Abschnitte

Ein objektkritischer Abschnitt hilft dem Programmierer, den Codeabschnitt zu isolieren, in dem ein Thread auf eine gemeinsam genutzte Ressource zugreift, und verhindert die gleichzeitige Verwendung der Ressource. Vor der Verwendung der Ressource tritt der Thread in den kritischen Abschnitt ein (ruft die Funktion EnterCriticalSection auf). Wenn dann ein anderer Thread versucht, in denselben kritischen Abschnitt einzutreten, wird seine Ausführung angehalten, bis der erste Thread den Abschnitt mit einem Aufruf von LeaveCriticalSection verlässt. Wird nur für Threads in einem einzelnen Prozess verwendet. Die Reihenfolge des Betretens des kritischen Abschnitts ist nicht festgelegt.

Es gibt auch eine TryEnterCriticalSection-Funktion, die prüft, ob ein kritischer Abschnitt gerade belegt ist. Mit seiner Hilfe kann der Thread, der auf den Zugriff auf die Ressource wartet, nicht blockiert werden, sondern einige nützliche Aktionen ausführen.

Beispiel. Synchronisation von Threads mit kritischen Abschnitten.

#enthalten #enthalten CRITICAL_SECTION cs; int ein; GRIFF hThr; unsigned long uThrID; void Thread(void* pParams) ( int i, num = 0; while (1) ( EnterCriticalSection(&cs); for (i=0; i<5; i++) a[i] = num; num++; LeaveCriticalSection(&cs); } } int main(void) { InitializeCriticalSection(&cs); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { EnterCriticalSection(&cs); printf("%d %d %d %d %d\n", a, a, a, a, a); LeaveCriticalSection(&cs); } return 0; }

Gegenseitiger Ausschluss

Gegenseitige Ausschlussobjekte (Mutexe, Mutex - von MUTual EXclusion) ermöglichen es Ihnen, den gegenseitigen Ausschluss des Zugriffs auf eine gemeinsam genutzte Ressource zu koordinieren. Der signalisierte Zustand eines Objekts (also der "Set"-Zustand) entspricht dem Zeitpunkt, an dem das Objekt keinem Thread angehört und "gefangen" werden kann. Umgekehrt entspricht der Zustand "Zurücksetzen" (nicht signalisiert) dem Moment, in dem ein Thread dieses Objekt bereits besitzt. Der Zugriff auf ein Objekt wird gewährt, wenn der Thread, der das Objekt besitzt, es freigibt.

Zwei (oder mehr) Threads können einen Mutex mit demselben Namen erstellen, indem sie die Funktion CreateMutex aufrufen. Der erste Thread erstellt tatsächlich den Mutex, und die nächsten Threads erhalten ein Handle auf ein bereits vorhandenes Objekt. Dadurch können mehrere Threads ein Handle auf denselben Mutex erwerben, sodass sich der Programmierer keine Gedanken darüber machen muss, wer den Mutex tatsächlich erstellt. Wenn dieser Ansatz verwendet wird, ist es wünschenswert, das bInitialOwner-Flag auf FALSE zu setzen, da sonst einige Schwierigkeiten beim Bestimmen des tatsächlichen Erzeugers des Mutex auftreten.

Mehrere Threads können ein Handle für denselben Mutex erwerben, wodurch die Kommunikation zwischen Prozessen möglich wird. Sie können die folgenden Mechanismen für diesen Ansatz verwenden:

  • Ein mit der CreateProcess-Funktion erstellter untergeordneter Prozess kann das Mutex-Handle erben, wenn der lpMutexAttributes-Parameter angegeben wurde, als der Mutex von der CreateMutex-Funktion erstellt wurde.
  • Ein Thread kann mithilfe der DuplicateHandle-Funktion ein Duplikat eines vorhandenen Mutex erhalten.
  • Ein Thread kann den Namen eines vorhandenen Mutex angeben, wenn er die Funktionen OpenMutex oder CreateMutex aufruft.

Um einen Mutex zu deklarieren, der dem aktuellen Thread gehört, muss eine der ausstehenden Funktionen aufgerufen werden. Der Thread, dem das Objekt gehört, kann es beliebig oft "erfassen" (dies führt nicht zu einer Selbstsperre), muss es jedoch mit der ReleaseMutex-Funktion beliebig oft freigeben.

Um die Threads eines Prozesses zu synchronisieren, ist es effizienter, kritische Abschnitte zu verwenden.

Beispiel. Synchronisation von Threads mit Mutexe.

#enthalten #enthalten GRIFF hMutex; int ein; GRIFF hThr; unsigned long uThrID; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hMutex, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; ReleaseMutex(hMutex); } } int main(void) { hMutex=CreateMutex(NULL, FALSE, NULL); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hMutex, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); ReleaseMutex(hMutex); } return 0; }

Entwicklungen

Ereignisobjekte werden verwendet, um wartende Threads zu benachrichtigen, dass ein Ereignis aufgetreten ist. Es gibt zwei Arten von Ereignissen - mit manueller und automatischer Rücksetzung. Manuelles Zurücksetzen wird durch die ResetEvent-Funktion durchgeführt. Manuelle Reset-Ereignisse werden verwendet, um mehrere Threads gleichzeitig zu benachrichtigen. Wenn Sie ein Auto-Reset-Ereignis verwenden, erhält nur ein wartender Thread die Benachrichtigung und setzt seine Ausführung fort, der Rest wartet weiter.

Die CreateEvent-Funktion erstellt ein Ereignisobjekt, SetEvent - setzt das Ereignis auf den Signalzustand, ResetEvent - setzt das Ereignis zurück. Die PulseEvent-Funktion setzt das Ereignis und setzt es nach Wiederaufnahme der Threads, die auf dieses Ereignis warten (alle mit manuellem Reset und nur einer mit automatischem), zurück. Wenn keine Threads warten, setzt PulseEvent einfach das Ereignis zurück.

Beispiel. Synchronisierung von Threads mithilfe von Ereignissen.

#enthalten #enthalten HANDLE hEvent1, hEvent2; int ein; GRIFF hThr; unsigned long uThrID; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hEvent2, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; SetEvent(hEvent1); } } int main(void) { hEvent1=CreateEvent(NULL, FALSE, TRUE, NULL); hEvent2=CreateEvent(NULL, FALSE, FALSE, NULL); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hEvent1, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); SetEvent(hEvent2); } return 0; }

Semaphoren

Ein Semaphor-Objekt ist eigentlich ein Mutex-Objekt mit einem Zähler. Dieses Objekt lässt sich von einer bestimmten Anzahl von Threads „einfangen“. Danach ist ein "Erfassen" unmöglich, bis einer der zuvor "erfassten" Threads des Semaphors es freigibt. Semaphore werden verwendet, um die Anzahl der Threads zu begrenzen, die gleichzeitig auf eine Ressource zugreifen können. Bei der Initialisierung wird dem Objekt die maximale Anzahl an Threads übergeben, nach jedem „Capture“ wird der Semaphore-Zähler kleiner. Der Signalzustand entspricht einem Zählerwert größer Null. Wenn der Zähler Null ist, gilt die Semaphore als nicht gesetzt (zurückgesetzt).

Die CreateSemaphore-Funktion erstellt ein Semaphore-Objekt mit Angabe seines maximal möglichen Anfangswerts, OpenSemaphore - gibt ein Handle auf ein vorhandenes Semaphor zurück, das Semaphor wird mit Wartefunktionen erfasst, während der Semaphor-Wert um eins reduziert wird, ReleaseSemaphore - gibt das Semaphor frei mit eine Erhöhung des Semaphorwerts um den in der Parameternummer angegebenen Wert.

Beispiel. Synchronisation von Threads mit Hilfe von Semaphoren.

#enthalten #enthalten GRIFF hSem; int ein; GRIFF hThr; unsigned long uThrID; void Thread(void* pParams) ( int i, num = 0; while (1) ( WaitForSingleObject(hSem, INFINITE); for (i=0; i<5; i++) a[i] = num; num++; ReleaseSemaphore(hSem, 1, NULL); } } int main(void) { hSem=CreateSemaphore(NULL, 1, 1, "MySemaphore1"); hThr=CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)Thread,NULL,0,&uThrID); while(1) { WaitForSingleObject(hSem, INFINITE); printf("%d %d %d %d %d\n", a, a, a, a, a); ReleaseSemaphore(hSem, 1, NULL); } return 0; }

Geschützter Zugriff auf Variablen

Es gibt eine Reihe von Funktionen, die es Ihnen ermöglichen, mit globalen Variablen aus allen Threads zu arbeiten, ohne sich Gedanken über die Synchronisierung machen zu müssen, denn. diese Funktionen kümmern sich selbst darum - ihre Ausführung ist atomar. Dies sind die Funktionen InterlockedIncrement, InterlockedDecrement, InterlockedExchange, InterlockedExchangeAdd und InterlockedCompareExchange. Beispielsweise erhöht die InterlockedIncrement-Funktion den Wert einer 32-Bit-Variablen atomar um eins, was für verschiedene Zähler nützlich ist.

Um vollständige Informationen über den Zweck, die Verwendung und die Syntax aller WIN32-API-Funktionen zu erhalten, müssen Sie das MS SDK-Hilfesystem verwenden, das Teil der Programmierumgebungen Borland Delphi oder CBuilder ist, sowie MSDN, das als Teil von geliefert wird das Programmiersystem Visual C.


Bei Programmen, die mehrere Threads oder Prozesse verwenden, ist es erforderlich, dass alle die ihnen zugewiesenen Funktionen in der gewünschten Reihenfolge ausführen. In der Windows 9x-Umgebung wird zu diesem Zweck vorgeschlagen, mehrere Mechanismen zu verwenden, die den reibungslosen Betrieb von Threads gewährleisten. Diese Mechanismen werden aufgerufen Synchronisationsmechanismen. Angenommen, Sie entwickeln ein Programm, in dem zwei Threads parallel laufen. Jeder Thread greift auf eine gemeinsam genutzte globale Variable zu. Ein Thread erhöht sie jedes Mal, wenn auf diese Variable zugegriffen wird, und der zweite Thread verringert sie. Bei gleichzeitiger asynchroner Arbeit von Threads ergibt sich zwangsläufig folgende Situation: - der erste Thread hat den Wert einer globalen Variablen in eine lokale eingelesen; - Das OS unterbricht ihn, da das ihm zugeteilte Prozessorzeitquantum abgelaufen ist, und übergibt die Kontrolle an den zweiten Thread; - der zweite Thread hat auch den Wert der globalen Variable in eine lokale eingelesen, dekrementiert und den neuen Wert zurückgeschrieben; - Das Betriebssystem überträgt die Kontrolle wieder an den ersten Thread, der, ohne etwas über die Aktionen des zweiten Threads zu wissen, seine lokale Variable erhöht und ihren Wert in die globale Variable schreibt. Offensichtlich gehen die vom zweiten Thread vorgenommenen Änderungen verloren. Um solche Situationen zu vermeiden, ist es notwendig, die Verwendung gemeinsamer Daten rechtzeitig zu trennen. In solchen Fällen werden Synchronisationsmechanismen verwendet, die den korrekten Betrieb mehrerer Threads sicherstellen. Synchronisierungstools im BetriebssystemFenster: 1) Kritischer Abschnitt (KritischAbschnitt) ist ein Objekt, das zum Prozess gehört, nicht zum Kernel. Das bedeutet, dass es keine Threads von verschiedenen Prozessen synchronisieren kann. Es gibt auch Funktionen zum Initialisieren (Erstellen) und Löschen, Eintreten und Verlassen eines kritischen Abschnitts: Erstellen - InitializeCriticalSection(...), Löschen - DeleteCriticalSection(...), Eintragen - EnterCriticalSection(...), Beenden - LeaveCriticalSection (...). Einschränkungen: Da es sich nicht um ein Kernel-Objekt handelt, ist es für andere Prozesse nicht sichtbar, d. h. Sie können nur die Threads Ihres eigenen Prozesses schützen. Der kritische Abschnitt analysiert den Wert einer speziellen Prozessvariablen, die als Flag verwendet wird, um zu verhindern, dass mehrere Threads gleichzeitig einen Codeabschnitt ausführen. Unter den Synchronisierungsobjekten sind kritische Abschnitte die einfachsten. 2) mutexveränderlichausschließen. Dies ist ein Kernel-Objekt, es hat einen Namen, was bedeutet, dass sie verwendet werden können, um den Zugriff auf gemeinsame Daten von mehreren Prozessen zu synchronisieren, genauer gesagt von Threads verschiedener Prozesse. Kein anderer Thread kann einen Mutex erwerben, der bereits einem der Threads gehört. Wenn ein Mutex einige gemeinsam genutzte Daten schützt, kann er seine Funktion nur ausführen, wenn jeder Thread den Zustand dieses Mutex überprüft, bevor er auf diese Daten zugreift. Windows behandelt einen Mutex als gemeinsames Objekt, das signalisiert oder zurückgesetzt werden kann. Der signalisierte Zustand des Mutex zeigt an, dass er beschäftigt ist. Threads müssen den aktuellen Zustand der Mutexe unabhängig analysieren. Wenn Threads anderer Prozesse auf den Mutex zugreifen sollen, müssen Sie ihm einen Namen geben. Funktionen: CreateMutex(name) - Erstellung, hnd=OpenMutex(name) - öffnen, WaitForSingleObject(hnd) - warten und besetzen, ReleaseMutex(hnd) - freigeben, CloseHandle(hnd) - schließen. Es kann zum Schutz vor dem Neustart von Programmen verwendet werden. 3) Semaphor -Semaphor. Das Kernel-Objekt „semaphore“ dient der Ressourcenabrechnung und dient dazu, den gleichzeitigen Zugriff mehrerer Threads auf eine Ressource zu begrenzen. Mithilfe eines Semaphors können Sie die Arbeit des Programms so organisieren, dass mehrere Threads gleichzeitig auf die Ressource zugreifen können, die Anzahl dieser Threads jedoch begrenzt ist. Beim Erstellen eines Semaphors wird die maximale Anzahl von Threads angegeben, die gleichzeitig mit der Ressource arbeiten können. Jedes Mal, wenn ein Programm auf eine Semaphore zugreift, wird der Ressourcenzähler der Semaphore um eins dekrementiert. Wenn der Ressourcenzählerwert Null wird, ist die Semaphore nicht verfügbar. Erstellen Sie CreateSemaphore, öffnen Sie OpenSemaphore, nehmen Sie WaitForSingleObject, geben Sie ReleaseSemaphore frei 4 ) Veranstaltung -Veranstaltung. Ereignisse benachrichtigen normalerweise nur über das Ende einer Operation, sie sind auch Kernel-Objekte. Sie können nicht nur explizit freigeben, sondern es gibt auch eine Operation zum Setzen von Ereignissen. Ereignisse können manuell (manuell) und einzeln (einzeln) sein. Ein einzelnes Ereignis ist eher eine allgemeine Flagge. Ein Ereignis befindet sich im signalisierten Zustand, wenn es von einem Thread gesetzt wurde. Wenn das Programm verlangt, dass bei einem Ereignis nur einer der Threads darauf reagiert, während alle anderen Threads weiter warten, dann wird ein einzelnes Ereignis verwendet. Ein manuelles Ereignis ist nicht nur ein gemeinsames Flag für mehrere Threads. Es führt etwas komplexere Funktionen aus. Jeder Thread kann dieses Ereignis setzen oder zurücksetzen (löschen). Sobald ein Ereignis gesetzt ist, bleibt es beliebig lange in diesem Zustand, unabhängig davon, wie viele Threads darauf warten, dass das Ereignis gesetzt wird. Wenn alle Threads, die auf dieses Ereignis warten, eine Nachricht erhalten, dass das Ereignis aufgetreten ist, wird es automatisch zurückgesetzt. Funktionen: SetEvent, ClearEvent, WaitForEvent. Ereignistypen: 1) automatisches Reset-Ereignis: WaitForSingleEvent. 2) ein Event mit manuellem Reset (manuell), dann muss das Event zurückgesetzt werden: ReleaseEvent. Einige Theoretiker heben ein weiteres Synchronisationsobjekt heraus: WaitAbleTimer ist ein Objekt des Betriebssystemkerns, das nach einem bestimmten Zeitintervall (Wecker) selbstständig in einen freien Zustand wechselt.

Wenn Sie mit mehreren Threads oder Prozessen arbeiten, wird dies manchmal erforderlich Ausführung synchronisieren zwei oder mehr davon. Der Grund dafür ist meistens, dass zwei oder mehr Threads möglicherweise Zugriff auf eine gemeinsam genutzte Ressource benötigen Ja wirklich kann nicht mehreren Threads gleichzeitig zur Verfügung gestellt werden. Eine gemeinsam genutzte Ressource ist eine Ressource, auf die von mehreren laufenden Tasks gleichzeitig zugegriffen werden kann.

Der Mechanismus, der den Synchronisationsprozess sicherstellt, wird aufgerufen Zugriffsbeschränkung. Die Notwendigkeit dafür entsteht auch in Fällen, in denen ein Thread auf ein Ereignis wartet, das von einem anderen Thread generiert wird. Natürlich muss es eine Möglichkeit geben, den ersten Thread zu unterbrechen, bis das Ereignis eintritt. Danach sollte der Thread seine Ausführung fortsetzen.

Es gibt zwei allgemeine Zustände, in denen sich eine Aufgabe befinden kann. Erstens kann die Aufgabe ausgeführt werden(oder zur Ausführung bereit sein, sobald es Zugriff auf Prozessorressourcen hat). Zweitens kann die Aufgabe sein verstopft. In diesem Fall wird seine Ausführung ausgesetzt, bis die benötigte Ressource freigegeben wird oder ein bestimmtes Ereignis eintritt.

Windows verfügt über spezielle Dienste, mit denen Sie den Zugriff auf gemeinsam genutzte Ressourcen auf bestimmte Weise einschränken können, da ein separater Prozess oder Thread ohne die Hilfe des Betriebssystems nicht selbst feststellen kann, ob er den alleinigen Zugriff auf eine Ressource hat. Das Windows-Betriebssystem enthält eine Prozedur, die in einem fortlaufenden Vorgang das Ressourcenzugriffs-Flag prüft und wenn möglich setzt. In der Sprache der Betriebssystementwickler wird eine solche Operation genannt Funktion prüfen und installieren. Die Flags, die verwendet werden, um die Synchronisation sicherzustellen und den Zugriff auf Ressourcen zu steuern, werden aufgerufen Semaphoren(Semaphor). Die Win32-API bietet Unterstützung für Semaphore und andere Synchronisationsobjekte. Die MFC-Bibliothek enthält auch Unterstützung für diese Objekte.

Synchronisierungsobjekte und MFC-Klassen

Die Win32-Schnittstelle unterstützt vier Arten von Synchronisationsobjekten, die alle auf die eine oder andere Weise auf dem Konzept eines Semaphors basieren.

Der erste Objekttyp ist das Semaphor selbst oder klassische (Standard-)Semaphore. Es ermöglicht einer begrenzten Anzahl von Prozessen und Threads den Zugriff auf eine einzelne Ressource. In diesem Fall ist der Zugriff auf die Ressource entweder vollständig eingeschränkt (ein und nur ein Thread oder Prozess kann in einem bestimmten Zeitraum auf die Ressource zugreifen), oder nur eine kleine Anzahl von Threads und Prozessen erhalten gleichzeitigen Zugriff. Semaphoren werden mit einem Zähler implementiert, der dekrementiert, wenn einer Aufgabe eine Semaphore zugewiesen wird, und inkrementiert, wenn eine Aufgabe die Semaphore freigibt.

Die zweite Art von Synchronisationsobjekten ist exklusives (Mutex) Semaphor. Es wurde entwickelt, um den Zugriff auf eine Ressource vollständig einzuschränken, sodass zu einem bestimmten Zeitpunkt nur ein Prozess oder Thread auf die Ressource zugreifen kann. Tatsächlich ist dies eine spezielle Art von Semaphor.

Die dritte Art von Synchronisationsobjekten ist Veranstaltung, oder Ereignisobjekt. Es wird verwendet, um den Zugriff auf eine Ressource zu blockieren, bis ein anderer Prozess oder Thread erklärt, dass die Ressource verwendet werden kann. Somit signalisiert dieses Objekt die Ausführung des gewünschten Ereignisses.

Mit dem Synchronisationsobjekt des vierten Typs ist es möglich, die Ausführung bestimmter Abschnitte des Programmcodes durch mehrere Threads gleichzeitig zu untersagen. Dazu müssen diese Parzellen als deklariert werden Kritischer Abschnitt. Wenn ein Thread in diesen Abschnitt eintritt, ist es anderen Threads untersagt, dasselbe zu tun, bis der erste Thread diesen Abschnitt verlässt.

Kritische Abschnitte werden im Gegensatz zu anderen Arten von Synchronisationsobjekten nur zum Synchronisieren von Threads innerhalb eines einzelnen Prozesses verwendet. Andere Arten von Objekten können zum Synchronisieren von Threads innerhalb eines Prozesses oder zum Synchronisieren von Prozessen verwendet werden.

In MFC wird der von der Win32-Schnittstelle bereitgestellte Synchronisierungsmechanismus durch die folgenden Klassen unterstützt, die von der CSyncObject-Klasse abgeleitet sind:

    CCriticalSection- Implementiert einen kritischen Abschnitt.

    CEvent- implementiert das Ereignisobjekt

    CMutex- Implementiert eine exklusive Semaphore.

    CSemaphore- Implementiert eine klassische Semaphore.

Zusätzlich zu diesen Klassen definiert MFC auch zwei zusätzliche Synchronisierungsklassen: CSingleLock und CMultiLock. Sie steuern den Zugriff auf das Synchronisationsobjekt und enthalten die Methoden, die zum Gewähren und Freigeben solcher Objekte verwendet werden. Klasse CSingleLock steuert den Zugriff auf ein einzelnes Synchronisationsobjekt und die Klasse CMultiLock- auf mehrere Objekte. Im Folgenden betrachten wir nur die Klasse CSingleLock.

Wenn ein Synchronisationsobjekt erstellt wird, kann der Zugriff darauf mithilfe der Klasse gesteuert werden CSingleLock. Dazu müssen Sie zunächst ein Objekt vom Typ erstellen CSingleLock mit dem Konstruktor:

CSingleLock(CSyncObject* pObject, BOOL bInitialLock = FALSE);

Der erste Parameter ist ein Zeiger auf ein Synchronisationsobjekt, beispielsweise ein Semaphor. Der Wert des zweiten Parameters bestimmt, ob der Konstruktor versuchen soll, auf das angegebene Objekt zuzugreifen. Wenn dieser Parameter nicht Null ist, wird der Zugriff gewährt, andernfalls wird kein Zugriff versucht. Wenn Zugriff gewährt wird, dann der Thread, der das Klassenobjekt erstellt hat CSingleLock, wird angehalten, bis das entsprechende Synchronisationsobjekt von der Methode freigegeben wird Freischalten Klasse CSingleLock.

Sobald ein Objekt vom Typ CSingleLock erstellt wurde, kann der Zugriff auf das Objekt, auf das der pObject-Parameter zeigt, mit zwei Funktionen gesteuert werden: sperren und Freischalten Klasse CSingleLock.

Methode sperren ist für den Zugriff auf das Objekt auf das Synchronisationsobjekt ausgelegt. Der aufrufende Thread wird ausgesetzt, bis die Methode abgeschlossen ist, d. h. bis auf die Ressource zugegriffen wird. Der Wert des Parameters bestimmt, wie lange die Funktion wartet, um Zugriff auf das erforderliche Objekt zu erhalten. Jedes Mal, wenn die Methode erfolgreich abgeschlossen wird, wird der Wert des Zählers, der dem Synchronisationsobjekt zugeordnet ist, um eins verringert.

Methode Freischalten gibt das Synchronisierungsobjekt frei, sodass andere Threads die Ressource verwenden können. Bei der ersten Variante des Verfahrens wird der Wert des dem gegebenen Objekt zugeordneten Zählers um eins erhöht. Bei der zweiten Option bestimmt der erste Parameter, um wie viel dieser Wert erhöht werden soll. Der zweite Parameter zeigt auf eine Variable, in die der vorherige Wert des Zählers geschrieben wird.

Bei der Arbeit mit einer Klasse CSingleLock Das allgemeine Verfahren zum Steuern des Zugriffs auf eine Ressource ist wie folgt:

    Erstellen Sie ein Objekt vom Typ CSyncObj (z. B. ein Semaphor), das verwendet wird, um den Zugriff auf die Ressource zu steuern.

    Erstellen Sie mithilfe des erstellten Synchronisationsobjekts ein Objekt des Typs CSingleLock;

    rufen Sie die Lock-Methode auf, um Zugriff auf die Ressource zu erhalten;

    Rufen Sie die Ressource an;

    Rufen Sie die Unlock-Methode auf, um die Ressource freizugeben.

Im Folgenden wird beschrieben, wie Sie Semaphore und Ereignisobjekte erstellen und verwenden. Sobald Sie diese Konzepte verstanden haben, können Sie die beiden anderen Arten von Synchronisationsobjekten leicht erlernen und verwenden: kritische Abschnitte und Mutexe.