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