titelk.jpg (13599 Byte)

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:

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 );

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 );

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

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@gmx.at

Tutorial vom 22. Juli 2001