Concurrency in C#: So bleibt Ihr Programm niemals untätig
\n\nStellen Sie sich vor, Sie sind ein Chefkoch. Sie haben drei Herdplatten, einen Backofen und einen Mixer. Wenn Sie alles nacheinander machen – zuerst Zwiebeln schneiden, dann braten, dann Sahne schlagen – zieht sich das Mittagessen bis in die Ewigkeit. Viel effizienter ist es, den Backofen einzuschalten, während Sie Gemüse schneiden, und die Sahne zu schlagen, während das Fleisch schmort. Das ist Concurrency – die Fähigkeit, mehrere Aufgaben „gleichzeitig“ auszuführen, indem Sie zwischen ihnen wechseln, sodass Ressourcen nicht ungenutzt bleiben.
\n\nIn C# gibt es dafür drei Hauptwerkzeuge: klassische Threads, moderne Tasks mit async/await und das Konzept der Goroutinen, das über Iteratoren realisiert wird. In diesem Artikel analysieren wir jeden Ansatz, vergleichen ihre Leistung und zeigen, wann Sie welchen verwenden sollten. Wenn Sie in C# programmieren und möchten, dass Ihr Code fliegt statt kriecht, sind Sie hier genau richtig.
\n\nThreads: Die schwere Artillerie
\n\nEin Thread ist die grundlegendste Methode, um Concurrency zu organisieren. Jeder Thread ist ein separater „leichter Prozess“ innerhalb Ihres Programms mit eigenem Stack und Kontext. Einen Thread in C# zu erstellen, ist ganz einfach:
\n\nusing System;\nusing System.Threading;\n\nclass Program\n{\n static void Main()\n {\n Thread thread = new Thread(DoWork);\n thread.Start();\n Console.WriteLine("Hauptthread läuft");\n thread.Join(); // warten auf Beendigung\n }\n\n static void DoWork()\n {\n for (int i = 0; i < 5; i++)\n {\n Console.WriteLine($"Thread: {i}");\n Thread.Sleep(200);\n }\n }\n}\n\nHier haben wir einen separaten Thread erstellt, der bis fünf zählt, während der Hauptthread eine Nachricht ausgibt. Ohne Join könnte das Programm beendet werden, bevor der Thread bis fünf gezählt hat.
Nachteile von Threads
\n- \n
- Hohes Gewicht. Jeder Thread reserviert etwa 1 MB Speicher für den Stack. 1000 Threads zu erstellen bedeutet, einfach so 1 GB zu belegen. \n
- Komplexe Synchronisation. Damit zwei Threads keine gemeinsamen Daten beschädigen, brauchen Sie Locks, Mutexe und Semaphore. Ein Fehler – und Sie haben einen Deadlock oder eine Race Condition. \n
- Kontextwechsel. Das Betriebssystem benötigt Zeit für den Wechsel zwischen Threads. Gibt es mehr Threads als CPU-Kerne, wird dies zum Engpass. \n
Threads sind gut, wenn Sie echten Parallelismus auf einem Mehrkernprozessor für rechenintensive Aufgaben benötigen. Für Ein-/Ausgabe (Dateiarbeit, Netzwerk, Datenbank) sind sie jedoch überdimensioniert.
\n\nTask und async/await: Der moderne Standard
\n\nAb .NET 4.5 hat Microsoft eine elegante Alternative vorgestellt – die Task Parallel Library (TPL) und die Schlüsselwörter async/await. Dabei handelt es sich nicht um reine Threads, sondern um Versprechen (Promises): Sie sagen dem Compiler „starte diese Arbeit, und wenn sie fertig ist, gib mir das Ergebnis“. Während die Arbeit ausgeführt wird, wird der Thread nicht blockiert, sondern kehrt in den Pool zurück.
using System;\nusing System.Net.Http;\nusing System.Threading.Tasks;\n\nclass Program\n{\n static async Task Main()\n {\n Console.WriteLine("Download wird gestartet...");\n string data = await DownloadDataAsync();\n Console.WriteLine($"Geladen: {data.Length} Zeichen");\n }\n\n static async Task<string> DownloadDataAsync()\n {\n using HttpClient client = new HttpClient();\n string result = await client.GetStringAsync("https://example.com");\n return result;\n }\n}\n\nBeachten Sie: await blockiert den Thread nicht. Während die Daten über das Netzwerk geladen werden, kehrt der Thread in den Pool zurück und kann andere Anfragen bedienen. Das ist ideal für Webserver und GUI-Anwendungen – die Oberfläche friert nicht ein.
Wie funktioniert das unter der Haube?
\nWenn der Compiler async sieht, verwandelt er die Methode in einen endlichen Automaten. Bei jedem await „merkt“ sich die Methode ihren Zustand