3.6 Synchronisation von Threads

In Windows NT gibt es wie unter anderen Betriebssystemen auch verschiedene Methoden um Threads zu synchronisieren.

 

3.6.1 Synchronisation durch Priorität

Durch das verändern der Priorität eines Threads kann man festlegen welcher Thread innerhalb eines Prozesses die meiste Rechenzeit zur Verfügung gestellt bekommt. Eine Synchronisation zwischen den Prozessen findet aber nicht statt. Deshalb darf man streng genommen hier nicht von einer Synchronisation sprechen sondern nur von der Bevorzugung eines bestimmten Threads. Die Funktionen, SetThreadPriority() und GetThreadPriority(), die hierfür benötigt werden sind im Kapitel 3.5 genauer beschrieben.

 

3.6.2 Synchronisation durch kritische Bereiche

Mit Hilfe von kritischen Bereichen erreicht man einen wechselseitigen Ausschluß von Threads. So das bestimmte Programmabschnitte immer nur von einem Thread ausgeführt werden können. Ein oder mehrere andere Threads die den kritischen Bereich betreten wollen müssen warten bis der Thread innerhalb des kritischen Bereiches diesen wieder verläßt (siehe Abb.3.3).


Abb. 3.3: Ausführung eines kritischen Bereiches von einem Thread [HaWi1997]

 

Der kritische Bereich wird dabei durch eine Variable überwacht. Diese Variable wird global initialisiert, damit jeder Thread auf diese Variable zugreifen kann.

Beispiel kritischer Bereich:

CRITICAL_SECTION csOutput ; // Initialisierung der Variable um
int zahl = 10;              // den kritischen Bereiches zu
                            // überwachen.
void Print ()
{
 _try              
 {                // Hier versucht der Thread in den kritischen
                  // Bereich zu gelangen indem er Zugriff auf
  EnterCriticalSection ( &csOutput );     // die Variable erhält.
  zahl -- ; 
  cout << “Die Zahl ist “ << zahl << endl; // Innerhalb des
                             // Bereiches wird die Variable zahl
 }                           // herabgezählt und ausgegeben. 
 _finally
 {
  LeaveCriticalSection ( &csOutput ); // Am Ende des kritischen 
 }                                    // Bereiches wird die 
}                               // Variable wieder “freigegeben“.

In diesem Beispiel hat der Einsatz des kritischen Bereiches den Vorteil, daß der selbe Thread, der die Variable "zahl" herabzählt diese auch ausgibt ohne das ein anderer Thread die Möglichkeit besitzt die Variable vor der Ausgabe zusätzlich herabzuzählen.

Kritische Bereiche werden in den Beispielprogrammen Semaphor und Philosophen eingesetzt (siehe Kapitel 6.2).

 

3.6.3 Synchronisation durch Ereignisse

In Windows NT ist ein Ereignis ein Synchronisationsobjekt, das von dem Betriebssystem verwaltet wird. Diesen Synchronisationsobjekten können Namen gegeben werden so das nicht nur Threads innerhalb eines Prozesses mit ihnen arbeiten können sondern auch mehrere Prozesse sich durch so ein Objekt synchronisieren können. Diese Synchronisationsart wird z.B. verwendet wenn ein Thread auf ein Ergebnis warten muß, daß von einem anderen Thread zuerst berechnet wird. Die Abbildung 3.4 zeigt einen allgemeinen Ablauf bei der Synchronisation durch Ereignisse.


Abb. 3.4: Grundprizip für die Verwendung von Ereignissen [HaWi1997]

 

Ein Ereignis kann in Windows NT zwei Zustände besitzen.

Bei der Synchronisation mit Hilfe von Ereignissen muß man als erstes einen sogenannten Ereignis-Handler erstellen. Dies geschieht durch die Funktion CreateEvent().

Beispiel CreateEvent():

HANDLE hEvent = CreateEvent ( NULL,
                              FALSE,
                              FALSE;
                              "EventName"
                             );
Parameter:

Als Rückgabewert dieser Funktion erhält man einen Handle auf das Ereignis oder NULL falls ein Fehler aufgetreten ist.

 

Ein anderer Prozess kann einen Handle zu dem selben Ereignis erhalten, indem er die CreateEvent() Funktion mit den selben Parameter aufruft oder die OpenEvent() Funktion benutzt.

Beispiel OpenEvent():

HANDLE hEvent = OpenEvent ( EVENT_ALL_ACCESS,
                            FALSE,
                            "EventName"
                           );
Parameter:

Als Rückgabewert dieser Funktion erhält man einen Handle auf das Ereignis oder NULL falls ein Fehler aufgetreten ist.

 

Event-Handle sollte man, wenn sie nicht mehr benötigt werden, schließen. Dies geschieht mit der Funktion CloseHandle() (siehe Kapitel 3.5).

Um ein Ereignis-Handle in den Zustand signalisierend zu setzen benötigt man die Funktion SetEvent().

Beispiel SetEvent():

SetEvent ( hEvent );

Parameter:

 

Da es zwei Arten von Ereignissen gibt und zwar das Auto-Reset-Ereignis und das Manueller-Reset-Ereignis gibt es Unterschiede im Zurücksetzen dieser beiden Ereignisarten.

Beim Auto-Reset-Ereignis folgt der Übergang in den nicht signalisierend Zustand automatisch nachdem eine Warte-Anfrage eines anderen Threads beantwortet wurde.

Beim Manueller-Reset-Ereignis muß das Ereignis durch die Funktion ResetEvent() manuell zurückgesetzt werden.

Beispiel ResetEvent():

ResetEvent ( hEvent );

Parameter:

 

Eine Besonderheit ist die PulseEvent() Funktion. Sie wird eingesetzt, wenn man mehrere Threads auf ein bestimmtes Ereignis warten lassen will. Da hierbei das Auto-Reset-Ereignis nach jeder Warte-Anfrage neu auf signalisierend gesetzt werden muß und es bei dem Manueller-Reset-Ereignis schwierig ist zu bestimmen ob nun wirklich alle wartenden Threads eine Antwort erhalten haben wird in so einer Situation die PulseEvent() Funktion verwendet.

Bei dieser Funktion wird das Ereignis solange auf signalisierend gesetzt, bis alle wartenden Threads eine Antwort erhalten haben.

Beispiel PulseEvent():

PulseEvent ( hEvent );

Parameter:

 

Es gibt zwei verschiedene Funktionen für eine Warte-Anfrage abhängig davon ob nur auf ein oder auf mehrere Ereignisse gewartet werden soll.

Bei der Funktion WaitForSingleObject() wird auf das Signal eines Ereignisses gewartet.

Bei der Funktion WaitForMultipleObjects() wird auf das Signal von mehreren Ereignissen gewartet.

Im ersten Moment könnte man annehmen das man anstatt zwei verschiedene Funktionen zu verwenden einfach die WaitForSingleObject() Funktion mehrmals hintereinander aufrufen kann. Bei so einem Vorgehen besteht aber die große Gefahr eines Deadlocks, da sich so mehrere Threads gegenseitig sperren können (siehe Abb. 3.5).

Bei der Verwendung der WaitForMultipleObjects() Funktion werden alle benötigte Ereignisse zur selben Zeit überwacht und entweder alle oder keines der Signale verwendet.


Abb. 3.5: Deadlock-Situation durch geschachtelte WaitForSingleObject() [HaWi1997]

 

Beispiel WaitForSingleObject():

DWORD dwResult = WaitForSingleObject ( hEvent,
                                       INFINITE
                                      );
Parameter:

Es gibt mehrere mögliche Rückgabewerte dieser Funktion:

 

Beispiel WaitForMultipleObjects():

HANDLE hEvents[2];

DWORD dwResult = WaitForSingleObject ( 2,
                                       hEvents,
                                       TRUE,
                                       INFINITE
                                      );
Parameter:

Die Rückgabewerte dieser Funktion unterscheiden sich etwas gegenüber der WaitForSingleObject() Funktion:

 

 

3.6.4 Synchronisation durch Mutex

Ein Mutex ist ein Windows-NT-Objekt, das für den wechselseitigen Ausschluß verwendet wird. Mutex ist sozusagen eine Mischung aus "kritische Bereiche" und "Ereignisse". Bei einem Mutex sind die Warte-Aufrufe WaitForSingleObject() und WaitForMultipleObjects() der Threads die selben wie bei Ereignissen. Andere Funktionen für die Arbeit mit Mutex-Objekten ähneln sehr der Funktionen die bei der Arbeit mit Ereignissen verwendet werden.

Ein Mutex-Objekt wird mit der Funktion CreateMutex() kreiert.

Beispiel CreateMutex():

HANDLE hMutex = CreateMutex ( NULL,
                              TRUE;
                              "MutexName"
                             );
Parameter:

Als Rückgabewert dieser Funktion erhält man einen Handle auf das Mutex oder NULL falls ein Fehler aufgetreten ist.

 

Möchte nun ein Thread in einen kritischen Bereich eintreten ruft er die Funktion WaitForSingleObject() oder WaitForMultipleObjects() auf. Ist der kritische Bereich frei so wird der Programmteil ausgeführt. Am Ende des kritischen Bereiches muß der Thread den Mutex wieder "frei" geben. Dies geschieht mit der Funktion RelaseMutex().

Wird der Zugriff eines kritischen Bereiches durch mehrere Mutexe gesteuert muß die Funktion RelaseMutex() sooft aufgerufen werden, bis alle Mutexe wieder freigegeben sind.

Beispiel RelaseMutex():

RelaseMutex ( hMutex );

Parameter:

 

Wie bei Ereignissen können auch Mutexe, wenn sie einen Namen haben, von anderen Prozessen benutzt werden. Dies geschieht durch die Funktion OpenMutex().

Beispiel OpenMutex():

HANDLE hMutex = OpenMutex ( MUTEX_ALL_ACCESS,
                            FALSE,
                            "MutexName"
                           );
Parameter:

 

Mutex-Handel sollte man, wenn sie nicht mehr benötigt werden, schließen. Dies geschieht mit der Funktion CloseHandle() (siehe Kapitel 3.5).

Die Anwendung eines Mutex wird im Beispielprogramm Philosophen (Kapitel 6.2.7) gezeigt.

 

3.6.5 Synchronisation durch Semaphore

Semaphore arbeiten in Windows NT nach dem selben Prinzip wie in Unix und Java. Es sind sozusagen Zähler dessen Werte erhöht oder herabgezählt werden je nachdem welche Operation auf so einen Semaphor einwirkt. Ist der interne Zähler NULL muß ein Thread warten, wenn er mit seiner Operation den internen Zähler herabsetzen will. (Ein kleines Beispiel für die Arbeitsweise eines Semaphores ist im Kapitel 4.6.1 beschrieben.)

Als erstes muß ein Semaphor mit der Funktion CreateSemaphor() erzeugt werden damit später die Threads mit ihm arbeiten können. In Windows NT wird der Zugang zu einem kritischen Bereich, auch bei der Verwendung eines Semaphores zur Synchronisation, durch die schon oben erläuterte WaitForSingleObject() oder bei der Verwendung von mehreren Semaphoren durch die WaitForMultipleObjects() Funktion erreicht. Wie bei der Synchronisation durch Mutex muß hier auch beim Verlassen des kritischen Bereiches ein Funktionsaufruf erfolgen, der in diesem Fall den internen Zähler des Semaphores wieder erhöht. Diese Funktion lautet ReleaseSemaphore(). Wie bei den anderen Synchronisationsarten sollte auch hier der Handle des Semaphores durch die Funktion CloseHandle() (siehe Kapitel 3.5) geschlossen werden.

Damit andere Prozesse auf ein durch CreateSemaphor() erstelltes Semaphor zugreifen können benötigt man auch hier eine "Open" Funktion und zwar die OpenSemaphore() Funktion.

Beispiel CreateSemaphor():

HANDLE semaphor = CreateSemaphore ( NULL,
                                    3,
                                    3,
                                    "SemapName"
                                   );
Parameter:

Als Rückgabewert dieser Funktion erhält man einen Handle auf das Semaphor oder NULL falls ein Fehler aufgetreten ist.

 

Beispiel OpenSemaphore():

HANDLE semaphor = OpenSemaphore ( SEMAPHORE_ALL_ACCESS,
                                  NULL,
                                  "SemapName"
                                 );
Parameter:

Als Rückgabewert dieser Funktion erhält man einen Handle auf das Semaphor oder NULL falls ein Fehler aufgetreten ist.

 

Beispiel ReleaseSemaphore():

ReleaseSemaphore ( semaphor,
                   1,
                   NULL	
                  );
Parameter:

Diese Funktion gibt als Rückgabewert TRUE zurück wenn kein Fehler auftritt ansonsten wird FALSE zurückgegeben.