Februar 2019

Band 34, Nummer 2

[C#]

Minimieren der Komplexität in C#-Multithreadcode

Von Thomas Hansen | Februar 2019

Forks oder Multithreadprogrammierung gehören zu den Dingen, die bei der Programmierung am schwierigsten sind. Dies liegt an ihrer parallelen Natur, die eine ganz andere Denkweise erfordert als die lineare Programmierung mit einem einzigen Thread. Eine gute Analogie für das Problem ist ein Jongleur, der mehrere Bälle in der Luft halten muss, ohne dass sie sich gegenseitig negativ beeinflussen. Es ist eine große Herausforderung. Mit den richtigen Tools und der richtigen Denkweise ist es jedoch beherrschbar.

In diesem Artikel beschäftige ich mich mit einigen der Tools, die ich entwickelt habe, um die Multithreadprogrammierung zu vereinfachen und Probleme wie Racebedingungen, Deadlocks und andere Schwierigkeiten zu vermeiden. Die Toolkette basiert wahrscheinlich auf syntaktischem Zucker und magischen Delegaten. Um jedoch den großen Jazzmusiker Miles Davis zu zitieren: „In der Musik ist Stille wichtiger als Klang“. Die Magie geschieht zwischen den Tönen.

Anders ausgedrückt, geht es nicht unbedingt darum, was Sie codieren können, sondern vielmehr darum, was Sie könnten, aber nicht wollen, weil Sie lieber ein wenig Magie zwischen den Zeilen erschaffen möchten. Ein Zitat von Bill Gates kommt mir in den Sinn: „Die Qualität der Arbeit nach der Anzahl der Codezeilen zu messen ist so, als würde man die Qualität eines Flugzeugs nach seinem Gewicht beurteilen“. Anstatt Ihnen also zu zeigen, wie Sie mehr Code schreiben können, hoffe ich, dass ich Ihnen helfen kann, weniger zu Code zu schreiben.

Die Herausforderung von Synchronisierung

Das erste Problem, das Ihnen bei der Multithreadprogrammierung begegnen wird, ist die Synchronisierung des Zugriffs auf eine freigegebene Ressource. Probleme treten auf, wenn sich mindestens zwei Threads den Zugriff auf ein Objekt teilen und beide möglicherweise versuchen, das Objekt gleichzeitig zu ändern. Als C# zum ersten Mal veröffentlicht wurde, implementierte die lock-Anweisung eine grundlegende Methode zum Sicherstellen, dass nur ein Thread auf eine bestimmte Ressource (beispielsweise eine Datendatei) zugreifen konnte, und dies funktionierte gut. Das Schlüsselwort „lock“ in C# ist so leicht zu verstehen, dass es im Alleingang die Art und Weise revolutioniert hat, wie wir über dieses Problem denken.

Eine einfache Sperre weist jedoch einen großen Nachteil auf: Dabei wird Lesezugriff nicht von Schreibzugriff unterschieden. Beispielsweise können Sie 10 verschiedene Threads verwenden, die aus einem freigegebenen Objekt lesen möchten, und diese Threads können über die ReaderWriterLockSlim-Klasse im Namespace System.Threading gleichzeitig Zugriff auf Ihre Instanz erhalten, ohne Probleme zu verursachen. Im Gegensatz zur lock-Anweisung können Sie mit dieser Klasse angeben, ob Ihr Code in das Objekt schreibt oder nur aus dem Objekt liest. Dies ermöglicht mehreren Lesern gleichzeitig den Zugriff, verweigert aber jedem Schreibcode den Zugriff, bis alle anderen Lese- und Schreibthreads ihre Arbeit getan haben.

Das Problem dabei: Die Syntax beim Nutzen der ReaderWriterLock-Klasse wird mühsam mit viel repetitivem Code, der die Lesbarkeit verringert und die Verwaltung im Lauf der Zeit erschwert. Außerdem wird Ihr Code häufig durch mehrere try- und finally-Blöcke verunstaltet. Ein einfacher Rechtschreibfehler kann außerdem katastrophale Auswirkungen haben, die später manchmal extrem schwer zu erkennen sind. 

Durch die Kapselung von ReaderWriterLockSlim in einer einfachen Klasse wird das Problem plötzlich ohne repetitiven Code gelöst, während gleichzeitig das Risiko verringert wird, dass ein kleiner Tippfehler Ihnen den Tag verderben könnte. Die Klasse (siehe Abbildung1) basiert vollständig auf Lambdatricksereien. Sie ist wohl nur syntaktischer Zucker, der sich um einige Delegaten dreht (vorausgesetzt, es gibt einige Schnittstellen). Am wichtigsten ist, dass sie dazu beitragen kann, dass Ihr Code in größerem Ausmaß DRY wird (wie in „Don't Repeat Yourself“ (wiederhole Dich nicht selbst)).

Abbildung 1: Kapseln von ReaderWriterLockSlim

public class Synchronizer<TImpl, TIRead, TIWrite> where TImpl : TIWrite, TIRead
{
  ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  TImpl _shared;

  public Synchronizer(TImpl shared)
  {
    _shared = shared;
  }

  public void Read(Action<TIRead> functor)
  {
    _lock.EnterReadLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitReadLock();
    }
  }

  public void Write(Action<TIWrite> functor)
  {
    _lock.EnterWriteLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitWriteLock();
    }
  }
}

Es gibt nur 27 Zeilen Code in Abbildung 1, die eine elegante und präzise Möglichkeit zur Sicherstellung bieten, dass Objekte über mehrere Threads hinweg synchronisiert werden. Die Klasse geht davon aus, dass Sie über eine Lese- und eine Schreibschnittstelle für Ihren Typ verfügen. Sie können sie auch verwenden, indem Sie die Vorlagenklasse selbst drei Mal wiederholen, wenn Sie aus irgendeinem Grund die Implementierung der zugrunde liegenden Klasse nicht ändern können, mit der Sie den Zugriff synchronisieren müssen. Die grundlegende Syntax kann so ähnlich sein wie in Abbildung 2 dargestellt.

Abbildung 2: Verwenden der Synchronizer-Klasse

interface IReadFromShared
{
  string GetValue();
}

interface IWriteToShared
{
  void SetValue(string value);
}

class MySharedClass : IReadFromShared, IWriteToShared
{
  string _foo;

  public string GetValue()
  {
    return _foo;
  }

  public void SetValue(string value)
  {
    _foo = value;
  }
}

void Foo(Synchronizer<MySharedClass, IReadFromShared, IWriteToShared> sync)
{
  sync.Write(x => {
    x.SetValue("new value");
  });
  sync.Read(x => {
    Console.WriteLine(x.GetValue());
  })
}

Im Code in Abbildung 2 wird unabhängig davon, wie viele Threads Ihre Foo-Methode ausführen, keine Write-Methode aufgerufen, solange eine andere Read- oder Write-Methode ausgeführt wird. Es können jedoch mehrere Read-Methoden gleichzeitig aufgerufen werden, ohne dass Sie Ihren Code mit mehreren try/catch/finally-Anweisungen versehen oder den gleichen Code immer wieder wiederholen müssen. Für das Protokoll: Die Nutzung mit einer einfachen Zeichenfolge ist sinnlos, da System.String unveränderlich ist. Ich verwende hier ein einfaches Zeichenfolgenobjekt, um das Beispiel zu vereinfachen.

Die Grundidee besteht darin, dass alle Methoden, die den Zustand Ihrer Instanz ändern können, der IWriteToShared-Schnittstelle hinzugefügt werden müssen. Gleichzeitig sollten alle Methoden, die nur aus Ihrer Instanz lesen, der IReadFromShared-Schnittstelle hinzugefügt werden. Indem Sie Ihre Belange so in zwei unterschiedlichen Schnittstellen trennen und beide Schnittstellen für Ihren zugrunde liegenden Typ implementieren, können Sie dann mit der Synchronizer-Klasse den Zugriff auf Ihre Instanz synchronisieren. Auf diese Weise wird die Kunst der Synchronisierung des Zugriffs auf Ihren Code viel einfacher, und Sie können dies größtenteils auf eine viel deklarativere Weise bewerkstelligen.

Wenn es um Multithreadprogrammierung geht, könnte syntaktischer Zucker den Unterschied zwischen Erfolg und Scheitern ausmachen. Das Debuggen von Multithreadcode ist oft extrem schwierig, und das Erstellen von Komponententests für Synchronisierungsobjekte kann eine vergebliche Übung sein.

Wenn Sie möchten, können Sie einen überladenen Typ mit nur einem generischen Argument erstellen, der von der ursprünglichen Synchronizer-Klasse erbt und sein einziges generisches Argument als Typargument drei Mal an seine Basisklasse übergibt. Auf diese Weise benötigen Sie die Lese- oder Schreibschnittstellen nicht, da Sie einfach die konkrete Implementierung Ihres Typs verwenden können. Dieser Ansatz erfordert jedoch, dass Sie sich manuell um die Elemente kümmern, die entweder die Write- oder die Read-Methode verwenden müssen. Er ist auch etwas weniger sicher, ermöglicht es aber, Klassen, die Sie nicht in eine Synchronizer-Instanz ändern können, in einen Wrapper einzuschließen.

Lambdasammlungen für Ihre Forks

Sobald Sie die ersten Schritte in die Magie der Lambda-Ausdrücke (oder Delegaten, wie sie in C# genannt werden) unternommen haben, ist es nicht schwer, sich vorzustellen, dass Sie noch viel mehr mit ihnen erreichen können. Ein häufiges wiederkehrendes Thema im Multithreading ist beispielsweise, dass mehrere Threads auf andere Server zugreifen, um Daten abzurufen und die Daten an den Aufrufer zurückzugeben.

Das einfachste Beispiel wäre eine Anwendung, die Daten von 20 Webseiten liest und nach Abschluss das HTML wieder an einen einzelnen Thread zurückgibt, der eine Art aggregiertes Ergebnis basierend auf dem Inhalt aller Seiten erstellt. Wenn Sie nicht für jede Ihrer Abrufmethoden einen Thread erstellen, wird dieser Code viel langsamer sein als gewünscht: 99 Prozent der gesamten Ausführungszeit würden wahrscheinlich damit verbracht, auf die Rückgabe der HTTP-Anforderung zu warten.

Die Ausführung dieses Codes in einem einzelnen Thread ist ineffizient, und die Syntax zum Erstellen eines Threads ist schwierig zu handhaben. Die Herausforderung wird noch größer, wenn Sie mehrere Threads und die dazugehörigen Objekte unterstützen, wodurch Entwickler gezwungen sind, sich beim Schreiben des Codes zu wiederholen. Sobald Sie feststellen, dass Sie eine Sammlung von Delegaten und eine Klasse als Wrapper für sie erstellen können, können Sie anschließend alle Ihre Threads mit einem einzigen Methodenaufruf erstellen. Auf diese Weise wird das Erstellen von Threads viel weniger mühsam.

In Abbildung 3 finden Sie einen Codeausschnitt, der zwei solche Lambda-Ausdrücke erstellt, die parallel ausgeführt werden. Beachten Sie, dass dieser Code tatsächlich aus den Komponententests meiner ersten Version der Lizzie-Skriptsprache stammt, die Sie unter bit.ly/2FfH5y8 finden können.

Abbildung 3: Erstellen von Lambda-Ausdrücken

public void ExecuteParallel_1()
{
  var sync = new Synchronizer<string, string, string>("initial_");

  var actions = new Actions();
  actions.Add(() => sync.Assign((res) => res + "foo"));
  actions.Add(() => sync.Assign((res) => res + "bar"));

  actions.ExecuteParallel();

  string result = null;
  sync.Read(delegate (string val) { result = val; });
  Assert.AreEqual(true, "initial_foobar" == result || result == "initial_barfoo");
}

Wenn Sie sich diesen Code genau ansehen, werden Sie feststellen, dass das Ergebnis der Auswertung nicht davon ausgeht, dass einer meiner Lambda-Ausdrücke vor dem anderen ausgeführt wird. Die Reihenfolge der Ausführung ist nicht explizit angegeben, und diese Lambda-Ausdrücke werden in separaten Threads ausgeführt. Dies liegt daran, dass die Actions-Klasse in Abbildung 3 es Ihnen ermöglicht, Delegaten hinzuzufügen, sodass Sie später entscheiden können, ob Sie die Delegaten parallel oder sequenziell ausführen möchten.

Zu diesem Zweck müssen Sie eine Reihe von Lambda-Ausdrücken erstellen und diese mit Ihrem bevorzugten Mechanismus ausführen. Sie können erkennen, wie die zuvor erwähnte Synchronizer-Klasse in Abbildung 3 den Zugriff auf die freigegebene Zeichenfolgenressource synchronisiert. Allerdings verwendet sie eine neue Methode für den Synchronizer („Assign“), die ich nicht in die Auflistung in Abbildung 1 für meine Synchronizer-Klasse aufgenommen habe. Die Assign-Methode verwendet die gleiche „Lambda-Trickserei“, die ich zuvor in den Write- und Read-Methoden beschrieben habe.

Wenn Sie die Implementierung der Actions-Klasse untersuchen möchten, beachten Sie, dass es wichtig ist, Version 0.1 von Lizzie herunterzuladen, da ich den Code vollständig neu geschrieben habe, damit er in späteren Versionen zu einer eigenständigen Programmiersprache wird.

Funktionale Programmierung in C#

Die meisten Entwickler neigen dazu, C# als fast gleichbedeutend oder zumindest eng verwandt mit objektorientierter Programmierung (OOP) zu betrachten – und das ist es offensichtlich. Indem Sie jedoch überdenken, wie Sie C# nutzen, und indem Sie sich in die funktionalen Aspekte vertiefen, wird es viel einfacher, einige Probleme zu lösen. OOP in ihrer jetzigen Form ist einfach nicht besonders gut wiederverwendbar, und das liegt zum großen Teil daran, dass sie stark typisiert ist.

Zum Beispiel zwingt Sie die Wiederverwendung einer einzelnen Klasse dazu, jede einzelne Klasse, auf die die ursprüngliche Klasse verweist, wiederzuverwenden: alle Klassen, die durch Zusammensetzung oder Vererbung verwendet werden. Darüber hinaus zwingt Sie die Wiederverwendung einer Klasse zur Wiederverwendung aller Klassen, auf die diese Drittanbieterklassen verweisen usw. Und wenn diese Klassen in verschiedenen Assemblys implementiert sind, müssen Sie eine ganze Reihe von Assemblys einbinden, nur um Zugriff auf eine einzige Methode für einen einzelnen Typ zu erhalten.

Ich bin vor einiger Zeit auf eine Analogie gestoßen, die dieses Problem veranschaulicht: „Sie möchten eine Banane, aber am Ende haben Sie einen Gorilla, der eine Banane hält, und den Regenwald, in dem der Gorilla lebt.“ Vergleichen Sie diese Situation mit der Wiederverwendung in einer dynamischeren Sprache (beispielsweise JavaScript), die sich nicht um Ihren Typ kümmert, solange sie die Funktionen implementiert, die Ihre Funktionen selbst nutzen. Ein etwas loser typisierter Ansatz ergibt Code, der flexibler ist und einfacher wiederverwendet werden kann. Mit Delegaten ist dies möglich.

Sie können mit C# so arbeiten, dass die Wiederverwendung von Code über mehrere Projekte hinweg verbessert wird. Sie müssen nur erkennen, dass eine Funktion oder ein Delegat auch ein Objekt sein kann und dass Sie Sammlungen dieser Objekte in einer schwach typisierten Weise manipulieren können.

Die Konzepte zu den in diesem Artikel vorgestellten Delegaten bauen auf denen eines früheren Artikels auf, den ich geschrieben habe: „Erstellen einer eigenen Skriptsprache mit symbolischen Delegaten“ in der Ausgabe von MSDN Magazine aus November 2018 (msdn.com/magazine/mt830373). In diesem Artikel wurde auch Lizzie vorgestellt, meine selbst gebastelte Skriptsprache, die ihre Existenz dieser delegatenzentrierten Denkweise verdankt. Wenn ich Lizzie nach OOP-Regeln erstellt hätte, wäre diese Skriptsprache meiner Meinung nach um wahrscheinlich mindestens eine Größenordnung umfangreicher.

Natürlich sind OOP und starke Typisierung heute in einer so dominanten Position, dass es praktisch unmöglich ist, eine Stellenbeschreibung zu finden, die sie nicht als primäre erforderliche Fähigkeit erwähnt. Für das Protokoll: Ich habe mehr als 25 Jahre lang OOP-Code erstellt. Also bin ich genauso schuldig wie jeder andere Entwickler mit einem stark typisierten Schwerpunkt. Heute bin ich jedoch pragmatischer in meinem Ansatz bei der Programmierung und weniger daran interessiert, wie meine Klassenhierarchie am Ende aussieht.

Es ist nicht so, dass ich eine wohlgeformte Klassenhierarchie nicht zu schätzen wüsste, aber sie lohnt sich immer weniger. Je mehr Klassen Sie einer Hierarchie hinzufügen, desto weniger elegant wird sie, bis sie unter ihrem eigenen Gewicht zusammenbricht. Manchmal weist das überlegene Design wenige Methoden, weniger Klassen und zumeist lose gekoppelte Funktionen auf, sodass der Code leicht erweitert werden kann, ohne dass Sie „den Gorilla und den Regenwald einbeziehen“ müssen.

Ich komme auf das wiederkehrende Thema dieses Artikels zurück, inspiriert von Miles Davis Zugang zur Musik, wo weniger mehr ist und „Stille wichtiger ist als Klang“. Dies gilt auch für Code. Die Magie entfaltet sich oft zwischen den Zeilen, und die besten Lösungen lassen sich eher daran messen, welchen Code Sie nicht schreiben, als daran, welchen Sie schreiben. Jeder Idiot kann in eine Trompete blasen und Lärm erzeugen, aber nur wenige können daraus Musik machen. Und noch weniger können Magie erzeugen, so wie es Miles Davis gelang.


Thomas Hansen arbeitet in der FinTech- und ForEx-Branche als Softwareentwickler und lebt auf Zypern.


Diesen Artikel im MSDN Magazine-Forum diskutieren