www.codeworx.org/c++ tuts /Kongos rand() Tutorials, 5: Multithreading

Tutorial 5: Multithreading

Einleitung

Manchmal ist es angebracht, dass ein Programm mehrere Dinge gleichzeitig tut. Das Programm könnte zb. in eine Datei schreiben, während es eine Seite druckt und Benutzergaben empfängt. Zusätzlich muss es noch umfangreiche Berechnungen ausführen. Dies wäre alles nicht möglich, gäbe es nicht Multithreading oder Multitasking.

Unter Dos programmierte man noch in einem durch, wie zb. bei Spielen. Man hatte man eine starre Hauptfunktion, die zuerst die Eingaben verarbeitet, dann Kollisionen überprüft, Sound ausgibt und zum Schluß noch das Bild rendert. Doch Dos ist TOT!!! Jetzt sind die Zeiten von Windows 9.x und dem Multithreading Modell gekommen.

Ich kann mich noch erinnern, als ich einmal ein Pathfinding Programm geschrieben habe. Ich schrieb die Suchfunktion in einem durch und wollte noch dazu, dass man beobachten kann, wie sich das Programm den Weg sucht. Ich habe einfach nach jeder Bewegung der Spielfigur das Programm angehalten (Sleep() *g). Natürlich konnte ich während dieser Berechnung auf nichts zugreifen und musste immer warten, bis das Programm fertig war. Also setzte ich überall in den Code Aufrufe der Nachrichtenschleife ein. Dadurch wurde der Code wirklich sehr sehr lang. Jetzt, da ich um einige Jahre reifer geworden bin, würde ich das mit Threads programmieren. *g

Ein Programm ist hierbei ein Prozess, jeder Prozess hat einen Hauptthread (primary thread) und jeder Hauptthread kann untergeordnete Threads starten, die wiederum auch Threads starten können. Windows teilt dabei jedem Thread Rechenzeit zu. Wenn diese Zeit verstrichen ist, wird dieser Thread angehalten und der nächste Thread wird ausgeführt. Dabei wechselt das Betriebsystem so schnell, dass der Anwender glaubt, die Programme laufen gleichzeitig ab.

Vorteile von Threads:

  • Desto mehr Prozesse/Threads eine Anwendung hat, desto mehr Rechenzeit bekommt die Anwendung vom Betriebsystem.
  • Es können mehrere Programme gleichzeit ausgeführt werden (Multitasking).
  • Jedes Programm kann mehrere Arbeiten gleichzeitig ausführen.
  • Stürzt ein Thread ab, nimmt er nicht das ganze Betriebssystem mit. Unter Windows 95/98 können abstürzende Threads aber auch weiterhin das System lahmlegen. Dies liegt daran, dass Win 95/98 große Teile des alten Win16-Codes verwendet, der immer nur von einer Anwendung gleichzeitig ausgeführt werden kann. Stürzt eine Anwendung während der Ausführung von 16-Bit-Betriebssystemcode ab, gibt sie diesen nicht mehr frei und der Code kann nicht mehr von anderern Programmen ausgeführt werden. Win32-Code ist jedoch dagegen gesichtert.

Zeig mir Multithreading!!! (Beispiel: multhread1)

In unserem ersten Beispiel gibt jeder Thread (Hauptthread + 3 untergeordnete Threads) 25 mal eine Zahl aus. Bevor wir aber unseren ersten Thread erstellen, müssen wir den Compiler noch informieren, dass er die Multithreaded Bibliotheken verwendet. In den Projekteinstellungen (Projekt->Einstellungen), unter dem Reiter 'C/C++', Kategorie: 'Code Generation', muss unter Laufzeit Bibliothek auf 'Multithreaded' umgestellt werden.

Als erstes müssen wir die Threads einmal starten. Einen Thread erzeugt man dadurch, dass man die Funktion CreateThread() aufruft. Sie ist folgendermaßen definiert:

HANDLE CreateThread( LPSECURITY_ATTRIBUTES lpThreadAttributes, 
                     DWORD dwStackSize, 
                     LPTHREAD_START_ROUTINE lpStartAdress, 
                     LPVOID lpParamter, 
                     DWORD dwCreationFlags, 
                     LPDWORD lpThreadId );

LPSECURITY_ATTRIBUTES lpThreadAttributes - Zeiger auf die Sicherheitsattribute für den Thread. Der Standardwert ist NULL. Damit wird der Thread mit dem gleichen Sicherheitsprofil wie die Anwendung erzeugt. Der Standardwert ist eigentlich zu bevorzugen.

DWORD dwStackSize - Stack-Größe, die für den neuen Thread bereitgestellt wird. Bei einer Angabe von 0 erhält der Thread die gleiche Stack-Größe wie der aufrufende Thread.

LPTHREAD_START_ROUTINE lpStartAdress - Hier wird die Adresse der Funktion angegeben, die der Thread beim Start aufrufen soll.

LPVOID lpParamter - Zeiger auf eine 32-bit Parameter, der der Threadfunktion als Wert übergeben wird.

DWORD dwCreationFlags - Ein Flag zum Erzeugen des Threads. Dieses Flag kann einen von zwei Werten ernthalten. Wenn man den Wert CREATE_SUSPENDED übergibt, wir der Thread in einem angehaltenen Zustand erzeugt. Der Thread startet erst dann, wenn die Funktion ResumeThread() für den Thread aufgerufen wird. Übergibt man den Wert 0 (der Standardwert), beginnt der Thread die Ausführung in dem Moment, zu dem er erzeugt wird.

LPDWORD lpThreadId - Zeiger auf eine 32-bit Variable, in der die Thread ID gespeichert wird.

In unserem ersten Beispiel sieht der Aufruf wie folgt aus:

HANDLE hThread[MAX_THREADS]; 
DWORD dwThreadID[MAX_THREADS]; 
   
   ... ... ... 
   
for (int index=0; index<MAX_THREADS;index++) 
{ 
   hThread[index] = CreateThread( NULL, 
                                  0, 
                                  ThreadFunc, 
                                  (LPVOID)index, 
                                  0, 
                                  &dwThread[index] 
                                 ); 
} 


Wir erzeugen ein paar Threads und lassen diese einfach laufen. In dem Array hThread[] speichern wir die Handles auf die Threads, damit wir später diese wieder schließen können.

ThreadFunc() ist unsere Funktion, die der Thread beim Start aufruft. Jeder Thread gibt 50 mal eine Zahl aus und wartet daraufhin 100 Millisekunden:

DWORD WINAPI ThreadFunc(LPVOID Data) 
{ 
   for (int index=0; index<50; index++) 
   { 
      printf("%d ",(int)data+1); 
      Sleep(100); 
   } 
   return((DWORD)data); 
} 


Nach der Erzeugung der Threads gibt auch der Hauptthread 25 mal eine Zahl aus:

for (index=0; index<25; index++) 
{ 
   printf("4 "); 
   Sleep(100); 
}

Die Ausgabe des Programms sah, bei einem Testlauf, bis zu dieser Zeile so aus:

Alle Threads starten...

   4 2 3 1 4 1 3 2 3 1 4 2 2 4 1 3 3 1 4 2 2 4 1 3 3 4 4 2 2 4 1 3 3 1 4 2 
   3 4 1 2 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 4 2 1 3 2 4 1 
   3 4 2 1 3 2 4 1 3 4 2 1 3 2 4 1 3 1 4 2 3 2 4 1 3 1 

Der Hauptthread hat seine Ausgabe schon beendet, jedoch sind die untergeordneten Threads noch an der Arbeit. Darum müssen wir warten, bis alle anderen Threads die Arbeit beendet haben. Die Funktion hierfür lautet:

DWORD WaitForMultipleObjects( DWORD nCount, 
                              CONST HANDLE * lpHandles, 
                              BOOL fWaitAll, 
                              DWORD dwMilliseconds ); 

DWORD nCount - Gibt die Anzahl der Handles an, die in dem Array für den nächsten Paramter gespeichert sind.
CONST HANDLE * lpHandles - Array der Thread-Handles
BOOL fWaitAll - Soll auf alle Threads die in dem Array angegeben sind gewartet werden oder nur auf einen.
DWORD dwMilliseconds - Gibt die Anzahl der Millisekunden an, die gewartet werden sollen, das die Threads ihre Arbeit beenden. Bei der Angabe von INFINITE wartet die Funktion, bis alle Threads beendet wurden.

In unserem Programm sieht der Aufruf so aus:

WaitForMultipleObjects( MAX_THREADS, 
                        hThreads, 
                        TRUE, 
                        INFINITE ); 

Nachdem alle Threads die Arbeit beendet haben, können wir alle Threads wieder schließen:

for (index=0; index<MAX_THREADS; index++)
   CloseHandle(hThread[index]); 


Am Schluß sah die Ausgabe des Programms so aus:

Alle Threads starten... 
   4 2 3 1 4 1 3 2 3 1 4 2 2 4 1 3 3 1 4 2 2 4 1 3 3 4 4 2 2 4 1 3 3 1 4 2 
   3 4 1 2 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 1 4 2 3 2 4 1 3 4 2 1 3 2 4 1 
   3 4 2 1 3 2 4 1 3 4 2 1 3 2 4 1 3 1 4 2 3 2 4 1 3 1 2 3 2 1 3 2 1 3 2 1 
   3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 
   3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 3 2 1 
Alle Threads beendet. 


Wie du siehts, haben sich die 4 Threads die Zeit sehr gut aufgeteilt. Alle Threads haben die gleiche Priorität gehabt, darum liefen sie zum Schluß immer in einer bestimmten Reihenfolge ab.

Critical Space Data oder Das Problem mit gemeinsam genutzten Ressourcen

Ein Problem mit Threads ergibt sich darin, das Threads versuchen könne auf gemeinsam genutzte Ressourcen gleichzeitig zuzugreifen zu wollen. Hierfür ein Beispiel:

Wir haben eine Klasse erstellt, die uns eine verkette Liste bereitstellt (linked-list). Die Klasse sieht gekürzt so aus:

class CList 
{ 
protected: 
   CList *m_pPrev, 
   *m_pNext; 
   cData m_Data; 

public: 
   // Konstruktor, Destruktor 
   // Zugriff Funktionen 
   // Management Funktionen 
} 


Wir haben nun noch zwei Threads, die beide auf so eine Liste zugreifen. Das Problem liegt jetzt hierbei:

// Thread A 
   delete ListItem5; 
   
   // Thread B bekommt Rechenzeit 
   for (pList = &List; pList != NULL; pList = pList->Next() ) { 
   // Suche nach irgendwas 
   } 
   
   // Thread A bekommt wieder Rechenzeit 
   ListItem4->SetNext(NULL); 


Das Problem ist, dass Thread A das fünfte Element aus dem Speicher löscht und dann den Zeiger des vierten Elements auf NULL setzt. Jedoch bekommt dazwischen Thread B Rechenzeit. Dieser Thread durchsucht die Liste nach irgendetwas. Wenn er jetzt zum vierten Element kommt nimmt er den Zeiger auf das fünfte Element, das zwar schon gelöscht ist, jedoch ist der Zeiger ja noch nicht NULL. !!!ABSTURZ!!!

Es gibt viele solcher Fälle und darum gibt es natürlich Gegenmittel. Die insgesamt vier Mechanismen heißen:

  • Kritische Abschnitte
  • Mutexe
  • Semaphore
  • Ereignisse

Kritische Abschnitte

Ein kritischer Abschnitt ist ein Mechanismus, der den Zugriff auf eine bestimmte Ressource auf einen einzelnen Thread innerhalb einer Anwendung beschränkt. Ein Thread tritt in den kritischen Abschnitt ein, bevor er mit der angegebenen gemeinsam genutzten Ressource arbeiten muß, und verläßt den kritischen Abschnitt, nachdem der Zugriff auf die Ressource abgeschlossen ist. Wenn ein anderer Thread versucht, in den kritischen Abschnitt einzutreten, bevor der erste Thread den kritischen Abschnitt verlassen hat, wird der zweite Thread blockiert und erhält keine Prozessorzeit, bis der erste Thread den kritischen Abschnitt verläßt und damit dem zweiten Thread den Eintritt ermöglicht. Mit kritischen Abschnitten markiert man Codebereiche, die nur ein Thread zu einem bestimmten Zeitpunkt ausführen sollte. Damit verhindert man nicht, daß der Prozessor von diesem Thread zu einem anderen umschaltet. Es wird nur unterbunden, daß zwei oder mehr Threads in denselben Codeabschnitt eintreten.

Mutexe

Mutexe arbeiten grundsätzlich in der gleichen Weise wie kritische Abschnitte. Allerdings setzt man Mutexe ein, wenn man Ressourcen zwischen mehreren Anwendungen gemeinsam nutzen will. Mit einem Mutex läßt sich garantieren, daß keine zwei Threads, die in einer beliebigen Anzahl von Anwendungen laufen, auf dieselbe Ressource zum selben Zeitpunkt zugreifen.

Aufgrund ihrer systemweiten Verfügbarkeit bringen Mutexe wesentlich mehr Overhead mit als kritische Abschnitte. Die Lebensdauer eines Mutexes endet nicht, wenn die Anwendung, die ihn erzeugt hat, beendet wird. Der Mutex kann weiterhin von anderen Anwendungen verwendet werden. Das Betriebssystem muß demnach verfolgen, welche Anwendungen einen Mutex verwenden, und muß den Mutex zerstören, sobald er nicht mehr benötigt wird. Im Gegensatz dazu bringen kritische Abschnitte nur einen geringen Verwaltungsaufwand mit sich, da sie nicht außerhalb der Anwendung existieren, die sie erzeugt und verwendet. Nachdem die Anwendung beendet wird, ist der kritische Abschnitt verschwunden.

Semaphore

Die Arbeitsweise von Semaphoren unterscheidet sich grundsätzlich von kritischen Abschnitten und Mutexen. Semaphoren setzt man bei Ressourcen ein, die nicht auf einen einzelnen Thread zu einem Zeitpunkt beschränkt sind - eine Ressource, die auf eine bestimmte Anzahl von Threads beschränkt ist. Vom Prinzip her ist ein Semaphor eine Art Zähler, den die Threads inkrementieren oder dekrementieren können. Der Trick bei Semaphoren besteht darin, daß sie nicht kleiner als Null werden können. Wenn demzufolge ein Thread versucht, einen Semaphor zu dekrementieren, der bereits Null ist, wird dieser Thread blockiert, bis ein anderer Thread den Semaphor inkrementiert.

Nehmen wir an, daß eine Warteschlange von mehreren Threads gefüllt wird und ein Thread die Elemente aus der Warteschlange entfernt und weiterverarbeitet. Wenn die Warteschlange leer ist, hat der Thread, der Elemente entfernt und weiterverarbeitet, nichts zu tun. Dieser Thread kann in eine Leerlaufschleife eintreten, in der die Warteschlange ständig daraufhin untersucht wird, ob sich irgend etwas darin befindet. Das Problem bei diesem Szenario besteht darin, daß der Thread Verarbeitungszeit verbraucht, um eigentlich überhaupt nichts zu tun. Diese Prozessorzeiten könnte man für andere Threads vergeben, die wirklich etwas ausführen müssen. Wenn Sie eine Warteschlange mit Semaphoren steuern, kann jeder Thread, der Elemente in die Warteschlange stellt, den Semaphor für jedes plazierte Element inkrementieren, und der Thread, der die Elemente aus der Warteschlange entfernt, kann den Semaphor dekrementieren, bevor er ein Element aus der Warteschlange nimmt. Wenn die Warteschlange leer ist, hat der Semaphor den Wert Null, und der Thread, der Elemente entfernt, wird beim Aufruf zum Dekrementieren der Warteschlange blockiert. Der Thread verbraucht damit keine Prozessorzeit, bis einer der anderen Threads den Semaphor inkrementiert, um anzuzeigen, daß er ein Element in die Warteschlange gestellt hat. Zu diesem Zeitpunkt wird die Blockierung für den Thread, der Elemente entfernt, unverzüglich aufgehoben, und der Thread kann das eben plazierte Element aus der Warteschlange nehmen und weiterverarbeiten.

Ereignisse

In dem Maße wie die Synchronisierungsmechanismen für Threads dafür ausgelegt sind, den Zugriff auf begrenzte Ressourcen zu steuern, sind sie auch dafür vorgesehen, unnötige Prozessorzeit in Threads zu unterbinden. Je mehr Threads gleichzeitig laufen, desto langsamer führt jeder einzelne Thread seine Aufgaben aus. Wenn ein Thread also nichts zu tun hat, blockieren man ihn, und belassen ihn im Leerlauf. Damit erhalten andere Threads mehr Prozessorzeit und laufen deshalb schneller, bis die Bedingungen erfüllt sind, daß der im Leerlauf arbeitende Thread etwas zu tun bekommt.

Aus diesem Grund verwendet man Ereignisse - um Threads den Leerlauf zu ermöglichen, bis die Bedingungen erfüllt sind, daß sie etwas zu tun bekommen. Ereignisse sind dem Namen nach mit den Ereignissen verwandt, die die meisten Windows-Anwendungen treiben, nur daß das Ganze etwas komplizierter ist. Die Ereignisse zur Synchronisierung von Threads arbeiten nicht nach den Mechanismen der Ereigniswarteschlangen und -behandlung. Statt eine Nummer zu erhalten und dann darauf zu warten, daß diese Nummer an die Behandlungsroutine von Windows übergeben wird, sind Ereignisse der Thread-Synchronisierung eigentliche Objekte, die sich im Speicher befinden. Jeder Thread, der auf ein Ereignis warten muß, teilt dem Ereignis mit, daß er auf dessen Auslösung wartet, und nimmt dann einen Ruhezustand ein. Wird das Ereignis ausgelöst, geht ein Signal an jeden Thread, der dem Ereignis seinen Wartezustand bekanntgegeben hat. Die Threads nehmen die Verarbeitung genau an dem Punkt auf, wo sie dem Ereignis die Warteabsicht mitgeteilt haben.

Download Source Code

Copyright (c) by Kongo

Bei Fragen, Beschwerden, Wünschen schreib an kongo@codeworx.org

Tutorial vom 22. Juli 2001