Sicheres und effizientes Multithreading in .NET

0
141

Multithreading kann verwendet werden, um die Leistung Ihrer Anwendung drastisch zu beschleunigen, aber keine Beschleunigung ist kostenlos—Das Verwalten von parallelen Threads erfordert eine sorgfältige Programmierung, und ohne die entsprechenden Vorsichtsmaßnahmen können Sie in Race-Conditions, Deadlocks und sogar Abstürze geraten.

Was macht Multithreading schwierig?

Sofern Sie Ihrem Programm nichts anderes mitteilen, wird Ihr gesamter Code im “Hauptthread” Vom Einstiegspunkt Ihrer Anwendung durchläuft es alle Ihre Funktionen und führt sie nacheinander aus. Dies hat eine Leistungsgrenze, da Sie natürlich nur so viel tun können, wenn Sie alles einzeln bearbeiten müssen. Die meisten modernen CPUs haben sechs oder mehr Kerne mit 12 oder mehr Threads, sodass die Leistung auf dem Tisch bleibt, wenn Sie sie nicht verwenden.

Es ist jedoch nicht so einfach wie das “Aktivieren von Multithreading” Nur bestimmte Dinge (wie Schleifen) können richtig multithreaded werden, und dabei sind viele Überlegungen zu berücksichtigen.

Der erste und wichtigste Punkt sind die Rennbedingungen< /stark>. Diese treten häufig während Schreibvorgängen auf, wenn ein Thread eine Ressource ändert, die von mehreren Threads gemeinsam genutzt wird. Dies führt zu einem Verhalten, bei dem die Ausgabe des Programms davon abhängt, welcher Thread zuerst etwas beendet oder ändert, was zu zufälligem und unerwartetem Verhalten führen kann.

Diese können sehr, sehr einfach sein,—zum Beispiel müssen Sie möglicherweise etwas zwischen den Schleifen fortlaufend zählen. Der offensichtlichste Weg, dies zu tun, besteht darin, eine Variable zu erstellen und sie zu inkrementieren, aber dies ist nicht threadsicher.

Werbung

Diese Race-Bedingung tritt auf, weil sie nicht nur “einer zur Variablen hinzufügt” im abstrakten Sinne; die CPU lädt den Wert von number in das Register, addiert eins zu diesem Wert und speichert dann das Ergebnis als neuen Wert der Variablen. Es weiß nicht, dass in der Zwischenzeit ein anderer Thread genau dasselbe versucht und einen bald falschen Wert von number geladen hat. Die beiden Threads stehen in Konflikt und am Ende der Schleife ist die Zahl möglicherweise ungleich 100.

.NET bietet eine Funktion, die Sie bei der Verwaltung unterstützt: das Sperrschlüsselwort. Dies verhindert nicht, dass Änderungen direkt vorgenommen werden, aber es hilft bei der Verwaltung der Parallelität, indem es nur einem Thread gleichzeitig erlaubt, die Sperre zu erhalten. Wenn ein anderer Thread versucht, eine Sperranweisung einzugeben, während ein anderer Thread verarbeitet, wartet er bis zu 300 ms, bevor er fortfährt.

Sie können nur Referenztypen sperren, daher wird ein gemeinsames Muster erstellt vorher ein Sperrobjekt und dieses als Ersatz für das Sperren des Werttyps verwenden.

< /p>

Sie werden jedoch feststellen, dass es jetzt ein weiteres Problem gibt: Deadlocks. Dieser Code ist ein Worst-Case-Beispiel, aber hier ist er fast genauso wie eine normale for-Schleife (eigentlich etwas langsamer, da zusätzliche Threads und Sperren zusätzlichen Aufwand verursachen). Jeder Thread versucht, die Sperre zu erhalten, aber immer nur einer kann die Sperre haben, sodass nur jeweils ein Thread den Code innerhalb der Sperre tatsächlich ausführen kann. In diesem Fall ist das der gesamte Code der Schleife, also entfernt die Lock-Anweisung alle Vorteile des Threadings und macht alles nur langsamer.

Im Allgemeinen möchten Sie bei Bedarf sperren, wenn Sie Schreibvorgänge ausführen müssen. Sie sollten jedoch die Parallelität bei der Auswahl der zu sperrenden Elemente berücksichtigen, da Lesevorgänge auch nicht immer threadsicher sind. Wenn ein anderer Thread in das Objekt schreibt, kann das Lesen aus einem anderen Thread einen falschen Wert ergeben oder dazu führen, dass eine bestimmte Bedingung ein falsches Ergebnis zurückgibt.

Glücklicherweise gibt es ein paar Tricks, um dies richtig zu machen, wo Sie können die Geschwindigkeit von Multithreading ausgleichen, während Sie Sperren verwenden, um Race-Bedingungen zu vermeiden.

Interlocked for atomic Operations verwenden

Für grundlegende Operationen kann die Verwendung der lock-Anweisung übertrieben sein. Es ist zwar sehr nützlich, um vor komplexen Änderungen zu sperren, aber es ist zu viel Aufwand für etwas so Einfaches wie das Hinzufügen oder Ersetzen eines Werts.

Werbung

Interlocked ist eine Klasse, die einige Speichervorgänge wie das Hinzufügen, Ersetzen und Vergleichen umfasst. Die zugrunde liegenden Methoden werden auf CPU-Ebene implementiert und sind garantiert atomar und viel schneller als die standardmäßige Lock-Anweisung. Sie sollten sie wann immer möglich verwenden, obwohl sie das Sperren nicht vollständig ersetzen.

Im obigen Beispiel wird das Ersetzen der Sperre durch einen Aufruf von Interlocked.Add() beschleunigt die Operation viel. Dieses einfache Beispiel ist zwar nicht schneller, als Interlocked nicht zu verwenden, aber es ist als Teil einer größeren Operation nützlich und immer noch eine Beschleunigung.

Es gibt auch Inkrement und Dekrement für ++ und — Operationen, wodurch Sie solide zwei Tastenanschläge sparen. Sie packen Add(ref count, 1) buchstäblich unter die Haube, sodass ihre Verwendung nicht spezifisch beschleunigt wird.

Sie können auch Exchange verwenden, eine generische Methode, die eine Variable gleich dem übergebenen Wert setzt. Allerdings sollten Sie mit diesem Wert vorsichtig sein, wenn Sie ihn auf einen Wert setzen, den Sie mit dem ursprünglichen Wert berechnet haben, ist dies nicht threadsicher, da der alte Wert vor der Ausführung von Interlocked geändert worden sein könnte .Exchange.

CompareExchange prüft zwei Werte auf Gleichheit, und ersetzen Sie den Wert, wenn sie gleich sind.

Verwenden Sie Thread-sichere Sammlungen

Die Standardsammlungen in System.Collections.Generic können mit Multithreading verwendet werden, sind jedoch nicht vollständig threadsicher. Microsoft bietet threadsichere Implementierungen einiger Sammlungen in System.Collections.Concurrent.

Werbung

Dazu gehören ConcurrentBag, eine ungeordnete generische Sammlung, und ConcurrentDictionary, ein threadsicheres Wörterbuch. Es gibt auch gleichzeitige Warteschlangen und Stacks sowie OrderablePartitioner, die bestellbare Datenquellen wie Listen in separate Partitionen für jeden Thread aufteilen können.

Schleifen parallelisieren< /h2>

Der einfachste Ort für Multithreading ist oft in großen, teuren Loops. Wenn Sie mehrere Optionen parallel ausführen können, können Sie die Gesamtlaufzeit enorm beschleunigen.

Dies geht am besten mit System.Threading.Tasks.Parallel. Diese Klasse bietet Ersatz für for – und foreach-Schleifen, die die Schleifenkörper in separaten Threads ausführen. Es ist einfach zu verwenden, erfordert jedoch eine etwas andere Syntax:

Der Haken dabei ist natürlich, dass Sie sicherstellen müssen, dass DoSomething() threadsicher ist und keine gemeinsamen Variablen stört. Dies ist jedoch nicht immer so einfach wie das Ersetzen der Schleife durch eine parallele Schleife, und in vielen Fällen müssen Sie gemeinsam genutzte Objekte sperren, um Änderungen vorzunehmen.

Um einige der Probleme mit Deadlocks zu beheben, bieten Parallel.For und Parallel.ForEach zusätzliche Funktionen für den Umgang mit dem Status. Grundsätzlich wird nicht jede Iteration in einem separaten Thread ausgeführt —wenn Sie 1000 Elemente haben, werden nicht 1000 Threads erstellt; Es wird so viele Threads erstellen, wie Ihre CPU verarbeiten kann, und mehrere Iterationen pro Thread ausführen. Das bedeutet, dass Sie, wenn Sie eine Gesamtsumme berechnen, nicht für jede Iteration sperren müssen. Sie können einfach eine Zwischensummenvariable herumreichen und ganz am Ende das Objekt sperren und einmal Änderungen vornehmen. Dies reduziert den Overhead bei sehr großen Listen drastisch.

Werbung

Schauen wir uns ein Beispiel an. Der folgende Code benötigt eine große Liste von Objekten und muss jedes einzeln in JSON serialisieren, was zu einer List<string> aller Objekte. Die JSON-Serialisierung ist ein sehr langsamer Prozess, daher ist die Aufteilung jedes Elements auf mehrere Threads eine große Beschleunigung.

Hier gibt es eine Menge Argumente und eine Menge zu entpacken:

  • Das erste Argument nimmt ein IEnumerable an, das die Daten definiert, über die es durchlaufen wird. Dies ist eine ForEach-Schleife, aber das gleiche Konzept funktioniert für grundlegende For-Schleifen.
  • Die erste Aktion initialisiert die lokale Zwischensummenvariable. Diese Variable wird bei jeder Iteration der Schleife geteilt, jedoch nur innerhalb desselben Threads. Andere Threads haben ihre eigenen Zwischensummen. Hier initialisieren wir es mit einer leeren Liste. Wenn Sie eine numerische Summe berechnen, können Sie hier 0 zurückgeben.
  • Die zweite Aktion ist der Hauptschleifenkörper. Das erste Argument ist das aktuelle Element (oder der Index in einer For-Schleife), das zweite ist ein ParallelLoopState-Objekt, mit dem Sie .Break() aufrufen können, und das letzte ist die Zwischensummenvariable.
    • In dieser Schleife können Sie das Element bearbeiten und die Zwischensumme ändern. Der zurückgegebene Wert ersetzt die Zwischensumme für die nächste Schleife. In diesem Fall serialisieren wir das Element in eine Zeichenfolge und fügen dann die Zeichenfolge zur Zwischensumme hinzu, die eine Liste ist.
  • Schließlich nimmt die letzte Aktion die Zwischensumme ‘Ergebnis’ nach Abschluss aller Ausführungen, sodass Sie eine Ressource basierend auf der Endsumme sperren und ändern können. Diese Aktion wird einmal ganz am Ende ausgeführt, aber sie wird immer noch in einem separaten Thread ausgeführt, sodass Sie die Methoden sperren oder Interlocked-Methoden verwenden müssen, um Ressourcen zu ändern. Hier rufen wir AddRange() auf, um die Zwischensummenliste an die endgültige Liste anzuhängen.

Unity Multithreading

Eins Schlussbemerkung—wenn Sie die Unity-Spiel-Engine verwenden, sollten Sie beim Multithreading vorsichtig sein. Sie können keine Unity-APIs aufrufen, sonst stürzt das Spiel ab. Es ist möglich, es sparsam zu verwenden, indem Sie API-Operationen im Hauptthread ausführen und hin und her wechseln, wenn Sie etwas parallelisieren müssen.

Meistens gilt dies für Operationen, die mit der Szene oder der Physik interagieren Motor. Vector3-Mathematik ist davon nicht betroffen und Sie können sie problemlos in einem separaten Thread verwenden. Es steht Ihnen auch frei, Felder und Eigenschaften Ihrer eigenen Objekte zu ändern, vorausgesetzt, sie rufen keine Unity-Operationen unter der Haube auf.