Von klein auf lernen wir, die uns umgebende Umwelt zu verstehen und zu kontrollieren. Wir handeln und denken dabei objektorientiert und das ganz intuitiv ohne uns darüber viele Gedanken machen zu müssen. Diese Denkweise auf die Softwareentwicklung anzuwenden ist somit eine logische Vorgehensweise. Die objektorientierte Programmierung sollte daher ähnlich leicht und intuitiv möglich sein wie die Zubereitung einer Mahlzeit oder das Kaufen einer Fahrkarte.
Die Realität sieht leider anders aus. Ein nicht gerade geringer Teil der Softwareprojekte hat mit grossen Problemen zu kämpfen oder scheitert völlig. Woran liegt das? Wenn die objektorientiere Programmierung so praxisnah und einfach ist dann dürfte es nicht so viele Fehlschläge in Projekten geben. Die Antwort ist eigentlich ganz einfach: In der realen Umwelt und in der Softwareentwicklung sind die einzelnen Objekte meist sehr einfach verständlich und beherrschbar. Sobald die einzelnen Objekte aber gemeinsam an der Lösung einer Aufgabe arbeiten müssen treten sehr schnell Schwierigkeiten auf. Gemeinsame Tätigkeiten müssen synchronisiert werden, Ressourcen müssen verwaltet werden und dabei wird das aus den einzelnen Objekten entstandene Gesamtsystem immer komplexer.
Eines der schwierigsten Themen der objektorientierten Programmierung ist somit die Synchronisierung von parallel arbeitenden Objekten, also das Multithreading. Dieser Artikel bildet den Beginn einer mehrteiligen Artikelserie in welcher ich Ihnen das Thema Multithreading näher bringen möchte.
In diesem Artikel soll gezeigt werden, dass bei Funktionsaufrufen welche parallel durch mehrere Threads erfolgen, immer mit Seiteneffekten gerechnet werden muss. In solchen Fällen ist daher eine Synchronisierung der Threads erforderlich.
Fangen wir mit einem simplen Beispiel an: Eine Variable soll inkrementiert werden. Der nachfolgende Quellcode zeigt eine entsprechende Konsolenanwendung:
1. Increment mittels Hilfsvariable
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 _count = 0;
6
7 Parallel.Invoke(
8 Increment,
9 Increment,
10 Increment);
11
12 Console.Write(_count);
13 Console.Read();
14 }
15
16 static void Increment()
17 {
18 int actual;
19
20 for (int i = 0; i < 1000000; i++)
21 {
22 actual = _count;
23 actual = actual + 1;
24 _count = actual;
25 }
26 }
27
28 static private int _count;
29 }
Innerhalb der Funktion Increment() wird die Variable _count ausgelesen und in eine Hilfsvariable gespeichert. Die Hilfsvariable wird anschliessend um eins erhöht und zurückgeschrieben. Dies erfolgt eine Millionen Mal. Zugegebenermassen ist die Funktion etwas umständlich programmiert und durchaus einfacher lösbar, aber das Beispiel soll die grundlegende Arbeitsweise einer Increment Funktion zeigen.
Wird diese Funktion sequentiell dreimal hintereinander aufgerufen, dann ist der das Ergebnis 3.000.000. Die Ausführung erfolgt im Beispielcode aber nicht sequentiell sondern parallel. Die Increment Funktion wird in drei parallel laufenden Threads ausgeführt. Sie ahnen schon, dass das Ergebnis der parallelen Programmausführung nicht 3.000.000 ist. Die Konsolenanwendung hat bei meinem Testlauf 1.8561.802 ausgegeben.
Wo aber sind nun die über eine Millionen fehlenden Increment Aufrufe geblieben? Die fehlenden Werte entstehen dadurch, dass die drei Threads zu beliebigen Zeitpunkten arbeiten. Dadurch kann es zum Beispiel zu folgender Befehlsreihenfolge kommen:
- _count ist im Moment 1000
- Thread 1 liest _count und speichert den Wert in der Zwischenvariable
- Thread 1 erhöht die Zwischenvariable auf 1001
- Bevor Thread 1 aber den Increment Zyklus beenden kann bekommt Thread 2 Rechenzeit zugeteilt
- Thread 2 liest nun ebenfalls den Wert von _count, welcher immer noch 1000 ist
- Thread 1 ist nun wieder an der Reihe und speichert 1001 als neuen _count
- Thread 2 erhöht nun die Zwischenvariable auf 1001 und speichert diesen Wert ebenfalls als neuen Wert für _count
Somit wurden zwei Increment Zyklen durchlaufen aber durch die parallele Ausführung der Teilbefehle ist der Wert nur um eins erhöht wurden.
Der Increment Fehler tritt also dadurch auf das mehrere nacheinander auszuführende Befehle eine logische Einheit bilden und diese logische Einheit während der Ausführung unterbrochen wird. Könnte man die Increment Logik als einen atomar durchführbaren Befehl abbilden, dann dürfte es keine Fehler bei der parallelen Ausführung geben. Im zweiten Beispiel wird daher ein Einzelbefehl verwendet.
2. Increment mittels ++ Operator
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 _count = 0;
6
7 Parallel.Invoke(
8 Increment,
9 Increment,
10 Increment);
11
12 Console.Write(_count);
13 Console.Read();
14 }
15
16 static void Increment()
17 {
18 for (int i = 0; i < 1000000; i++)
19 {
20 _count++;
21 }
22 }
23
24 static private int _count;
25 }
In dieser Anwendung wurde die Increment Logik durch einen Einzelbefehl ersetzt. Somit sollte auch bei paralleler Ausführung als Ergebnis 3.000.000 in der Konsole erscheinen. Die Konsole gibt bei mir aber leider 1.666.004 aus. Woran liegt das? Der ++ Operator sieht zwar wie ein atomarer Befehl aus, aber im Hintergrund macht dieser im Prinzip das Gleiche wie die kompliziertere Programmablauf aus Beispiel 1. Es wird der Wert der Variable gelesen, erhöht und zurückgeschrieben. Somit treten die gleichen Effekte wie im ersten Beispiel auf.
Es muss also eine Lösung her die wirklich atomar ist. Der nachfolgende Quellcode zeigt eine Implementierung bei welcher der lock Mechanismus verwendet wird um Abschnitte im Programm als atomar zu kennzeichnen. Dies führt dazu das parallele Threads nicht gleichzeitig auf diese Programmabschnitte zugreifen können. Ist ein Thread gerade mit der Abarbeitung des lock-Bereiches beschäftigt so muss ein nächster Thread der ebenfalls zu diesem Abschnitt gelangt warten bis der erste Thread den Abschnitt wieder verlässt.
3. Increment mittels lock
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 _count = 0;
6
7 Parallel.Invoke(
8 Increment,
9 Increment,
10 Increment);
11
12 Console.Write(_count);
13 Console.Read();
14 }
15
16 static void Increment()
17 {
18 for (int i = 0; i < 1000000; i++)
19 {
20 lock (_lock)
21 {
22 _count++;
23 }
24 }
25 }
26
27 private static int _count;
28 private static object _lock = new object();
29 }
Und endlich sind wir am Ziel! Die Konsole zeigt nach Programmausführung das erhoffte Ergebnis an: 3.000.000.
Als wissbegieriger Softwareentwickler geben Sie sich aber sicher nicht mit diesem Ergebnis zufrieden. Voller Entdeckergeist und Tatendrang stellen Sie sich sicher die Frage: „Gibt es noch andere, im Besten Fall einfachere und effizientere Lösungsmöglichkeiten?“. Diese Frage ist berechtigt und ja es gibt andere Lösungsmöglichkeiten. Da es sich bei der Increment Funktion um eine oft benötigte Standardfunktion handelt, waren die .NET Entwickler bereits so nett und haben eine einfachere Lösung in das Framework integriert. Der nachfolgende Quellcode zeigt diese optimierte Lösung.
4. Increment mittels Interlocked.Increment
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 _count = 0;
6
7 Parallel.Invoke(
8 Increment,
9 Increment,
10 Increment);
11
12 Console.Write(_count);
13 Console.Read();
14 }
15
16 static void Increment()
17 {
18 for (int i = 0; i < 1000000; i++)
19 {
20 Interlocked.Increment(ref _count);
21 }
22 }
23
24 static private int _count;
25 }
Die Klasse Interlocked bietet einige Funktionen wie zum Beispiel das Inkrementieren einer Variable. Diese Funktionen sind intern so implementiert das sie garantiert atomar ausgeführt werden. Somit kann auf den lock-Mechanismus verzichtet werden.
Fazit
Funktionsaufrufe erfolgen nicht atomar, selbst wenn sie auf den ersten Blick so erscheinen. Dies hat in einer Anwendung mit einem Thread keinerlei Auswirkungen. Wird aber eine Multithreadinganwendung erstellt, dann müssen gemeinsame Funktionsaufrufe der einzelnen Threads synchronisiert werden. Dazu bietet sich die lock Funktion an. Mit dieser lassen sich Codeabschnitte als atomar ausführbar definieren. Des Weiteren bietet die Klasse Interlocked einige Basis-Programmierfunktionen und stellt dabei sicher dass diese atomar ausgeführt werden.
>Die Konsolenanwendung hat bei meinem Testlauf 1.8561.802 ausgegeben.
Sicher? 🙂
Also bei mir funktioniert es super