Hyper-Threading: Optimierungen und Fallen

29.01.2003 von ALBERT  LAUCHNER
An Thread-optimierten Programmen führt in Zukunft kein Weg mehr vorbei. Doch selbst wer diese Kunst bereits beherrscht und einsetzt, muss für Intels Hyper-Threading kräftig umdenken.

Noch vor zehn Jahren mussten sich PC-Programmierer mit zahlreichen Prozessordetails herumschlagen: Die Größe von Pointern, Integer-Variablen und die Speicherung von Variablen (big endian, little endian) variierte von Plattform zu Plattform. Ohne dieses Wissen ließ sich kein vernünftiges Programm schreiben.

Objektorientierte Programmiersprachen bewahren den Software-Entwickler heutzutage meist vor derlei Unbill. Doch mit Intels Hyper-Threading kommt eine neue Herausforderung auf die Entwickler zu. Künftige Programme werden vermehrt multithreaded sein und ihre Arbeit in echt parallel ablaufende Blöcke unterteilen. Nur so lässt sich die "zweite" CPU in einem Hyper-Threading-Prozessor sinnvoll beschäftigen und der Programmablauf beschleunigen.

Doch egal ob Java, C++ oder C#, ohne ein gewisses Verständnis für das simulierte Multiprocessing der neuen Intel-CPUs läuft man schnell in einige Fallen. Durch eine ungeschickte Synchronisation der Threads untereinander kann die Software instabil oder unnötig langsam laufen. Was bei einer normalen CPU noch unkritisch ist, wird bei Intels Hyper-Threader plötzlich zur gemeinen Falle. Selbst die Platzierung der Variablen im Speicher wird plötzlich wieder zum Thema, über das es sich nachzudenken lohnt.

Im Folgenden finden Sie einige grundsätzliche Programmiertechniken für Multi- und Hyper-Threading. Mehr zur Prozessortechnologie lesen Sie in unserem Beitrag Hyper-Threading im Detail. Wie viel Mehrleistung Hyperthreading in aktuellen Anwendungen bringt, erfahren Sie im Test Pentium 4 Hyper-Threading Benchmarks.

Separation der Daten

Bei der Aufteilung eines Programms in mehrere Threads gibt es einen fundamentalen Grundsatz zu berücksichtigen: "Threads sollten so unabhängig wie nur irgend möglich voneinander sein". Dabei sind zwei Hauptaspekte zu beachten: Die Aufteilung in unabhängige funktionale Code-Blöcke und die Separation der Daten. In den folgenden Überlegungen wird der Einfachheit halber von einer Aufteilung in zwei Blöcke und zwei Threads ausgegangen, in der Praxis ist jedoch eine weitaus höhere Aufteilung nicht selten.

Im Idealfall lassen sich die Daten in vollkommen unabhängige Blöcke aufteilen und mit unabhängigen parallelen Threads bearbeiten. Eine Bildbearbeitung etwa kann ein Foto in die obere und untere Hälfte teilen und auf zwei CPUs parallel manipulieren. Da einfache Operationen wie die Erhöhung der Bildhelligkeit jeden Pixel unabhängig bearbeiten, ist hier die Datenaufteilung perfekt.

Komplexere Filter wie Schärfen oder Weichzeichnen beziehen jedoch neben dem eigentlichen Bildpunkt auch die Nachbarwerte ein. In diesem Fall ist Vorsicht an der Schnittstelle der beiden Bildhälften geboten. Bei einstufigem Filter können beide Threads den Schnittbereich aber noch ohne spezielle Absicherung gemeinsam lesen. Das neu berechnete Bild müssen sie aber zunächst in einem temporären Speicherbereich ablegen, da sonst ein Thread auf schon bearbeitete Daten des anderen und nicht mehr auf die Originaldaten zugreifen könnte.

Spätestens bei mehrstufigen Filtern, die die Daten mehrfach hintereinander manipulieren, ist es aber mit der simplen Aufteilung vorbei. In diesem Fall ist eine Kommunikation der Threads untereinander oder eine Synchronisation nötig. Beispielsweise muss nun Thread 1 sicher sein, dass auch Thread 2 mit dem ersten Schritt fertig ist, bevor er mit dem zweiten Filterdurchgang startet.

Nichts ist sicher

Was sich zunächst einfach anhört, führt in der Praxis mitunter zum kompletten "Absturz" des Programms. Wenn Thread 1 auf Daten von Thread 2 wartet, Thread 2 aber seinerseits auf Daten von Thread 1, dann kommt es zum gefürchteten Deadlock. Besonders hinterhältig ist, dass man ohne entsprechende Vorkehrungen keine Annahme über den zeitlichen Ablauf der Threads treffen darf.

Auch wenn etwa Thread 1 viel weniger Daten zu bearbeiten hat als Thread 2 ist nicht sicher, dass er vor Thread 2 fertig wird. Denn das Betriebssystem unterbricht die Threads je nach Bedarf, um anderen Programmen auch Rechenzeit zur Verfügung zu stellen. Ein nicht sauber synchronisiertes Programm, das hundert Mal stabil gelaufen ist, kann plötzlich hängen, weil externe Ereignisse die zeitliche Abfolge verschoben haben.

Durch Intels Hyper-Threading wird die Sache nun noch komplizierter. Denn viele Programme, die multithreaded arbeiten, sind bislang nie in einer Multiprozessor-Umgebung gelaufen. Der Thread-Ansatz dient hier vor allem dazu, verschiedene Teile der Software unabhängig reaktionsfähig zu halten. Ansonsten könnte etwa eine offene Dialogbox den Datenverkehr des Programms im Hintergrund blockieren.

Eine normale CPU kann jedoch immer nur einen Code-Block zu einer Zeit bearbeiten, die Parallelität erzeugt de facto das Betriebssystem, indem es jedem Thread kurze Zeitscheiben zuteilt. Bei Hyper-Threading-CPUs gibt es aber eine echte parallele Abarbeitung von Code. Daher kann beispielsweise tatsächlich ein gleichzeitiger Zugriff auf eine Variable stattfinden. Wie auf den folgenden Seiten beschrieben ist, kann dies katastrophale Folgen haben.

Die Katastrophe zu verhindern, ist Aufgabe des Programmierers. Dazu stellt ihm das Betriebssystem etliche Bordmittel bereit, andere kann er sich selbst bauen. Im Folgenden werden diese speziell hinsichtlich Hyper-Threading untersucht und einige Gefahren aufgezeigt. Denn bei Hyper-Threading muss man sich von einigen gängigen Annahmen trennen, die falsch waren, auf Single-CPU-Systemen aber keine Auswirkung hatten.

Pingpong-Thread-Wechsel

Als erstes Beispiel zur Synchronisation sollen zwei Threads betrachtet werden, die nur abwechselnd arbeiten dürfen. Wenn Thread 1 arbeitet, muss Thread 2 warten und umgekehrt. Handelt es sich dabei nur um kurze Code-Abschnitte, nimmt man die Synchronisation am besten selbst in die Hand und verzichtet auf die relativ langsamen Betriebssystemobjekte. Zunächst wird dies anhand einer suboptimalen Lösung demonstriert.

Alle folgenden Beispiele sind bewusst einfach gehalten und erledigen keine sinnvolle Aufgabe. Auch sind die angegeben Messwerte nicht als exakte Benchmarkergebnisse von hoch optimiertem Code zu verstehen. Sie dienen lediglich der Veranschaulichung der Größenordnung.

Das Testprogramm startet zwei der unten abgebildeten Threads, wobei der erste die Thread-Nummer num=0, der zweite num=1 zur internen Abstimmung erhält. Die Kommunikation mit dem Hauptprogramm erfolgt dabei über die globale Variable "Status". Die eigentliche Arbeit passiert in der Schleife while (Status != STOP). Zur besseren Demonstration fällt die Arbeit recht einfach aus: Eine globale Zählervariable iLoops soll jeweils um 1 erhöht werden.

Jeder Thread läuft so lange in einer leeren Warteschleife, bis die globale Variable "owner" gleich seiner Thread-Nummer wird. Ist dies der Fall, erhöht er iLoops, ändert owner und übergibt damit die Kontrolle an den nächsten Thread und beginnt wieder zu warten. So ist sichergestellt, dass immer nur ein Thread arbeitet und anschließend der andere an die Reihe kommt.

Selbst auf einem 3,06 GHz Pentium 4 unter Windows XP finden bei diesem Ansatz ohne Hyper-Threading nur elf Wechsel pro Sekunde statt. Wenn ein Thread eine Zeitscheibe vom Scheduler erhält, erledigt er in Mikrosekunden seine Arbeit und verbrät anschließend einige zig Millisekunden sinnlos in der leeren Warteschleife "while (owner != num);". Erst wenn der Scheduler den anderen Thread auf die CPU lässt, wird durch diesen iLoops wieder erhöht.

Dieses Verhalten ändert sich dramatisch bei aktiviertem Hyper-Threading. Jetzt laufen beide Threads tatsächlich parallel auf der CPU und sind nicht mehr auf das simulierte Multitasking des Windows-Schedulers angewiesen. Als Ergebnis können sie sich den Ball 8,6 Millionen Mal pro Sekunde zuwerfen.

Schneller durch Sleep?

Auf einer Single-CPU ist der bisherige Ansatz unbrauchbar, da hier die meiste Zeit mit sinnlosem Warten in einer Schleife verbraucht wird. Für solche Fälle bietet sich der Einsatz der Sleep()-Funktion an. Mit dieser Kernel-Funktion kann ein Thread dem Scheduler mitteilen, dass er gerade nichts zu tun hat. Daraufhin entzieht ihm der Scheduler die CPU und vergibt die nächste Zeitscheibe neu.

Im Beispiel ist jetzt die leere Warteschleife lediglich mit Sleep(0) gefüllt. In einer Single-CPU-Maschine ändert sich nun das Verhalten drastisch. Statt elf Wechseln pro Sekunde agieren die beiden Threads nun 1,3 Millionen Mal pro Sekunde.

Mit Hyper-Threading ist dies jedoch nicht der Weisheit letzter Schluss. Denn der Aufruf von Sleep() ist relativ aufwendig. Der Scheduler des Betriebssystems wird dabei aktiv und geht durch seine Listen der wartenden Threads. Anschließend belegt er die nächste Zeitscheibe neu. Auf einer Hyper-Threading-CPU mit zwei aktiven Threads kommt dabei jedoch meist derselbe Thread wieder zum Zuge, so dass der Scheduler-Aufruf sinnlos Rechenleistung verbraucht. Im obigen Beispiel sinkt daher die Leistung von 8,6 Millionen Loops pro Sekunde auf 2,4 Millionen.

Optimal mit Pause

Gerade bei sehr kurzen Aufgaben zahlt sich eine aufwendige Synchronisation nicht aus. Zumindest wenn eine zweite CPU zur Verfügung steht, ist es besser, diese mit "voller Kraft" warten zu lassen. Allerdings steht bei Hyper-Treading ja nicht wirklich eine zweite CPU bereit. Intern müssen sich die zwei virtuellen CPUs Ressourcen teilen, so dass die wartende CPU die aktive deutlich ausbremst.

Deshalb hat Intel mit Hyper-Threading auch den neuen Befehl "Pause" eingeführt. Die Instruktion mit dem Op-Code F3 90 hält dabei die Ausführungseinheit des Prozessors für ein paar Takte an. Während dieser Zeit kann die zweite CPU in einem Hyper-Threading-Prozessor alle Ressourcen allein für sich nutzen. Nach ein paar Takten nimmt dann die pausierende Pipeline ihre Tätigkeit wieder auf. Da sich dabei der CPU-Status in keiner Weise ändert, geschieht dies, ohne dass etwa ein Betriebssystem etwas davon bemerkt.

Der Warteschleifenbefehl "Pause" existiert erst seit dem Pentium 4. Allerdings kann er auch bei Software für andere CPUs bedenkenlos eingesetzt werden. Diese interpretieren ihn als NOP (no operation).

Durch die kurzfristige Entlastung der Pipeline vom sinnlosen Warten legt unser Pentium 4 3,06 GHz mit Hyper-Threading noch einmal kräftig zu. Statt 8,6 Millionen Loops mit leerer Warteschleife schafft er nun 51 Millionen. Ohne Hyper-Threading bleibt das Ergebnis auf dem Wert des ersten Versuchs: elf Loops pro Sekunde.

Pingpong Resümee

Das gezeigte Beispiel stellt einen Härtefall für die Thread-Kommunikation dar: Die Nutzlast, das Inkrementieren einer Variablen, ist im Vergleich zum Aufwand für die Synchronisation gering. Zudem wird nur die Extremsituation betrachtet, dass zwei Threads sich gegenseitig den Ball zuwerfen müssen und nie gleichzeitig arbeiten dürfen. In der Praxis sind die Auswirkungen daher normalerweise geringer, aber in ähnlicher Form dennoch vorhanden.

Loops/s mit und ohne Hyper-Threading

Ohne Hyper-Threading

Mit Hyper-Threading

Gemessen mit Pentium 4 3,06 GHz unter Windows XP

Leere Warteschleife

11

8.557.000

Mit Sleep(0)

1.253.000

2.392.000

Mit Pause-Instruction

11

51.058.000

Auf einer Single-CPU-Maschine muss immer das Betriebssystem mit einem Sleep bemüht werden, wenn Threads sich schnell abwechseln müssen. Bemerkenswerterweise ist dieser Weg - zumindest bei geringen Workloads - die schlechteste Wahl für Hyper-Threading-Systeme.

Am besten ist es, derart geringe Workloads zu vermeiden und den Threads genügend Arbeit für einige zig Mikrosekunden mitzugeben. Ist dies möglich, spielt der Overhead zur Synchronisation keine Rolle mehr.

Vorsicht: Selbst Integer ist nicht threadsafe

In vielen Situationen ist es nötig, dass Threads auf gemeinsame globale Variablen zugreifen. Solange dies nur lesend geschieht, ist das kein Problem. Kritischer wird es jedoch, wenn die Threads die Variablen auch verändern.

Verständlich sind die Probleme bei zusammengesetzten Variablen wie Arrays, Records, Strukturen oder Objekten. Ändert etwa ein Thread einen String, so geschieht dies Charakter für Charakter. Während dieses Updates kann er jedoch vom Scheduler mitten in der Arbeit unterbrochen werden. Zu diesem Zeitpunkt enthält ein Teil des Strings bereits den neuen Wert, der andere noch die alten Zeichen. Fällt die nächste Zeitscheibe an einen Thread, der ebenfalls auf den String zugreift, so arbeitet der neue Thread mit einem Zwischenstadium aus altem und neuem Wert. Was bei einem String eventuell noch ohne schlimme Folgen abläuft, führt spätestens bei über Pointer verketteten Listen ins Nirwana.

Deshalb ist es bei schreibendem Zugriff auf gemeinsame globale Variablen Pflicht, jeden Zugriff - auch den lesenden - durch Kernel-Funktionen abzusichern.

Was für komplexe Variablen klar ist, muss jedoch nicht unbedingt für einfache Variablen gelten. So sind viele Programmierer der Meinung, einen Zugriff auf einen gemeinsamen Integer-Wert nicht absichern zu müssen. Und sie haben sogar manchmal Recht, allerdings nur bei einer Single-CPU. In einer Hyper-Threading-Umgebung wird diese Annahme zur Falle.

30 Prozent Fehler

Im folgenden Beispiel soll eine simple globale Integervariable iLoops als Schleifenzähler von mehreren Threads aus verändert werden. Zudem inkrementiert die Testschleife noch einen für jeden Thread individuellen Integer iLoopThread[ ] mit einer individuellen Thread-Nummer als Index. In der Theorie sollte iLoops und die Summe aller individuellen Zähler gleich sein.

Lassen wir mit diesem Programm einen Pentium 4 ohne Hyper-Threading laufen, scheint zunächst alles in Ordnung. Mit zwei Threads ist die Summe der individuellen Zähler gleich der Gesamtanzahl. Mit aktiviertem Hyper-Threading zählt iLoops jedoch in einer Sekunde bis 30 Millionen, die Summe der beiden iLoopThread[ ] ergibt 48 Millionen. Unser Testprogramm verzählt sich also in rund 30 Prozent der Fälle.

Dummer Code?

Nach gängiger Meinung sollte so etwas nicht passieren können, da der Inkrement eines Integers mit einem einzigen Inc-Befehl erfolgen soll. Deshalb könne er nicht unterbrochen werden und sei demnach Thread-sicher.

Doch ein Blick in den vom Microsoft-Compiler ohne spezielle Optimierungen erzeugten Code zeigt schnell die erste Stolperfalle. Der Compiler nutzt nicht einen einzelnen Inc-Befehl, sondern lädt die Variable in ein Register, addiert im nächsten Befehl eine 1 und schreibt dann das Ergebnis wieder zurück.

Wird diese Befehlsfolge in der Mitte vom Scheduler unterbrochen und verändert ein anderer Thread zwischenzeitlich die Variable, geht die Addition schief. Das Gleiche gilt, wenn auf einer Hyper-Threading-CPU zwei Threads gleichzeitig in diesem Code-Abschnitt arbeiten. Dies trifft bei dem gezeigten Assembler-Code auch für Single-CPU-Systeme zu. Allerdings ist dabei die Wahrscheinlichkeit für einen Fehler nicht sonderlich hoch, da bei einer einzelnen CPU nur einige zig Thread-Wechsel pro Sekunde auftreten.

Auch Optimieren hilft nicht

Greift man beim Compiler zu Intel, sieht der erzeugte Code schon etwas freundlicher aus. Hier wird iLoops tatsächlich mit einem einzigen Befehl inkrementiert, so dass dieser Zugriff - vordergründig gedacht - sicher sein sollte. Wie man aber auch sieht, gilt dies schon für Integer in einem Array nicht mehr. Das Update einer Array-Variablen zerlegt der Compiler in fünf Befehle. Die CPU berechnet die Adresse, lädt die Variable in ein Register, erhöht dieses und schreibt die Variable dann zurück. Diese Kombination ist auf jeden Fall unsicher. Da in unserem Beispiel aber jeder Thread nur auf seine eigene Array-Variable zugreift, kann an dieser Stelle nichts schief laufen.

Doch der oben abgebildete Code verzählt sich dennoch auf Hyper-Threading-Systemen. Denn innerhalb der CPU durchläuft der Inc-Befehl die einzelnen Pipelinestufen und wird in einzelne Schritte zerlegt: Wert holen, inkrementieren und abspeichern. Greift ein anderer Thread der virtuellen zweiten CPU auf eine Variable zu, solange diese sich noch in der Pipeline der ersten CPU befindet und noch nicht zurückgeschrieben ist, tritt ein Rechenfehler auf.

Sicher ist sicher

Auf einer Single-CPU sind Variablen in der Pipeline kein Problem. Unterbricht der Scheduler einen Thread, läuft zunächst einiges an Betriebssystem-Code, bevor ein anderer Thread zum Zuge kommt. Bis dahin haben alle Thread-spezifischen Variablen die Pipeline längst verlassen. Auf Hyper-Threading-Systemen kann dieser Zustand jedoch sehr häufig auftreten - in unserem Beispiel in jedem dritten Fall. Es hilft also alles nichts: Wer auf globale Variablen auch schreibend zugreift, muss diese aufwendig absichern.

Rechenfehler

ILoopThread[0]

iLoopThread[0]

Summe

iLoops

Gemessen mit Pentium 4 3,06 GHz unter Windows XP

Ohne Hyper-Threading

245.606.869

231.350.580

476.957.449

476.957.449

Mit Hyper-Threading

23.575.428

24.237.485

47.812.913

29.339.340

Bevor einige Methoden zur Absicherung miteinander verglichen werden, noch eine allgemeine Anmerkung zu globalen Variablen. Die rund 90 Prozent schlechtere Performance mit Hyper-Threading in obigem Beispiel beruht allein auf dem Update von iLoops. Schreibt eine CPU-Pipeline einen Wert in den Cache zurück, muss sie dazu die entsprechende Cacheline für eine relativ lange Zeit exklusiv sperren. Während dieser Zeit kann die andere Pipeline nicht weiterarbeiten, wenn sie Daten aus dieser Line benötigt. In unserem Extrembeispiel kostet dieser Effekt 90 Prozent Performance. Da in der Praxis die Threads neben dem Update gemeinsamer Variablen auch noch etwas anderes zu tun haben, ist dieser Effekt weniger dramatisch. Dennoch gilt: "Threads sollten so unabhängig wie nur irgend möglich voneinander sein."

Critical Section oder Mutex?

Das vorangegangene Beispiel verdeutlicht, dass man beim Update von gemeinsamen Variablen entsprechende Vorsichtsmaßnahmen treffen muss. Für das Update von Integer-Variablen stehen dafür sogar spezielle Interlocked-Funktionen zur Verfügung. So erhöht InterlockedIncrement( ) einen Integer Thread-sicher um Eins. Da die Interlocked-Funktionen allerdings nur Integer-Variablen abdecken, werden sie im Folgenden nicht weiter betrachtet.

Die Klassiker für den Thread-sicheren Zugriff auf beliebige Variablen sind unter Windows die Critical Section und der Mutex. Bei der Critical Section definiert man einen Code-Abschnitt, der immer nur von einem Thread betreten werden kann. Ein Mutex dagegen ist ein Objekt, dass immer nur einen Thread als Besitzer hat. Während eine Critical Section nur innerhalb eines Programms zur Verfügung steht, ist ein Mutex ein Kernel-Objekt. Dadurch kann er auch die Synchronisation zwischen verschiedenen Programmen regeln.

In der Literatur ist allgemein zu lesen, dass ein Mutex rund 100 Mal langsamer wäre als eine Critical Section. Doch auch hier muss man bei Hyper-Threading umdenken.

Das Testprogramm ist im Wesentlichen gleich geblieben. Die eigentliche Benchmark-Schleife while (Status != STOP) zählt die Thread-spezifische iLoopsThread[num] und die gemeinsame globale Variable iLoops hoch. Im ersten Fall sorgt der Scheduler dafür, dass immer nur maximal ein Thread innerhalb der Critical Section läuft.

Im zweiten Fall wartet jeder Thread so lange, bis er den Mutex mMutex exklusiv besitzt. Dann erhöht er die Variablen und gibt den Mutex wieder frei.

In beiden Fällen ist sichergestellt, dass immer nur ein Thread gleichzeitig auf die Variablen zugreift. Damit kann kein Verzählen mehr stattfinden.

SpinCountLoops

Auf einer Single-CPU stellt sich das erwartete Zeitverhalten ein. Die Critical-Section-Schleife dreht 9,8 Millionen Runden pro Sekunde, die Mutex-Lösung lediglich 680 Tausend. Aktiviert man auf dem Testsystem jedoch Hyper-Threading, fallen beide Lösungen auf 250 Tausend ab.

Auf einem Single-CPU-System kann die Critical Section sehr einfach vom Betriebssystem implementiert werden, da hier physikalisch immer nur ein Thread läuft. Arbeiten jedoch mehrere Prozessoren parallel, nutzt das Betriebssystem zur Überwachung der Critical Sections intern auch wieder einen Mutex. Deshalb unterscheiden sich die beiden Fälle in ihrer Geschwindigkeit auch nicht.

Allerdings gibt es eine Möglichkeit, gerade einfache Critical Sections wie im Beispiel auch auf MP-Systemen deutlich zu beschleunigen. Dazu dient der Aufruf SetCriticalSectionSpinCount, der bei Single-CPU-Systemen keine Funktion hat. Deshalb wird er bislang nur von wenigen Entwicklern genutzt. Setzt man den SpinCount nach dem Erzeugen einer Critical Section, so kann man steuern, wie viel Zeit das Betriebssystem bei einer besetzten Critical Section in einer Warteschleife verbringt, bis es den langsamen Mutex bemüht. Wie in den vorgenannten Beispielen wartet das System also ein gewisse Zeit mit Vollgas.

Mit gesetztem SpinCount steigt die Leistung um das 19fache auf 4,7 Millionen an. Dabei ist der Wert des SpinCounts unkritisch, im Bereich von 300 bis 30.000 blieben die Ergebnisse in unserem Fall stabil.

Schleifendurchläufe pro Sekunde

Critical Section

Mutex

Critical Section mit SpinCount

Interlocked Increment

Gemessen mit Pentium 4 3,06 GHz unter Windows XP

Ohne Hyper-Threading

9.765.164

678.163

9.765.164

24.832.365

Mit Hyper-Threading

250.758

247.137

4.741.571

11.765.580

Fazit

Die gezeigten Fallen haben ihre Ursache nicht im Hyper-Threading. Sie gelten für alle Mehrprozessor-Systeme. Doch bislang waren SMP-Systeme eher selten, kaum ein Entwickler testet seine selbst geschriebenen Programme darauf. Da Intel aber jeden zukünftigen Prozessor mit Hyper-Threading ausstatten wird, müssen sich die Entwickler jetzt den neuen Anforderungen stellen.

In erster Linie gilt es, Thread-orientierte Programme MP-sicher zu machen. Hierzu muss jeder Zugriff auf gemeinsame Variablen abgesichert werden, wenn deren Wert auch nur an einer Stelle in einem Thread verändert wird. Erst im zweiten Schritt kann man daran gehen, die Geschwindigkeit zu erhöhen.

Die gezeigten Beispiele sind sicherlich Extrema, da sie ohne einen Workload arbeiten und nur die Synchronisation der Threads testen. In der Praxis fallen die Geschwindigkeitsunterschiede zwischen den verschiedenen Verfahren deutlich geringer aus. Auch liefern Threads mit Workloads auf Hyper-Threading-Systemen weitaus bessere Resultate als auf Single-CPUs - im Gegensatz zu den gezeigten leeren Threads.

Für eigene Experimente bieten wir den kompletten Sourcecode kostenlos als Download an. Den entsprechenden Link finden Sie in der rechten Navigation. Neben allen für diesen Beitrag verwendeten Testroutinen und dem kompilierten Programm finden Sie in dem Zip-File auch ein für Benchmark- und Timing-Zwecke hervorragend geeignetes universelles Timer-Objekt. (ala)