Multithreading in der Praxis

Teil 2: Programmieren für den Multi-Core-Prozessor

07.05.2008 von Rami Radi
Ein Multi-Core-Prozessor erzielt durch Parallelverarbeitung von Software höhere Leistung. Doch Vorteile bringt der Multi-Core-Prozessor nur, wenn auch die Software für die CPU optimiert ist und Multithreading ausnutzt. In dieser mehrteiligen Reihe zur Programmierung für den Multi-Core-Prozessor führt TecChannel Sie nach und nach durch die Besonderheiten der Multi-Core-Programmierung.

Die Leistung von Computersystemen steigt seit Jahren kontinuierlich an. Dies betrifft nahezu alle Komponenten der Rechner. Am deutlichsten ist der Leistungsgewinn bei den Prozessoren, dem Arbeitsspeicher und den Festplatten zu verfolgen. Insbesondere die Prozessoren haben nicht nur dank höherer Taktung sondern vor allem durch die Multi-Core-Technologie massiv an Leistungsfähigkeit zugelegt. Einzelne Anwendungen profitieren aber nur von Multi-Core, wenn sie entsprechend programmiert sind.

In dieser mehrteiligen Reihe zur Programmierung für Multi-Core-Prozessoren führen wir Sie nach und nach durch die Besonderheiten der Multi-Core-Programmierung. Angefangen bei den wichtigsten Grundlagen der verschiedenen Parallelisierungs-Verfahren in Prozessoren im ersten Teil. In diesem zweiten Teil befassen wir uns mit der notwendigen Modularisierung in Applikationen und gehen auf das .Net-Framework ein. Im nächsten Teil geht es ans Eingemachte: Deadlocks, Race Conditions und Zugriffe auf die .Net-Thread-Implementation mit Locking, Delegates, Timern und asynchronen Events.

Das Prinzip der Multi-Core-CPUs ist einfach und in unserer modernen Welt allgegenwärtig: Die Erhöhung der Taktrate entspricht einer Beschleunigung des Fließbands der industriellen Produktion. Wenn man dabei aber, aus welchen Gründen auch immer, an Grenzen stößt, so platziert man kurzerhand die gesamte Fertigungstrasse ein zweites, drittes oder viertes Mal. Genauso wie bei den Kassen im Supermarkt, um mehr Käufer bedienen zu können. So auch bei den Prozessoren, nur spricht man da von Multi-Core. Ihre „Kunden“ sind die anstehenden Programme, die durch die Prozessoren bedient oder besser abgearbeitet werden. Je mehr Prozessoren oder Kassen, umso höher der Gesamtdurchsatz.

Parallelverarbeitung in der Praxis

Eine Erhöhung dieses Gesamtdurchsatzes ist aber nur dann zu erzielen, wenn die anstehenden Aufträge, also die Programme auch wirklich parallel, wie die Kunden im Supermarkt, bedient werden können und auch unabhängig voneinander sind.

Denn, so viele Kerne (Kassen) man auch bereitstellt, für das einzelne Programm (Kunde), der genau von einem Kern bedient und somit abgearbeitet wird, ändert das erstmal nichts. Hier hilft nur die Erhöhung der Abfertigungsgeschwindigkeit, also eine höhere Taktung oder ILP. Um dennoch einen höheren Gesamtdurchsatz zu erzielen, muss das Programm auf die verschiedenen Cores verteilt werden, wie die Kunden auf mehrere Kassen im Supermarkt. Ein Unterfangen, das jedoch nicht ganz einfach ist.

Verteilt: Der Rechenaufwand soll auf die verschiedenen Cores in der CPU verteilt werden.

Betrachtet man ein Programm aus der Sicht seiner Erstellung im Prozess der Software-Entwicklung, so stellt man zwar fest, dass es bereits bei der Programmierung in eine Vielzahl von Programmstücken zerlegt wird, diese Zerlegung aber mit der späteren Laufzeit wenig zu tun hat. Die Modularisierung der Programme bei der Entwicklung dient nämlich einem anderen Zweck: einer vereinfachten Entwicklung und der Wiederverwendbarkeit des Codes.

Modularisierung in der Programmentwicklung

Ein Programm oder eine Applikation, wie etwa eine Textverarbeitung, das Betriebssystem oder ein Mailsystem bestehen aus tausenden verwobener Codestücke. Diese bezeichnet man je nach Programmiersprache als Routine, Prozedur, Funktion, Objekt, Methode oder Komponente.

All diese Begriffe beziehen sich dabei auf den Software-Entwicklungsprozess. Zusammengefasst werden diese Codestücke in den Bibliotheken, wie etwa dem .Net-Framework oder den Java-Funktionssammlungen. Die Zusammenfassung von Funktionen in Bibliotheken soll die Entwicklung von Applikationen durch Wiederverwendung beschleunigen.

Auch die Betriebssysteme, wie etwa Windows oder Linux, stellen prinzipiell nur eine Sammlung an Standardfunktionen dar. Der Unterschied zu den Funktionsbibliotheken liegt letztendlich darin, dass die Betriebssysteme eben allgemein gängige Funktionen in einem Basissystem zusammenfassen, dem Betriebssystem.

Die oben erwähnten Funktionssammlungen allerdings orientieren sich näher an den Geschäftsapplikationen und sollen deren Erstellung vereinfachen. Aus den Übersetzern der Programmiersprachen (Compiler, Linker oder Runtime-Umgebung) werden dann die ausführbaren Software-Module, beispielsweise eine EXE-Datei, ein Web-Service oder eine .Net-Assembly.

Die Zerlegung eines Programms in viele Module bietet für die Software-Erstellung eine Reihe von Vorteilen. Die Entwicklung von Modulen kann parallel erfolgen, die Wiederverwendbarkeit (der Code-Re-Use) steigt. Bereits geprüfte Funktionen müssen nicht neu erstellt und getestet werden. Dies alles aber sind Anforderungen der Software-Entwicklung und haben mit jenen, wie sie zur späteren Laufzeit in einem Multi-Core-System benötigt werden, wenig gemeinsam.

Taskverteilung durch mehr Kerne

Dennoch vermag ein geschicktes Design eines Programms auch einen Nutzen für den Einsatz in Multi-Core-Systemen nach sich ziehen. Dies gilt immer dann, wenn die separierten Module auf unterschiedlichen Ausführinstanzen, also unterschiedlichen Cores, abgearbeitet werden. Eine Ausführinstanz in diesem Kontext kann ein CPU-Core, aber auch ein separater Rechner sein. Dieser separate Rechner wiederum kann sich im nahen Umfeld befinden, wie etwa in einem Rechenzentrum, oder auch auf einem anderen Kontinent stehen, wie bei den Service-Orientierten-Architekturen (SOA). Der Unterschied in dieser Betrachtungsweise liegt ja nur in der Distanz der beiden Ausführinstanzen. Bei Multi-Core-CPUs sind die Ausführinstanzen, also die Cores, nur Bruchstücke von Millimetern auseinander, bei SOA-Applikationen tausende von Kilometern.

Vielfach: In je mehr Threads eine Applikation/Routine aufgeteilt ist, desto mehr Cores können parallel arbeiten.

Die Herausforderungen an die Softwarenentwicklung sind bei beiden Extremen im Prinzip gleich. Sie hängen davon ab, wie die separierten Software-Fragmente zusammenarbeiten und wie sie synchronisiert werden. In der Regel ist die Möglichkeit der Parallelverarbeitung während der Laufzeit nicht vorhersehbar (nicht deterministisch). Das erfordert ausgefeilte Tools auf der Software-Entwicklungsseite.

Die oben erwähnten Begriffe wie Funktion oder Methode werden in der Softwareentwicklung für Quellprogramme verwendet. Die ausführbaren Module sind in .Net die so genannten .Net-Assemblies. Eine Assembly ist ähnlich wie eine ZIP-Datei, sie stellt eine Paketierung für ein Programm dar. Wird das Programm, die Assembly, tatsächlich ausgeführt, so erfolgt dies in der Regel immer unter der Kontrolle eines Betriebssystems, das dafür einen Prozess, einen Task oder einen Thread generiert.

Der Begriff des Prozesses, des Tasks oder des Threads steht für gerade ausgeführte Programmabschnitte, die unter der Kontrolle eines Betriebssystems ausgeführt werden. Ein Task wiederum ist das aktive Abbild einer Routine oder Methode, meist werden Tasks in Prozessen gruppiert. Der Prozess zerfällt somit in mehrere Tasks. Die Namensgebung ist allerdings nicht ganz eindeutig und wird unterschiedlich angewandt. Der Taskmanager in Windows zeigt eigentlich Programme an. Linux wiederum kennt Prozesse und das passende Kommando ps (Process-Status).

Durch die Modularisierung der Software in das Betriebssystem und die Applikationen mit ihren Assemblies ergibt sich letztendlich eine Vielzahl an Tasks oder Prozessen, je nachdem, welche Namenskonvention vorherrscht.

Die Herausforderungen bei der Softwareerstellung

Die Anforderung an die Software-Entwicklung besteht nun darin, den gesamten Code so zu zerlegen, dass er parallel, in mehreren Tasks, abgearbeitet werden kann, und zwar während der Laufzeit. Dies ist nicht unmöglich aber sicher keine triviale Aufgabe.

Es ist umso einfacher, je gröber diese Modularisierung vorgenommen wird. In einer Textverarbeitung etwa könnten die Rechtschreibkorrektur oder das Ausdrucken eines Dokumentes als eigener Prozess (Task) abgewickelt werden. Das bringt aber auch nur dann einen Leistungsgewinn, wenn tatsächlich gedruckt wird oder die Rechtschreibprüfung eingeschaltet ist. Für die eigentliche Erfassung eines Textes bringt diese Aufteilung noch keinen Nutzen. Der größte Zeitvorteil wird also dann auftreten, wenn die am meisten verwendeten Code-Abschnitte auf alle zur Verfügung stehenden CPU-Cores aufgeteilt sind und dort auch parallel abgearbeitet werden.

Gerade weil die Programmierung von parallelem Code alles andere als einfach ist, wurde sie bis dato meist nur für besonders wichtige und zeitkritische Bereiche eingesetzt. Die gängigen Betriebssysteme, Datenbanksysteme oder Mailsysteme sind daher bereits in der Lage, Multi-Core-CPUs und damit Parallelverarbeitung zu nutzen.

Auf Seiten der Desktop-Programme ist die Situation noch eine andere: Auch wenn hier die gängigen Betriebssysteme seit geraumer Zeit schon mehrere Kerne unterstützen, finden sich dennoch nur wenige Applikationen, die direkt Nutzen aus der Parallelverarbeitung ziehen. Damit ergeben sich derzeit lediglich Vorteile beim Ausführen von mehreren Programmen auf dem Desktop.

Leistungsgewinn: Eine spürbare Steigerung der Rechenleistung lässt sich nur durch Multi-Core- und/oder Multi-Prozessor-Technologie erzielen.

Mit der Ausdehnung von Multi-Core-CPUs in neue Anwendungsfelder und dem Vordringen in den Desktop-Bereich allerdings wird sich dieses gravierend ändern, denn nur, wenn die Applikationen auch mehrere Kerne unterstützen, bringen diese einen Gewinn für den Benutzer.

Parallelisierung in der Softwareentwicklung

Durch die Parallelisierung der Cores in einer Multi-Core-CPU ändert sich die Verantwortung für schnelleren Code. Bei Single-Core-CPUs, ob mit oder ohne ILP und Hyper-Threading, wurde der Leistungsgewinn allein durch eine Änderung in der CPU bzw. die Optimierungen der Compiler bewirkt. Nun aber sind die Software-Entwickler gefordert.

Optimalfall: Im besten Fall erreicht man ein lineares Verhältnis zwischen Anzahl Cores/CPUs und Performance der Anwendung.

Denn nur dann, wenn die Programme auch „multithreaded“ entwickelt werden, erzielen sie den Leistungszuwachs. Dafür allerdings erhalten der Benutzer und die Applikation weitaus mehr an Rechenkraft. Richtig genutzt, kann die Applikation auf einem Dual-Core auch doppelt so schnell abgearbeitet werden, verglichen mit heutigen CPUs.

Die Herausforderungen für den Entwickler sind umso größer, je abstrakter die Technik der Software-Entwicklung war, die man bis dato anwandte. Entwickler die überwiegend auf so genannten „managed Runtime Environments“ (MRTEs) aufsetzen, sind dabei am meisten betroffen. Der Nutzen eines MRTE, wie Java oder .Net, besteht ja gerade darin, die technischen Details und Grundlagen des Betriebssystems, der Prozessverwaltung und ähnlicher Systemaufgaben vor dem Entwickler zu verbergen und ihm damit die Applikationsentwicklung einfacher zu machen. Durch die integrierten Funktionen der Laufzeitumgebung wandert die Programmierung einer Applikation näher an der Geschäftslogik und damit gleichzeitig weg von den internen Verwaltungstechniken und der CPU mit Speicher- und Prozessverwaltung.

Das .Net-Framework

Obgleich Java den Softwaremarkt für Neuentwicklungen seit den 1990er Jahren dominierte, setzen immer mehr Unternehmen mittlerweile auf .Net. Einer der Gründe ist sicher auch die Sprachunabhängigkeit von .Net. Ein zweiter Beweggrund ist zweifelsfrei der hohe Verbreitungsgrad von .Net und dessen Entwicklungswerkzeug Visual Studio.

.Net ist unabhängig von der Programmiersprache - genauer gesagt, wird es für unterschiedliche Sprachen angeboten. Gleichzeitig ist .Net allerdings fest an ein Windows-Betriebssystem als Ausführungsumgebung gebunden. Microsoft spricht in dem Zusammenhang gerne auch vom Language Independence Software Development in .Net und der Common Language Runtime (CLR). Die Java-Runtime-Umgebung hingegen existiert für unterschiedliche Plattformen (Platform Independence), benötigt aber immer Java als Unterbau.

Die Common Language Runtime (CLR)

Die Common Language Runtime (CLR) ist die Laufzeitumgebung für jegliche ausführbaren Codemodule. Es ist der Container, in dem weitere .Net-Module, aber auch der Applikationscode ausgeführt werden – vergleichbar mit der Java Runtime. Die Anwendungen laufen alle im Kontext und unter der Verwaltung der CLR – dies wird als verwalteter (managed) Code bezeichnet. Durch die Kontrolle der CLR wird die Sicherheit des Gesamtsystems erhöht.

Ferner ermöglicht das Management des Codes die Bereitstellung der notwendigen Umgebung für einen reibungslosen Ablauf der Routinen. Um aber bereits bestehende Routinen weiterverwenden zu können, besteht die Möglichkeit, diese als „unmanaged Code“, der nicht unter der Aufsicht der CLR steht, auszuführen. Eine weitere Eigenschaft der CLR ist die Verwendung von Zwischencode statt Maschinencode. Dieser Zwischencode nach der Definition der Microsoft Intermediate Language (MSIL) wird zur Laufzeit von einem integrierten Compiler der CLR in ausführbaren Maschinencode transferiert und geladen. Die Compiler der Quellsprachen wie C# oder Visual Basic erzeugen nun ihrerseits MSIL-Code aus den Quellen.

Container: In der CLR werden weitere .Net-Module, aber auch der Applikationscode, ausgeführt.

Das Ziel der Applikationsentwickler liegt in der Abbildung des gewünschten Geschäftprozesses durch die Applikation. Mit der Weiterentwicklung der Bibliotheken, der Fortschreibung der Tools und nicht zuletzt den Modellierungstools orientiert sich der Entwickler immer mehr an den Geschäftprozessen und eben weniger an den Techniken der Prozessverwaltung oder der Thread-Programmierung. Das Zerlegen eines Programms in mehrere Threads führt zu einem höheren Durchsatz und einer besseren Reaktion des Programms. Es erfordert auch ein durchdachtes Konzept, damit sich die Threads nicht gegenseitig blockieren.

Multithreading-Support durch den Compiler

Die Aufteilung des Codes in Threads kann prinzipiell in mehreren Varianten erfolgen. Handelt es sich dabei um die gleiche Code-Logik, die unterschiedliche Datenräume bearbeitet, so ist dies bereits durch die PRAGMA-Anweisungen der Compiler möglich. Dies gilt beispielsweise dann, wenn in einer Schleife ein Array mit Inhalten zu füllen ist und mehrere Threads parallel diese Daten bereitstellen oder bearbeiten.

Wenn beispielsweise eine Schleife von 1 bis 400 zu durchlaufen ist, so lässt sich diese theoretisch auf vier Kerne aufteilen, wobei jeder Kern dann 100 Durchläufe ausführt. Wichtig in dem Zusammenhang ist nur, dass die Verteilung des Codes und der Threads durch den Compiler und nicht durch den Programmierer erfolgt, wenngleich dieser natürlich die Anwendungen und deren Code bereitstellen muss.

Anforderungen dieser Art sind aber eher die Ausnahme. In der Regel wird der Entwickler seinen Code in mehrere Abschnitte aufbrechen und in der Codierung bereits dafür sorgen müssen, dass sich später daraus unterschiedliche Threads ergeben. Hier gilt es herauszufinden, welcher Code-Bereich tatsächlich parallel zum bestehenden ausgeführt werden kann. Die einfache Abbildung eines Threads aus einer Funktion ist hier kaum hilfreich, denn sie sagt nichts über das Laufzeitverhalten aus.

So ergibt es beispielsweise wenig Sinn, einen Code-Abschnitt der ohnehin nur zehn Prozent der durchschnittlichen Programmlaufzeit beansprucht, in weitere Threads zu zerlegen, wenn gleichzeitig die restlichen 90 Prozent als ein Thread ausgeführt werden. Um ein Gefühl über Laufzeiten und dergleichen zu erhalten, offerieren die Hersteller von Entwicklungs- und Testwerkzeugen so genannte Code-Profiler, die über das Laufzeitverhalten der Applikationen Auskunft geben.

Mit den Fallen bei der Programmierung für Parallelverarbeitung und den Multithreading-Optionen der .Net-Runtime beschäftigt sich der demnächst erscheinende dritte Teil dieser Reihe. (mha)