Microsoft Async CTP–Ein kritischer Blick

2. März 2011

Matthias hat ja hier im Flurfunk schon auf die Async CTP von C# hingewiesen, die seit der PDC10 verfügbar ist. Da ich das Thema Multithreading als “persönliches Hobby” betrachte, habe ich mir diese CTP näher angeschaut… und bin nach anfänglichem Optimismus eher wenig begeistert.

Die Async CTP enthält zwei wesentlichen Bestandteile:

  1. Eine angepasste Version des C# Compilers. Dieser setzt zwei neue Schlüsselwörter in Code um, der sich wesentlich auf die seit .NET 4.0 vorhandene Task Parallel Library (TPL) stützt.
  2. Eine Bibliothek, die neben unterstützendem Code für die neuen Compilerfeatures vor allem viele schon länger vorhandene asynchrone Funktionalitäten des .NET Framework mit einer TPL-verträglicheren Schnittstelle versieht. Typisches Beispiel ist TaskEx.Delay als Ersatz für Thread.Sleep.

Es gibt einige Beispiele aus unterschiedlichen Quellen seitens Microsoft. In der CTP selbst, im bereits von Matthias angesprochenen White Paper, oder auch in Blogs. Stellvertretend sei hier das Beispiel aus dem White Paper angeführt – es ist am einfachsten und überschaubarsten.

Ein Beispiel…

Mehrere Webseiten sollen eingelesen und die Größen der Dateien aufaddiert werden; synchron in etwa so:

   1: public int SumPageSizes(IList<Uri> uris) {

   2:     int total = 0;

   3:     foreach (var uri in uris) {

   4:         statusText.Text = string.Format("Found {0} bytes ...", total);

   5:         var data = new WebClient().DownloadData(uri);

   6:         total += data.Length;

   7:     }

   8:     statusText.Text = string.Format("Found {0} bytes total", total);

   9:     return total;

  10: }

Ließe man diesen Code in einer normalen Windows-Anwendung so laufen, dann würde für die Dauer der Abfragen die Anwendung “einfrieren” und nicht mehr auf Benutzereingaben reagieren. Man würde das also gerne asynchron zum UI-Thread erledigen. Die im White Paper vorgeschlagene Variante läuft die Liste weiter synchron im aktuellen Thread ab, verlagert aber jeweils das Einlesen der Webseiten in einen anderen Thread. Das “bricht” natürlich die Schleife “auf”, so dass folgender Code vorgeschlagen wird:

   1: public void SumPageSizesAsync(IList<Uri> uris) {

   2:     SumPageSizesAsyncHelper(uris.GetEnumerator(), 0);

   3: }

   4:  

   5: private void SumPageSizesAsyncHelper(IEnumerator<Uri> enumerator, int total) {

   6:     if (enumerator.MoveNext()) {

   7:         statusText.Text = string.Format("Found {0} bytes ...", total);

   8:         var client = new WebClient();

   9:         client.DownloadDataCompleted += (sender, e) => {

  10:             SumPageSizesAsyncHelper(enumerator, total + e.Result.Length);

  11:         };

  12:         client.DownloadDataAsync(enumerator.Current);

  13:     }

  14:     else {

  15:         statusText.Text = string.Format("Found {0} bytes total", total);

  16:         enumerator.Dispose();

  17:     }

  18: }

Anforderung erfüllt, aber zu einem, wie ich meine, sehr hohen Preis. Manuelles hantieren mit Enumeratoren, eine Methode die sich via Event und Callback indirekt asynchron selbst aufruft. Und das ist nur der erste Vorschlag, zusammen mit leicht geänderten Anforderungen und Fehlerbehandlung wird daraus ein wahres Ungetüm.

Ergo: Dieser Code ist deutlich komplexer, schlechter nachvollziehbar, und infolgedessen fehleranfällig und wartungsintensiv. Und schreiben möchte man solchen Code auch nicht allzu oft, die Wahrscheinlichkeit, dass bei nächster Gelegenheit wieder das UI einfriert ist also recht groß.

Genau hier greift die neue Async-Funktionalität ein: Anstatt die sequentielle Schleife manuell (und mit den beschriebenen Auswirkungen) in asynchrone Aufrufe zu zerlegen, teilt man einfach dem Compiler mit, an welchen Stellen man gerne eine asynchrone Entkopplung hätte:

   1: public async Task<int> SumPageSizesAsync(IList<Uri> uris) {

   2:     int total = 0;

   3:     foreach (var uri in uris) {

   4:         statusText.Text = string.Format("Found {0} bytes ...", total);

   5:         var data = await new WebClient().DownloadDataAsync(uri);

   6:         total += data.Length;

   7:     }

   8:     statusText.Text = string.Format("Found {0} bytes total", total);

   9:     return total;

  10: }

Wie man sieht ist der Code wieder ähnlich sauber und verständlich wie in der synchronen Variante. Gleichzeitig hat aber der Compiler im Hintergrund jede Menge Arbeit erledigt und hieraus asynchronen Code gebastelt, der das UI nicht mehr einfrieren lässt.

So gesehen bieten die neuen Schlüsselwörter also einen deutlichen Qualitätssprung: Bislang (notwendigerweise?) komplexer Code wird deutlich vereinfacht. Asynchronität im Handumdrehen sozusagen.

Der kritische Blick…

Notwendigerweise komplexer Code wird deutlich vereinfacht. Ist das wirklich so? Schaut man sich die Anforderung nochmals an, so ging es nur darum, die Verarbeitung aus dem UI-Thread heraus zu verlagern. Es ist keine Anforderung, dass dies in Häppchen geschehen muss. Man hätte also ohne weiteres die gesamte Schleife in einen eigenen Thread verlegen können. Natürlich müssen Zwischenergebnisse evtl. in den UI-Thread zurückgemarshalt werden, aber das ist nichts Neues und kein großes Problem. Folgende Variante würde die Anforderung ebenfalls erfüllen:

   1: public void SumPageSizes(IList<Uri> uris) {

   2:     Task<int>.Factory.StartNew(() => SumPageSizesAsync(uris));

   3: }

   4:  

   5: public int SumPageSizesAsync(IList<Uri> uris) {

   6:     int total = 0;

   7:     foreach (var uri in uris) {

   8:         SetStatusTextSync(string.Format("Found {0} bytes ...", total));

   9:         var data = new WebClient().DownloadData(uri);

  10:         total += data.Length;

  11:     }

  12:     SetStatusTextSync(string.Format("Found {0} bytes total", total));

  13:     return total;

  14: }

  15:  

  16: void SetStatusTextSync(string text) {

  17:     Dispatcher.BeginInvoke(new Action(() => { statusText.Text = text; }));

  18: }

Dieser Code ist nur wenig komplexer als die synchrone Variante – Starten über Task und eine Hilfsmethode um denn Text an den UI-Thread zu übergeben – er kommt aber ohne jede Spracherweiterung aus. Vergleicht man den Code mit den neuen Schlüsselwörtern mit diesem Code statt dem Ursprünglichen Beispiel, dann kehrt die Bilanz zwar nicht das Vorzeichen um, aber der Gewinn fällt deutlich geringer aus.

Das Fazit…

Tatsächlich waren alle Beispiele die ich geprüft habe ähnlich gelagert: “Schöner” synchroner Code, übermäßig komplexer asynchroner Code, und die neuen Async-Features eilen zur Rettung und produzieren wieder “schönen” aber asynchronen Code. Aber auch: Mit etwas Nachdenken war der übermäßig komplexe asynchrone Code deutlich zu vereinfachen – in einem Fall sogar deutlich effizienter – auch ohne auf neue Sprachfeatures angewiesen zu sein.

Das stellt alles die neue Funktionalität nicht grundsätzlich in Frage. Aber der Mehrwert stellt sich doch deutlich geringer dar, als die Beispiele den Anschein geben. Das wirft durchaus die Frage auf, ob der verbleibende Mehrwert ausreichend ist, um ein neues Sprachfeature zu rechtfertigen. Dabei darf nicht vergessen werden, dass dies zum einen mit Einarbeitungsaufwand verbunden ist – die Tatsache, dass ein Stück Code nicht in üblicher Abfolge abgearbeitet wird will erst einmal von allen Entwicklern verstanden werden; zum anderen erzwingen die notwendigen Bibliotheken vermutlich einen erneuten Sprung der .NET Framework-Version – das kann für bestehende Anwendungen deutliche Auswirkungen bezüglich Test- und Wartungskosten mit sich bringen.

Um die Leistung Microsofts bei aller Kritik nicht zu schmälern: Die Auseinandersetzung mit diesem Thema zeigt auch, dass Microsoft durchaus ein großer Wurf gelungen ist. Und zwar bereits mit der TPL, seit Version 4.0 Bestandteil des .NET Framework – und damit bereits heute breit verfügbar.

PS: Eine detailliertere Ausführung in der ich auch auf die angesprochenen weiteren Beispiele eingehe findet sich hier.