Multithreading in Echtzeit-Anwendungen – Einleitung

Im Zeitalter von Mehrkern-Prozessoren führt am Thema Multithreading kein Weg mehr vorbei. Selbstverständlich werden auch wir uns ausführlich mit diesem Thema befassen, wobei unser Fokus auf der Entwicklung von Echtzeit-Anwendungen liegen wird.


Der große Vorteil von Mehrkern-Prozessoren liegt auf der Hand – es lassen sich mehrere Aufgaben (Betriebssystem, Firewall, Virenscan, usw.) parallel verarbeiten, was zu einem entsprechend hohen Performancegewinn führt. Auch die Anwendungen selbst laufen auf Mehrkern-Prozessoren spürbar schneller, sofern sie multithread-fähig und damit in der Lage sind, ihre Arbeitslast möglichst gleichmäßig auf die zur Verfügung stehenden Prozessorkerne zu verteilen.

Bei der Angabe der Anzahl der Kerne unterscheidet man zwischen den physikalischen (tatsächlich existierenden) Prozessorkernen und den virtuellen Kernen, welche dank Hyperthreading dem Betriebssystem lediglich vorgespiegelt werden. Summiert man die physikalischen und virtuellen Kerne, so erhält man die Anzahl der logischen Kerne. Bei der Beschreibung der Prozessormodelle wird in diesem Zusammenhang oftmals nur von Threads gesprochen – gemeint ist die Anzahl der Aufgaben, die parallel verarbeitet werden können.

Basis aller multithread-fähigen Programme sind die sogenannten Threads. Vereinfacht kann man sich einen Thread als eine Funktion vorstellen, die im Unterschied zu „herkömmlichen“ Funktionen parallel zu weiteren Threads ausgeführt werden kann. Die hierfür benötigte Rechenzeit bekommen die Threads vom Betriebssystem zugewiesen. Da normalerweise weitaus mehr Threads am laufen sind als Prozessorkerne für die Berechnungen zur Verfügung stehen, muss das Betriebssystem einem Thread zwischenzeitlich auch die Prozessorzeit entziehen und diese einem anderen Thread zuzuweisen.

Führen die Threads voneinander unabhängige Aufgaben aus, dann spielt es keine Rolle, wenn ihre Arbeit zwischenzeitlich vom Betriebssystem unterbrochen wird. Problematisch wird es erst dann, wenn mehrere Threads an der selben Aufgabe und damit verbunden mit den selben Variablen arbeiten, denn die Threads besitzen keinerlei Informationen darüber, ob eventuell andere Threads gerade ebenfalls auf die selbe Variable zugreifen und gegebenenfalls ihren Wert verändern.
Ein weiteres Problem ergibt sich, wenn ein Thread auf die Berechnungen eines zweiten Threads warten muss, um die eigenen Berechnungen abschließen zu können. Hierdurch wird die benötigte Rechenzeit für die zu lösende Aufgabe verlängert. Dies tritt immer dann auf, wenn die Aufgabenlast zu ungleichmäßig auf die beteiligten Threads verteilt wird. Selbstverständlich ist dies nicht immer die Schuld des Programmierers, denn einige Aufgaben lassen sich nun einmal leichter parallelisieren als andere.

Bei der Entwicklung von multithread-fähigen Programmen darf man also die folgende zentrale Frage niemals aus dem Blick verlieren – wie lassen sich einzelne Aufgaben möglichst unabhängig voneinander und asynchron (Threads müssen nicht aufeinander warten) bearbeiten?

Bei der Entwicklung von Echtzeit-Anwendungen kommt dem Multithreading neben der optimalen Ausnutzung aller zur Verfügung stehenden Prozessorkerne noch eine zweite wichtige Aufgabe zu – die Skalierung der auszuführenden Berechnungen.

Bei der Entwicklung einer Anwendung, die 30 oder mehr Bilder (Frames) pro Sekunde auf den Bildschirm bringen muss, stellt sich zwangsläufig die folgende Frage – müssen eigentlich alle Berechnungen für jedes neue Frame wiederholt werden oder steht für einzelne Aufgaben eventuell auch mehr Zeit zur Verfügung?

Als erstes Beispiel sei hier die KI (künstliche Intelligenz) genannt – strategische Berechnungen müssen selbstverständlich nicht für jedes Frame neu durchgeführt werden, und die Berechnungsdauer selbst kann ohne Probleme auch mehrere Frames beanspruchen.
Auch Kollisionsberechnungen – insbesondere bei sich „langsam“ bewegenden Objekten – müssen nicht innerhalb eines Frames abgeschlossen werden. Ein Spieler wird normalerweise gar nichts davon merken, wenn sich diese Berechnungen über mehrere Frames hinziehen.

Oft wird argumentiert, die Performance einer Anwendung lasse sich nur durch zusätzliche Rechenleistung deutlich steigern. Die zuvor gemachten Überlegungen kommen bei dieser Betrachtungsweise jedoch ein wenig zu kurz.
Plant man beispielsweise für die Kollisionsberechnung im Durchschnitt zwei Frames ein, dann halbiert sich die pro Frame benötigte Rechenleistung. Bei vier Frames sind es gar 75% Rechenleistung, die man im Vergleich zur vollständigen Berechnung innerhalb eines Frames einspart. Die frei werdenden Ressourcen könnte man nun beispielsweise nutzen, um die Anzahl der physikalisch simulierten Körper zu erhöhen.

Bevor man nun damit beginnen kann, Threads zu erzeugen und diese den zur Verfügung stehen Prozessorkernen zuzuweisen, muss zuvor die Anzahl der logischen Kerne (physikalische Kerne + virtuelle Kerne) ermittelt werden. In unseren Programmbeispielen verwenden wir hierfür die Funktion GetNumCPUsAndGenerateAffinityMasks():

void GetNumCPUsAndGenerateAffinityMasks(void)
{
    // Hinweis:
    // Mithilfe der AffinityMask lassen sich Threads und Prozesse einem
    // oder mehreren Prozessorkernen zuordnen:

    if(g_AffinityMaskAlreadyGenerated == true)
        return;

    // wir legen als Obergrenze 100 Kerne fest:
    for(long i = 0; i < 100; i++)
        g_dwThreadAffinityMask[i] = (1 << i);

    // 0x1   == cpu core 0
    // 0x2   == cpu core 1
    // 0x4   == cpu core 2
    // 0x8   == cpu core 3
    // 0x10  == cpu core 4
    // 0x20  == cpu core 5
    // 0x40  == cpu core 6
    // 0x80  == cpu core 7
    // 0x100 == cpu core 8
    // 0x200 == cpu core 9
    // usw.

    // Die Anzahl der logischen Prozessorkerne ermitteln
    // wir aus der System Info:
    SYSTEM_INFO siSysInfo;
    GetSystemInfo(&siSysInfo);

    // Anzahl der logischen Prozessorkerne:
    g_NumCPUs = siSysInfo.dwNumberOfProcessors;

    Add_To_Log("available Cores", &g_NumCPUs);

    g_AffinityMaskAlreadyGenerated = true;
}


Hinweis:
Um multithread-fähige Windows-Anwendungen erstellen zu können, müssen Sie die folgende Header-Datei in Ihr Programm mit einbinden:

#include <process.h>


Mithilfe des Taskmanagers können Sie sich eine Übersicht der auf Ihrem Computer laufenden Prozesse (Programme) verschaffen. Um Informationen über die einzelnen Prozesse zu erhalten, wählen Sie einen Prozess aus und drücken dann die rechte Maustaste. Die Option Priorität festlegen zeigt Ihnen an, wie viel Rechenzeit dem Prozess in Relation zu den anderen Prozessen zugeteilt wird und gibt Ihnen die Möglichkeit, die Priorität entweder zu erhöhen (mehr Rechenzeit) oder zu verringern (weniger Rechenzeit). Mithilfe der Option Zugehörigkeit festlegen… können Sie darüber hinaus festlegen, auf welchen der zur Verfügung stehenden Prozessorkerne die Anwendung ausgeführt werden soll. Mithilfe der Set_ProcessPriority()-Funktion lassen sich diese Einstellungen aus den Programmbeispielen heraus vornehmen:

void Set_ProcessPriority(void)
{
    HANDLE ProcessHandle = GetCurrentProcess();

    if(g_ProcessPriority == 0)
        SetPriorityClass(ProcessHandle, NORMAL_PRIORITY_CLASS);
    else if(g_ProcessPriority == 1)
        SetPriorityClass(ProcessHandle, ABOVE_NORMAL_PRIORITY_CLASS);
    else if(g_ProcessPriority == 2)
        SetPriorityClass(ProcessHandle, HIGH_PRIORITY_CLASS);
    else // höchste Priorität
        SetPriorityClass(ProcessHandle, REALTIME_PRIORITY_CLASS);

    if(g_ProcessProcessorID > -1 && g_ProcessProcessorID < g_NumCPUs)
    SetProcessAffinityMask(ProcessHandle,
                           g_dwThreadAffinityMask[g_ProcessProcessorID]);
}


Analog zur Priorität der Anwendung kann man auch dem Haupt-Thread eine Priorität zuweisen. Hierfür steht uns die Set_MainThreadPriority()-Funktion zur Verfügung.

Hinweis:
Der Haupt-Thread wird bei Programmstart automatisch erzeugt und stellt gewissermaßen das Hauptprogramm dar. Für das Starten und Beenden aller weiteren Threads sind wir jedoch explizit verantwortlich.

// Hinweis:
// Funktion muss aus dem Hauptprogramm heraus aufgerufen werden!
void Set_MainThreadPriority(void)
{
    HANDLE ThreadHandle = GetCurrentThread();

    if(g_MainThreadPriority == 0)
        SetThreadPriority(ThreadHandle, THREAD_PRIORITY_NORMAL);
    else if(g_MainThreadPriority == 1)
        SetThreadPriority(ThreadHandle, THREAD_PRIORITY_ABOVE_NORMAL);
    else
        SetThreadPriority(ThreadHandle, THREAD_PRIORITY_HIGHEST);
}


Mithilfe der SetMainThreadCore()-Funktion können wir festlegen, auf welchem Prozessorkern der Haupt-Thread ausgeführt werden soll:

// Hinweis:
// Funktion muss aus dem Hauptprogramm heraus aufgerufen werden!
void SetMainThreadCore(void)
{
    // zur Sicherheit
   
if(g_MainThreadProcessorID < 0)
        return;

    if(g_MainThreadProcessorID >= g_NumCPUs)
        return;
    else
   
{
        HANDLE ThreadHandle = GetCurrentThread();

        SetThreadAffinityMask(ThreadHandle,
        g_dwThreadAffinityMask[g_MainThreadProcessorID]);
    }
}


Für alle von uns explizit erzeugten Threads lässt sich mithilfe der SetCore()-Funktion festlegen, auf welchem Prozessorkern der jeweilige Thread ausgeführt werden soll:

void SetCore(HANDLE ThreadHandle, long CoreNr)
{
    // zur Sicherheit
    if(CoreNr < 0)
        return;

    if(CoreNr >= g_NumCPUs)
        return;
    else
    SetThreadAffinityMask(ThreadHandle, g_dwThreadAffinityMask[CoreNr]);
}


Die Thread-Erzeugung selbst werden wir im Rahmen dieser Einleitung noch nicht besprechen. Im übernächsten Artikel werden wir jedoch ausführlich darauf eingehen und zeigen, wie sich Kollisionsberechnungen in einem separaten Thread asynchron zum Hauptprogramm durchführen lassen.