Praktische Best Practices für Multithreading: .NET-Edition — Was Sie entscheiden sollten, bevor Sie weitere Threads hinzufügen
· Go Komura · Windows, Multithreading, C#, .NET, Geschäftsanwendungen, Fehleruntersuchung, Design
„Die Verarbeitung war langsam, also haben wir Threads gestartet, um sie zu parallelisieren – und jetzt stimmen die aggregierten Summen gelegentlich nicht mehr.“ „Wir haben eine Hintergrundverarbeitung hinzugefügt, und jetzt friert die Anwendung einmal im Monat ein.“ „Man sagt uns, es reproduziere sich nicht im Debug-Lauf, aber beim Kunden tritt es definitiv auf.“ — Das Beängstigende an Multithreading-Programmierung ist, dass der Code direkt nach dem Schreiben korrekt zu funktionieren scheint. Bugs durch Race Conditions sind zeitabhängig: Sie schlüpfen durch Tests hindurch und zeigen sich erst in der Produktion.
Andererseits ist Multithreading auch in Geschäftsanwendungen inzwischen unvermeidlich, da Multicore-Hardware zur Norm geworden ist – etwa bei Anforderungen wie „aufwendige Verarbeitung ausführen, ohne die UI einfrieren zu lassen“ oder „mehrere Geräte oder Dateien parallel verarbeiten“. Entscheidend ist, die Entwurfsprinzipien festzulegen, bevor Sie weitere Threads hinzufügen. Denn Multithreading-Bugs beseitigt man nicht durch Debugging – man entwirft so, dass ihnen von vornherein kein Raum bleibt.
Dieser Artikel ist die .NET-Edition der Multithreading-Praxisserie. Er richtet sich an Entwickler, die unter Windows Geschäftsanwendungen entwickeln und Multithreading einführen müssen, und fasst sprach- und betriebssystemunabhängige Entwurfsprinzipien sowie das konkrete Werkzeugrepertoire in C#/.NET zusammen – gestützt auf Primärquellen mit Stand August 2026. Die Prinzipien selbst ändern sich weder unter Linux noch in C++. Wenn Sie nativen Code schreiben, finden Sie dieselben Prinzipien, übertragen auf die jeweiligen Sprachwerkzeuge, in der „C++-Edition“ und der „C-Edition“; wenn Sie mit Java arbeiten, in der „Java-Edition“.
1. Das Wichtigste zuerst
- Die erste Best Practice besteht darin, keine Threads selbst zu erstellen. Setzen Sie statt auf
new Threadauf höherwertige APIs wie Task, den Thread-Pool und dieParallel-Klasse, und überlassen Sie die Verwaltung der Thread-Anzahl der Laufzeitumgebung.12 - Das Erste, was Sie bei der Parallelisierung eliminieren sollten, ist „gemeinsam genutzter veränderlicher Zustand“. Stellen, an denen mehrere Threads in dieselbe Variable schreiben, sind die Quelle von Konflikten. Reduzieren Sie die gemeinsame Nutzung selbst durch Aufteilen der Daten, Unveränderlichkeit und Weitergabe, bevor Sie zu Sperren greifen.3
- Geben Sie dem Sperren Disziplin. Legen Sie eins-zu-eins fest, „welche Sperre welche Daten schützt“, und verwenden Sie als Sperrobjekt eine dedizierte, nach außen nicht sichtbare Instanz.
lock(this)undlock(typeof(X))sind verboten. Ab .NET 9 verwenden Sie den eigens dafür vorgesehenen TypSystem.Threading.Lock.4 - Leiten Sie die Datenweitergabe zwischen Threads über eine Warteschlange. Ein Producer/Consumer-Aufbau mit
System.Threading.Channelsoder einer nebenläufigen Auflistung ist einfacher zu entwerfen als ein Geflecht aus Sperren und schafft zugleich eine klare Grenze.56 - Entwerfen Sie zuerst, wie es angehalten wird. Für das Beenden ist die kooperative Abbruchsteuerung über
CancellationTokendie einzig richtige Antwort;Thread.Abortlöst unter .NET (der Core-Linie) eine Laufzeitausnahme aus.78 - Die UI gehört ausschließlich dem UI-Thread. Weder WinForms-Steuerelemente noch WPF-Elemente dürfen von einem anderen Thread als dem, der sie erstellt hat, berührt werden. Von einem anderen Thread aus erfolgt der Zugriff als Auftrag über
Control.Invokebzw.Dispatcher.910 - „Parallel bedeutet nicht zwangsläufig schneller.“ Eine Schleife, bei der die Arbeit pro Durchlauf gering ist, wird durch den Overhead der Parallelisierung eher langsamer. Messen Sie immer, bevor Sie sich dafür entscheiden.3
2. Warum Multithreading schwierig ist — Race Conditions und Deadlocks
Letztlich führt Multithreading zwei Arten von Problemen ein.4
Eine Race Condition ist ein Fehler, bei dem sich das Ergebnis danach richtet, in welcher Reihenfolge mehrere Threads eine bestimmte Codestelle erreichen. Das klassische Beispiel ist das Inkrementieren eines gemeinsam genutzten Zählers: Die eine Zeile count++ zerfällt tatsächlich in drei Schritte – „lesen → addieren → zurückschreiben“. Führen zwei Threads diese drei Schritte gleichzeitig aus, überschreibt das Zurückschreiben des einen Threads die Addition des anderen, und das Inkrement geht verloren. Das Ergebnis ändert sich bei jedem Lauf, und welches Ergebnis herauskommt, ist nicht vorhersagbar.4
sequenceDiagram
participant A as Thread A
participant M as Gemeinsame Variable count
participant B as Thread B
Note over M: count = 10
A->>M: Lesen (10)
B->>M: Lesen (10)
A->>A: Lokal addieren (11)
B->>B: Lokal addieren (11)
A->>M: Zurückschreiben (11)
B->>M: Zurückschreiben (11)
Note over M: count = 11 trotz zweier Inkremente<br/>Das Inkrement von Thread A ging verloren
Abbildung 1: Eine typische Race Condition, bei der ein Inkrement auf einem gemeinsam genutzten Zähler verloren geht. Unterbricht ein anderer Thread die drei Schritte von count++, überschreibt derjenige, der zuletzt zurückschreibt, den anderen
Ein Deadlock ist ein Zustand, in dem zwei Threads jeweils auf die vom anderen gehaltene Sperre warten und keiner von beiden weiterkommt. Thread A hält Sperre 1 und wartet auf Sperre 2, Thread B hält Sperre 2 und wartet auf Sperre 1 — das allein genügt, damit beide für immer stehen bleiben.4
flowchart LR
A["Thread A<br/>hält Sperre 1"] -->|"wartet auf Freigabe von Sperre 2"| B["Thread B<br/>hält Sperre 2"]
B -->|"wartet auf Freigabe von Sperre 1"| A
Abbildung 2: Die zirkuläre Wartesituation eines Deadlocks. Sobald die Wartepfeile einen Ring bilden, bleiben alle Threads in diesem Ring für immer stehen
Das Tückische an beidem ist, dass es zeitabhängig ist. Es ist ganz normal, dass eine Interleaving-Kombination (eine bestimmte Ausführungsreihenfolge), die auf einer Entwicklungsmaschine nur einmal unter Zehntausenden von Läufen auftritt, auf einer Kundenmaschine mit anderer Kernzahl und anderem Timing täglich vorkommt. Auch „es reproduziert sich nicht, wenn man den Debugger anhängt“ oder „es verschwand, als ich Logging hinzufügte“ liegt daran, dass die Beobachtung selbst das Timing verändert – ein typisches Verhalten von Race-Bugs.
Genau deshalb weisen alle folgenden Prinzipien in eine Richtung: Vor „richtig synchronisieren“ steht „die Stellen reduzieren, an denen synchronisiert werden muss“ — das ist das Rückgrat des Multithreading-Designs.
3. Prinzip 1: Threads nicht selbst erstellen
3.1. Auf Task und den Thread-Pool setzen
Einen Thread direkt mit new Thread(...) zu erstellen, ist im heutigen .NET ein Ausnahmefall und letztes Mittel. Seit .NET Framework 4 ist das empfohlene Mittel für Multithreading- und Parallelcode die TPL (Task Parallel Library), also die auf Task zentrierte API-Familie. Die TPL passt den Parallelitätsgrad dynamisch an die verfügbaren Prozessoren an und übernimmt sämtliche Low-Level-Aufgaben wie das Aufteilen der Arbeit, die Einplanung auf dem Thread-Pool, die Behandlung von Abbrüchen und die Zustandsverwaltung.1
Der Thread-Pool ist die Infrastruktur, die .NET selbst umfassend nutzt – für die Ausführung von Tasks, den Abschluss asynchroner I/O-Vorgänge, Timer-Rückrufe und mehr. Solange Sie ihm kurze Arbeitspakete übergeben, müssen Entwickler den Lebenszyklus von Threads nicht selbst verwalten.2
// Führt eine CPU-intensive Berechnung im Hintergrund aus
var result = await Task.Run(() => HeavyCalculation(input));
// Mehrere unabhängige Vorgänge parallel starten und auf alle warten (bei kleiner Anzahl)
// * Diese Form setzt voraus, dass ProcessAsync eine I/O-lastige asynchrone Methode ist.
// WhenAll wartet lediglich auf "bereits laufende" Tasks. Wenn Sie CPU-Berechnungen
// parallel ausführen wollen, packen Sie jeden Vorgang in Task.Run(() => Calc(x)),
// um ihn auf den Thread-Pool zu bringen
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Bei vielen Elementen den Grad der Nebenläufigkeit begrenzen
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Zwei Punkte sind wichtig: das Token des Aufrufers mit ParallelOptions verbinden
// (vergisst man das, ist das ct im Rumpf immer None) und dieses ct auch an den
// Rumpf weiterreichen (nicht verwerfen)
Ein Vorbehalt ist zu beachten. Task.WhenAll(items.Select(...)) startet die Verarbeitung aller Elemente auf einmal, sobald sie enumeriert wird. Bei einer festen Handvoll bis zu einigen Dutzend Elementen ist das unproblematisch, aber bei einer großen Collection erschöpft es Sockets, DB-Verbindungen und Speicher gleichzeitig. Bei Arbeit mit unbekannter Elementanzahl begrenzen Sie entweder den Parallelitätsgrad wie oben mit Parallel.ForEachAsync, oder Sie steuern den Durchsatz mit einem später beschriebenen bounded Channel.
Ein selbst erstellter Thread ist praktisch nur dann gerechtfertigt, wenn eine Eigenschaft des Threads selbst zur Anforderung wird – etwa „braucht eine eigene Nachrichtenschleife“, „muss ein Thread-Apartment (STA) festlegen“ oder „muss über die gesamte Lebensdauer der Anwendung laufen“.
3.2. Für Datenparallelität: Parallel.For / ForEach
Für Datenparallelität – „dieselbe Verarbeitung auf jedes Element einer Collection anwenden, um das Ganze zu beschleunigen“ – verwenden Sie Parallel.For / Parallel.ForEach, statt die Schleife selbst auf Threads zu verteilen. Das Aufteilen der Datenquelle (Partitionierung) und die Neuverteilung der Last übernimmt die TPL; bei einer einfachen Schleife sind nicht einmal Sperren nötig.11
Allerdings gibt es zwei Fallstricke, auf die die offizielle Dokumentation ausdrücklich hinweist.3
- Gehen Sie nicht davon aus, dass parallel immer schneller ist. Eine Schleife mit wenigen Durchläufen oder leichter Arbeit pro Durchlauf wird langsamer, weil der Overhead der Parallelisierung den eigentlichen Aufwand übersteigt. Die Performance hängt von vielen Faktoren ab – messen Sie immer, bevor Sie entscheiden.
- Lassen Sie Iterationen nicht aufeinander warten. Es gibt keine Garantie, dass jede Iteration von
Parallel.Fortatsächlich parallel ausgeführt wird. Code, bei dem eine Iteration auf ein von einer anderen Iteration gesetztes Ereignis wartet, kann je nach Scheduling in einen Deadlock geraten.
3.3. „Wartende“ Vorgänge gehören zu asynchronem I/O, nicht zu Threads
Vorgänge wie Datei-, Netzwerk- oder DB-Zugriffe, die überwiegend auf I/O warten, sind kein Kandidat für zusätzliche Threads. Einen ganzen Thread während des Wartens zu belegen, ist schlicht Verschwendung; asynchrones I/O mit async/await verbraucht während des Wartens keinen Thread. Diese Unterscheidung – CPU-gebundene Arbeit parallelisieren, I/O-gebundene Arbeit asynchron machen – ist die erste Trennlinie, die Sie am Eingang des Multithreading-Designs ziehen sollten.
flowchart TB
S["Es gibt Arbeit, die nebenläufig laufen soll"] --> Q1{"Was dominiert die Arbeit?"}
Q1 -->|"Überwiegend Warten auf I/O<br/>Datei, Netzwerk, DB"| ASYNC["Asynchrones I/O mit async/await<br/>keine zusätzlichen Threads"]
Q1 -->|"CPU-gebundene Berechnung"| Q2{"Welche Form hat die Arbeit?"}
Q2 -->|"Dieselbe Verarbeitung auf<br/>jedes Element einer Collection anwenden"| PAR["Parallel.For / ForEach"]
Q2 -->|"Ein unabhängiger, in sich<br/>geschlossener Hintergrundvorgang"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Nachrichtenschleife, STA-Vorgabe usw. –<br/>eine Eigenschaft des Threads selbst ist gefordert"| TH["new Thread<br/>(Ausnahmefall, letztes Mittel)"]
Abbildung 3: Die Verzweigungen, die Sie durchgehen sollten, bevor Sie „einen Thread starten“. Die meisten Geschäftsprozesse enden bei einem der oberen drei Ausgänge; new Thread zu erreichen, ist die Ausnahme
Praktische Entscheidungshilfen für async/await finden Sie in der „C# async/await-Praxis-Entscheidungstabelle“, und wie Thread-Pool und asynchrones I/O darunter zusammenhängen, wird ausführlich in „IOCP und der .NET-Thread-Pool“ behandelt.
4. Prinzip 2: Gemeinsam genutzten veränderlichen Zustand minimieren
Ein Konflikt entsteht nur, wenn „mehrere Threads“ und „gemeinsam genutzte veränderliche Daten“ zusammenkommen. Die Anzahl der Threads wird durch die Anforderungen bestimmt, sodass sich im Entwurf nur die gemeinsame Nutzung reduzieren lässt. Dafür gibt es drei Mittel.
4.1. Aufteilen — Jeder Thread berührt nur seine eigenen Daten
Am einfachsten und wirkungsvollsten ist es, die Daten pro Thread aufzuteilen. Bei der Aggregation in einer parallelen Schleife schreiben Sie nicht bei jedem Durchlauf in eine gemeinsame Summenvariable, sondern verwenden die Überladung von Parallel.For, die Thread-lokalen Zustand entgegennimmt: Jeder Thread bildet lokal eine Zwischensumme, und diese werden am Ende nur einmal zusammengeführt. Schreibzugriffe auf den gemeinsamen Zustand sinken von „bei jedem Durchlauf“ auf „einmal pro Thread“, wodurch sowohl die Synchronisationskosten als auch das Zeitfenster für Konflikte um Größenordnungen kleiner werden.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // Thread-lokaler Anfangswert
(i, state, local) => local + Weigh(items[i]), // Jede Iteration addiert nur zu ihrem eigenen local
local => Interlocked.Add(ref total, local)); // Zusammenführung erfolgt einmal pro Thread
flowchart TB
SRC["Datenarray (zu verarbeitende Eingabe)"] --> T1["Thread 1<br/>verarbeitet seinen Anteil und<br/>addiert nur zu seiner eigenen Zwischensumme"]
SRC --> T2["Thread 2<br/>verarbeitet seinen Anteil und<br/>addiert nur zu seiner eigenen Zwischensumme"]
SRC --> T3["Thread 3<br/>verarbeitet seinen Anteil und<br/>addiert nur zu seiner eigenen Zwischensumme"]
T1 --> M["Zusammenführung: Interlocked.Add überträgt<br/>die Zwischensumme jedes Threads einmal pro Thread in die Summe"]
T2 --> M
T3 --> M
Abbildung 4: Thread-lokale Aggregation. Da jeder Thread während der Verarbeitung nur seine eigenen Daten berührt, gibt es keinen Raum für Konflikte, und Schreibzugriffe auf den gemeinsamen Zustand erfolgen nur einmal pro Thread, bei der Zusammenführung
4.2. Unveränderlich machen — Was nicht überschrieben wird, darf geteilt werden
Daten, die nur gelesen werden, sind sicher, egal von wie vielen Threads gleichzeitig darauf zugegriffen wird. Konfigurationswerte, Stammdaten, Berechnungseingaben und Ähnliches lassen sich frei und ohne Synchronisierung teilen, wenn Sie sie nach der Konstruktion nicht mehr verändern (also unveränderlich machen). In C# unterstützen record-Typen und init-Eigenschaften diesen Entwurf. Allein die Entscheidung „wenn eine Änderung nötig wird, erstelle ich eine neue Instanz und tausche sie aus, statt die bestehende zu überschreiben“ reduziert den zu schützenden veränderlichen Zustand um ein Stück.
„Sieht schreibgeschützt aus“ und „ist unveränderlich“ sind allerdings zwei verschiedene Dinge. Eine schreibgeschützte Schnittstelle wie IReadOnlyList<T> bedeutet nur, dass „über diese Schnittstelle nicht geschrieben werden kann“ – sie verhindert nicht, dass die dahinterliegende List<T> über eine andere Referenz überschrieben wird. Auch die Garantie von record / init ist flach: Sie schützt nicht die Objekte, auf die eine Eigenschaft verweist. Für Daten, die Sie wirklich sicher zwischen Threads teilen möchten, verwenden Sie entweder eine unveränderliche Auflistung aus System.Collections.Immutable, etwa ImmutableArray<T>, oder übergeben Sie im Moment des Teilens eine Kopie und kappen so den Schreibpfad vollständig. Voraussetzung dafür ist, dass auch der Elementtyp T selbst unveränderlich ist. Eine unveränderliche Collection schützt nur die „Anordnung“ – Referenzen auf veränderliche Elementobjekte werden unverändert weitergegeben, sodass der Konflikt bestehen bleibt, wenn der Inhalt eines Elements über einen anderen Pfad überschrieben werden kann. Machen Sie den Objektgraphen bis in die Blätter hinein unveränderlich, oder übergeben Sie eine tiefe Kopie.
4.3. Weiterreichen — Statt zu teilen, über eine Warteschlange senden
Trotzdem müssen Sie gelegentlich Daten zwischen Threads bewegen. Verwenden Sie dann nicht „eine gemeinsame Variable, die beide Seiten berühren“, sondern einen Producer/Consumer-Aufbau, bei dem eine Warteschlange dazwischenliegt, in die die eine Seite schreibt und aus der die andere liest.
Die erste Wahl unter .NET ist System.Threading.Channels. Es handelt sich um eine FIFO-Struktur, in die der Producer asynchron schreibt und aus der der Consumer asynchron liest; die gesamte Synchronisierung übernimmt der Channel.5
var channel = Channel.CreateBounded<WorkItem>(100); // Kapazität 100 - erzeugt Backpressure
// Producer-Seite
await channel.Writer.WriteAsync(item, ct); // Wartet bei Vollauslastung, bis Platz frei wird
// …sobald alle Producer fertig geschrieben haben:
channel.Writer.Complete(); // Erklärt "es kommt nichts mehr". Ohne das kann die Leseschleife nie enden
// Consumer-Seite
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
P1["Producer 1<br/>WriteAsync"] --> CH["Bounded Channel (Kapazität 100)<br/>FIFO-Warteschlange<br/>Synchronisierung übernimmt der Channel"]
P2["Producer 2<br/>WriteAsync"] --> CH
CH --> C1["Consumer 1<br/>ReadAllAsync"]
CH --> C2["Consumer 2<br/>ReadAllAsync"]
CH -.->|"Bei Vollauslastung lässt es Schreiber warten<br/>(Backpressure)"| P1
CH -.->|"Bei Leerstand lässt es Leser warten"| C1
Abbildung 5: Ein Producer/Consumer-Aufbau mit einem Channel dazwischen. Keine Seite berührt eine gemeinsame Variable direkt; sowohl das Warten als auch die Kapazitätssteuerung übernimmt der Channel
In der Praxis kommt es darauf an, einen Channel mit begrenzter Kapazität (bounded) zu wählen. Das Standardverhalten beim Erreichen der Grenze ist, dass „die schreibende Seite auf freien Platz wartet“ – das wird zur natürlichen Backpressure. Verwenden Sie in einem Aufbau, in dem die Produktion schneller ist als der Konsum, eine unbegrenzte Warteschlange, erhalten Sie eine Zeitbombe, die zwar läuft, deren Speicherverbrauch aber immer weiter steigt.5
In der synchronen Welt übernimmt BlockingCollection<T> mit angegebener Kapazität die Rolle, die dort einem bounded Channel entspricht. Sie vereint Blockierung und Kapazitätssteuerung: Die Kapazitätsgrenze verhindert, dass die produzierende Seite der konsumierenden zu weit vorauseilt, und blockiert die konsumierende Seite, wenn sie leer ist, bis Nachschub kommt.12 ConcurrentQueue<T> / ConcurrentStack<T> dagegen sind schnelle Collections, die Threadsicherheit ohne Sperren, allein durch Interlocked-Operationen erreichen6, besitzen aber weder eine Kapazitätsgrenze noch einen Mechanismus, der bei Leerstand wartet – reine threadsichere Warteschlangen. Betrachten Sie sie als Baustein, nicht als Hauptdarsteller der Datenweitergabe. Beachten Sie außerdem, dass BlockingCollection<T> nicht für asynchronen Zugriff konzipiert ist; kombinieren Sie es mit async/await, wählen Sie stattdessen Channel<T>.12
Hüten Sie sich außerdem vor der Annahme, „ein Dictionary durch ConcurrentDictionary zu ersetzen mache es threadsicher“. Auch wenn einzelne Operationen threadsicher sind, konkurrieren zusammengesetzte Operationen wie „prüfen, ob vorhanden, und dann hinzufügen“ weiterhin (verwenden Sie dafür Methoden wie GetOrAdd, die eigens für zusammengesetzte Operationen gedacht sind). Auch GetOrAdd hat einen Vorbehalt: Zwar wird der gespeicherte Wert eindeutig festgelegt, aber die Factory-Funktion, die den Wert erzeugt, kann bei Konkurrenz mehrfach aufgerufen werden. Enthält die Factory einen Seiteneffekt (eine Verbindung öffnen, eine Datei anlegen usw.), führt die doppelte Ausführung zu einem Leck. Machen Sie die Factory daher entweder frei von Seiteneffekten, oder speichern Sie für eine Initialisierung, die garantiert nur einmal erfolgen soll, ein Lazy<T> als Wert. Den Typ der Collection zu wechseln, ist kein Ersatz dafür, den gemeinsam genutzten veränderlichen Zustand zu reduzieren.
5. Prinzip 3: Sperren diszipliniert einsetzen
Selbst wenn Sie gemeinsam genutzten veränderlichen Zustand reduzieren, lässt er sich oft nicht auf null bringen. Für das, was an gemeinsamer Nutzung übrig bleibt, verwenden Sie wechselseitigen Ausschluss (Sperren) – aber eine Sperre ist kein Werkzeug, um „vorsichtshalber verdächtige Stellen mit lock zu umschließen“. Es gibt vier Regeln der Disziplin.
5.1. Festlegen, „was geschützt wird“, und mit einem dedizierten Objekt sperren
Denken Sie bei der Einheit der Sperre nicht in „Codeabschnitten“, sondern in „Daten“. Ordnen Sie jeder Menge zu schützender veränderlicher Daten genau ein Sperrobjekt zu, und nehmen Sie an jeder Stelle, die diese Daten berührt, dieselbe Sperre — ein zusammenbrechendes Zuordnungsschema genau dieser Art ist in der Praxis die Ursache von Race-Bugs.
Machen Sie das gesperrte Objekt zu einer dedizierten, nicht nach außen sichtbaren Instanz. lock(this) teilt die Sperre mit jedem externen Code, der die eigene Instanz referenzieren kann, lock(typeof(X)) mit der gesamten Anwendungsdomäne – beides ist ein Nährboden für Deadlocks. Ab .NET 9 / C# 13 wird empfohlen, als Sperrobjekt eine Instanz des dedizierten Typs System.Threading.Lock zu verwenden.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (davor: readonly object)
private readonly List<Order> _orders = []; // Die Daten, die _gate schützt
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
Die C#-Anweisung lock garantiert, dass die Sperre auch bei einer Ausnahme zuverlässig freigegeben wird. Wie sie expandiert wird, hängt vom Typ des Sperrobjekts ab: Bei einem gewöhnlichen Objekt wird daraus ein Aufruf von Monitor.Exit in einem finally-Block, beim Lock-Typ ein Aufruf von EnterScope() samt dessen Disposal.413 Mit anderen Worten: Ein Feld vom Typ Lock ist ein anderer Mechanismus als Monitor, und wenn nur ein Teil des Codes Monitor.Enter(_gate) von Hand schreibt, kommt kein wechselseitiger Ausschluss mit lock (_gate) zustande. Bei beiden Typen ist es sicherer, auf das Handschreiben von Monitor.Enter / Exit zu verzichten und sich durchgängig auf die lock-Syntax festzulegen.4
5.2. Während eine Sperre gehalten wird, nichts „Zeitaufwendiges“ oder „Externes“ tun
Je kürzer die Haltezeit einer Sperre, desto besser; während sie gehalten wird, sollten Sie nur die geschützten Daten lesen und schreiben. Code, der bei gehaltener Sperre I/O ausführt oder über ein Ereignis oder einen Callback externen Code aufruft, verlängert nicht nur die Haltezeit, sondern eröffnet auch einen Pfad, auf dem der aufgerufene Code versucht, eine andere Sperre zu nehmen, und dadurch einen Deadlock erzeugt. Bereiten Sie außerhalb der Sperre vor, und tauschen Sie innerhalb der Sperre nur aus — das ist die Grundform.
Innerhalb eines lock können Sie nicht await verwenden (das ist ein Kompilierfehler). Das ist eher ein Schutz als eine bloße Einschränkung: Monitor besitzt Thread-Affinität – der Thread, der die Sperre genommen hat, muss sie auch freigeben –, was mit asynchronem Code, bei dem sich der ausführende Thread vor und nach einem await ändern kann, nicht vereinbar ist. Verwenden Sie für den Ausschluss in asynchronem Code SemaphoreSlim mit einer Anfangsanzahl von 1.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. Mehrere Sperren immer in derselben Reihenfolge nehmen
Wenn es zwei oder mehr Sperren gibt, ist es das klassische Deadlock-Muster, dass sich die Erwerbsreihenfolge von Thread zu Thread vertauscht. Die Abhilfe ist einfach: Machen Sie zur Regel, dass alle Threads die Sperren in derselben Reihenfolge nehmen. Wo Sie die Reihenfolge nicht garantieren können, verwenden Sie die Überladung von Monitor.TryEnter mit Timeout und geben die Sperre wieder frei, um es erneut zu versuchen (oder die Anomalie zu protokollieren), falls Sie sie nicht bekommen – so verwandeln Sie einen endlosen Hang in einen erkennbaren Fehler.4
5.4. Einfache Aktualisierungen mit Interlocked, bei vielen Lesezugriffen ReaderWriterLockSlim
Für die atomare Aktualisierung einer einzelnen Variablen – etwa das Erhöhen/Verringern eines Zählers oder das Austauschen eines Flags – ist die Interlocked-Klasse (Increment / Add / CompareExchange) schneller als lock. Ohne Konkurrenz genügt oft ein einziges CPU-Befehlspräfix.4 Umgekehrt reicht Interlocked genau bis dahin und lässt sich nicht dafür einsetzen, mehrere Variablen gemeinsam konsistent zu halten. Eine selbstgebaute lockfreie Struktur in Kombination mit volatile ist ein Werkzeug für Experten, das ein tiefes Verständnis des Speichermodells erfordert, und sollte in einer Geschäftsanwendung nicht geschrieben werden.
Für gemeinsam genutzte Daten, bei denen „Lesezugriffe häufig, Schreibzugriffe aber selten“ sind, gibt es außerdem die Option ReaderWriterLockSlim, das nur Schreibzugriffe exklusiv macht und Lesezugriffe gleichzeitig zulässt.13
6. Prinzip 4: Zuerst entwerfen, wie es angehalten wird
Die erste Frage, die Sie in einem Multithreading-Design-Review stellen sollten, lautet: „Wie hält das an?“ Code, der loslegt, lässt sich schreiben, aber Code, der sicher anhält, entsteht nur, wenn Sie ihn entwerfen.
6.1. Kooperative Abbruchsteuerung (CancellationToken) ist die einzig richtige Antwort
Das Stoppmodell von .NET ist einheitlich auf kooperative Abbruchsteuerung ausgerichtet. Die Seite, die etwas beenden möchte, erstellt eine CancellationTokenSource und übergibt deren Token an jeden Vorgang. Will sie ihn beenden, ruft sie Cancel() auf. Die verarbeitende Seite beobachtet das Token und räumt an einer selbst gewählten, geeigneten Stelle auf, bevor sie endet — weil es Kooperation statt Zwang ist, kann die verarbeitende Seite enden, während sie einen konsistenten Zustand bewahrt.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // Lehnt einen doppelten Start ab, während noch etwas läuft
throw new InvalidOperationException("Der Worker läuft bereits.");
if (_worker is { IsFaulted: true }) // Nicht auf einem verschluckten vorherigen Fehler neu aufbauen
throw new InvalidOperationException("Der vorherige Worker ist fehlgeschlagen.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // Zuerst in eine lokale Variable fangen, damit es nicht mit einem Neustart nach dem Stoppen konkurriert
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // Durch Polling überwachen
{
ProcessNextItem(ct); // ct an jeden blockierenden Aufruf übergeben, für sofortigen Abbruch
}
}
public async Task StopAsync()
{
var cts = _cts; // In lokale Variablen fixieren, damit auch dann,
var worker = _worker; // wenn die Felder während des Wartens ersetzt werden, nicht das falsche Ziel gestoppt wird
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // Ein am Token registrierter Callback kann eine Ausnahme werfen
catch (Exception ex) { cancelFailure = ex; } // Aufbewahren und erst nach dem Join melden
try
{
try { await worker; } // Unabhängig vom Erfolg von Cancel immer joinen und auch einen Fehler mitten in der Ausführung beobachten
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // Nur den selbst angeforderten Abbruch als "normal" behandeln
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // Keinen der beiden Fehler verlieren
}
}
finally
{
cts.Dispose(); // Die Source nach dem Join freigeben (gibt OS-Ressourcen wie ihr WaitHandle frei).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // Ein bereits freigegebenes Source-Objekt nicht von einem nachfolgenden StopAsync verwenden lassen
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Beachten Sie, dass dieses Start / StopAsync eine minimale Konstruktion ist, die voraussetzt, dass es sequenziell von demselben Thread aus aufgerufen wird (etwa dem UI-Thread). Können mehrere Threads den Lebenszyklus gleichzeitig steuern, serialisieren Sie Start / StopAsync selbst mit etwas wie SemaphoreSlim — es wäre widersinnig, wenn schon die Verwaltungsoperationen des Lebenszyklus selbst konkurrieren, bevor Sie überhaupt den Worker schützen.
Auch dieses kleine Beispiel enthält Kniffe, die sich in der Praxis auszahlen. Zunächst lehnt Start einen doppelten Aufruf ab, während bereits etwas läuft. Würde man _cts und _worker bedingungslos überschreiben, ginge die Referenz auf den vorherigen Worker verloren, und ein „herrenloser Thread“ liefe weiter mit, den man weder stoppen noch joinen kann. Es ist Grundprinzip einer Lebenszyklus-API (Start/Stop), sich selbst auf „immer nur eins gleichzeitig“ zu beschränken. Darüber hinaus drei weitere Punkte. Erstens: Die Stopp-API wartet auf den Abschluss. Cancel() „fordert“ den Abbruch nur an; im Moment der Rückkehr kann der Worker noch mitten in ProcessNextItem stecken. Ein Stop(), das nur anfordert und sofort zurückkehrt, schafft eine neue Race Condition, bei der der Aufrufer mit dem Aufräumen beginnt, während der Worker noch läuft. Zweitens: Halten Sie den Task fest, statt ihn wegzuwerfen. Wird er mit _ = Task.Run(...) verworfen, bemerkt niemand, wenn der Worker durch eine Ausnahme stirbt. Drittens: Fangen Sie das Token in eine lokale Variable, bevor Sie es weitergeben, statt _cts.Token innerhalb des Lambda-Ausdrucks zu referenzieren. Wird es innerhalb der Lambda referenziert, erfolgt die Auswertung erst zur Laufzeit, sodass es bei einem Neustart direkt nach dem Stoppen zu einer Verwechslung kommen kann, bei der der alte Worker das neue Token erfasst. Reichen Sie dasselbe Token zusätzlich als zweites Argument an Task.Run weiter, wird der Task als „Cancelled“ statt als „Faulted“ eingestuft, wenn die verarbeitende Seite über ThrowIfCancellationRequested oder die OperationCanceledException einer abbruchfähigen API endet (endet sie wie in diesem Beispiel ganz gewöhnlich über die Schleifenbedingung, gilt das als normaler Abschluss). Ein weiterer Punkt: Der catch-Block in StopAsync fängt mit einem when-Filter nur den Abbruch ab, der vom eigenen Token stammt. Würde OperationCanceledException bedingungslos verschluckt, sähe selbst ein echter Fehler, den ein anderes, internes Token der Verarbeitung wirft (etwa ein Timeout pro Element), so aus, als sei „gestoppt worden, also alles in Ordnung“. Beachten Sie, dass diese Identifizierung anhand der Token-Übereinstimmung zusammenbricht, wenn WorkLoop intern ein verknüpftes Token verwendet (die Verknüpfungskomposition aus 6.1), weil dann eine Ausnahme mit dem verknüpften Token ankommt. Wählen Sie in diesem Aufbau ausdrücklich als Entwurfsentscheidung entweder, am Ausgang von WorkLoop ct.ThrowIfCancellationRequested() aufzurufen, um es vor dem Verlassen auf das äußere Token zu „übersetzen“, oder den Filter auf when (cts.IsCancellationRequested) zu lockern und „Abbruch während eines angeforderten Stopps gilt als normal“ zu akzeptieren.
flowchart TB
OWNER["Die stoppende Seite"] -->|"Ruft einmal Cancel() auf"| CTS["CancellationTokenSource"]
CTS -->|"Übergibt das Token"| W1["Worker-Vorgang 1"]
CTS -->|"Übergibt das Token"| W2["Worker-Vorgang 2"]
CTS -->|"Übergibt das Token"| W3["Abbruchfähige API<br/>einer Bibliothek"]
W1 -->|"Prüft IsCancellationRequested,<br/>räumt auf und endet selbst"| E1["Endet normal"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= gilt als abgeschlossener Abbruch"]
W3 -->|"Bricht auch im Wartezustand sofort ab"| E3["Abbruch abgeschlossen"]
Abbildung 6: Die Struktur der kooperativen Abbruchsteuerung. Die stoppende Seite ruft nur Cancel() auf; jeder Vorgang entscheidet selbst, “wann und wie” er endet. Deshalb kann er anhalten, während er einen konsistenten Zustand bewahrt
Auch auf Seiten der Bibliotheken gibt es eine feste Konvention. Ein abbrechbarer Vorgang sollte eine öffentliche Methode bereitstellen, die ein CancellationToken entgegennimmt, und in einer Berechnungsschleife regelmäßig entweder IsCancellationRequested prüfen oder ThrowIfCancellationRequested() aufrufen. Letzteres löst OperationCanceledException aus, was Task als „Abbruch abgeschlossen“ statt als „Fehler“ behandelt. Wollen Sie sowohl auf ein von außen übergebenes Token als auch auf interne Belange (etwa ein Timeout) reagieren, komponieren Sie beide mit einem verknüpften Token.7
6.2. Thread.Abort als nicht existent betrachten
Thread.Abort – „einen nicht gehorchenden Thread von außen töten“ – löst unter .NET Core / .NET 5 und später nur noch PlatformNotSupportedException aus und ist damit nicht mehr verwendbar. Der Grund ist, dass das Einwerfen einer Ausnahme in einen Thread, ohne zu wissen, wo er sich gerade befindet, die Ressourcenfreigabe unterbrechen und den Zustand beschädigen kann. Müssen Sie Drittanbietercode, der auf kooperative Abbruchsteuerung nicht reagiert (oder sich nicht entsprechend umschreiben lässt), zwangsweise beenden, lautet die offizielle Empfehlung, ihn in einem separaten Prozess auszuführen und mit Process.Kill zu stoppen.8
6.3. Beim Warten ein Wait-Handle statt Polling verwenden
Code, der „in einer Sleep(100)-Schleife wartet, bis ein Flag gesetzt wird“, verschwendet sowohl CPU-Zeit als auch Reaktionsfähigkeit. Für Signale zwischen Threads gibt es Synchronisationsprimitive wie ManualResetEventSlim und SemaphoreSlim, die einen Thread korrekt schlafen legen, bis er signalisiert wird.13 Wann Sie unter Windows Timer-Präzision und wann Event-Waits einsetzen sollten, wird ausführlich in „Warum Sie unter Windows Event-Waits gegenüber Sleep(1) bevorzugen sollten“ behandelt.
7. Der Sonderfall UI-Thread — das Gesetz von Windows-Desktopanwendungen
Windows-Desktopanwendungen unterliegen zusätzlich zu den allgemeinen Prinzipien noch einer weiteren strengen Einschränkung: dem Gesetz, dass die UI nur von dem Thread berührt werden darf, der sie erstellt hat (dem UI-Thread).
WinForms-Steuerelemente sind nicht threadsicher; werden sie von mehreren Threads aus bedient, geraten sie in einen inkonsistenten Zustand und verursachen Konflikte, Deadlocks und Einfrieren. Windows verlangt von einer Anwendung, einen dedizierten Thread für den Empfang von Systemnachrichten bereitzuhalten, und Erstellung wie Bedienung der UI müssen auf diesem Thread gebündelt sein.9 WPF folgt genau derselben Struktur: Nur der UI-Thread darf UI-Elemente ändern.10
Wenn Sie die UI von einem anderen Thread aus aktualisieren möchten, berühren Sie sie nicht direkt, sondern wandeln das Vorhaben in „einen Auftrag an den UI-Thread“ um.
flowchart LR
OS["Windows<br/>Maus, Tastatur, Neuzeichnen"] --> Q["Nachrichtenwarteschlange<br/>des UI-Threads"]
BG["Hintergrund-Thread<br/>(aufwendige Verarbeitung, Kommunikation)"] -->|"Beauftragt über Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["UI-Thread<br/>der einzige Thread, der Steuerelemente berühren darf"]
BG -.->|"Steuerelement direkt berühren"| NG["Verboten<br/>Ursache für Konflikte, Deadlocks, Einfrieren"]
Abbildung 7: UI-Aktualisierungen werden in einen “Auftrag” umgewandelt. Die Aufgabe des Hintergrund-Threads endet damit, dass seine Arbeit in die Nachrichtenwarteschlange eingereiht wird - das Steuerelement berührt stets der UI-Thread selbst
| Framework | Mittel zur Beauftragung |
|---|---|
| WinForms | Control.Invoke (synchron) / Control.BeginInvoke (asynchron) / ab .NET 9 Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (synchron) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asynchron)10 |
Bei den synchronen Formen (Control.Invoke / Dispatcher.Invoke) ist Vorsicht geboten. Wartet der UI-Thread synchron auf den Abschluss dieses Workers, während der Worker Invoke aufruft, entsteht ein Deadlock, bei dem beide aufeinander warten (genau die zirkuläre Wartesituation aus Kapitel 2). Machen Sie die asynchronen Formen (BeginInvoke / InvokeAsync) zur Standardwahl für Benachrichtigungen und Fortschrittsmeldungen aus dem Hintergrund, und beschränken Sie die synchronen Formen auf Situationen, in denen Sie mit Sicherheit sagen können, dass der UI-Thread nicht auf sich selbst wartet.
In der Praxis gibt es eine noch bessere Antwort. Schreiben Sie einen auf dem UI-Thread gestarteten Vorgang mit async/await, fängt await den SynchronizationContext des UI-Threads ein und setzt die Fortsetzung automatisch auf dem UI-Thread fort, wodurch die Fälle, in denen Sie Invoke von Hand schreiben müssen, erheblich zurückgehen. Das ist allerdings keine bedingungslose Eigenschaft: Code, der über einen Hintergrund-Callback eingetreten ist, und die Fortsetzung nach einem ConfigureAwait(false) kehren nicht zum UI-Thread zurück, sodass Sie auf diesem Pfad weiterhin explizit dispatchen müssen, wenn Sie die UI berühren. Die Form „aufwendige Arbeit geht an Task.Run oder asynchrones I/O, die Anzeige des Ergebnisses erfolgt in der Fortsetzung nach await“ zu vereinheitlichen, ist die Grundform moderner Windows-Anwendungen. Die Beziehung zwischen UI-Thread und async/await ist in „async und der UI-Thread in WPF/WinForms auf einen Blick“ in einer einzigen Grafik zusammengefasst.
Ist außerdem COM im Spiel – Office-Integration, Legacy-Komponenten und Ähnliches –, kommt mit dem eigenen Threading-Modell von COM (STA/MTA) noch eine weitere Ebene hinzu. Vorfälle wie „das COM-Objekt wurde auf dem UI-Thread erstellt, aber von einem anderen Thread aus aufgerufen, und alles fror ein“ gehören zu dieser Ebene und werden in „Grundlagen von COM STA/MTA“ erläutert.
8. Beim Schreiben in nativem Code (C++/C)
Die bisherigen Prinzipien — Threads nicht direkt erstellen, gemeinsam genutzten veränderlichen Zustand reduzieren, Sperrdisziplin, Entwurf des Anhaltens — gelten unverändert auch für nativen Code. Was sich ändert, ist das Werkzeugrepertoire. In C++ sind die Entsprechungen RAII zusammen mit std::jthread / std::mutex / std::atomic, in C die Win32-API mit _beginthreadex, SRW-Sperren, Bedingungsvariablen und dem Stop-Event-Muster. Beides wird, einschließlich sprachspezifischer Fallstricke (der Destruktor von std::thread, die Gefahren von TerminateThread, DllMain und der Loader-Lock und mehr), jeweils in der „C++-Edition“ und der „C-Edition“ dieser Serie behandelt.
9. Verifikation und Debugging — Vorbereitung unter der Annahme, dass es sich nicht reproduzieren lässt
Sie können nicht erwarten, dass Multithreading-Bugs durch Tests gefunden werden. Ein gewöhnlicher Unit-Test zählt einen Lauf, bei dem „zufällig kein Konflikt aufgetreten ist“, als Erfolg. Denken Sie Ihre Vorbereitung in drei Schichten.
Die erste Verteidigungslinie sind die bisherigen Entwurfsprinzipien selbst. Zwischen einer Anwendung mit fünf Elementen gemeinsam genutzten veränderlichen Zustands und einer mit fünfzig unterscheidet sich die Anzahl der verdächtigen Stellen um den Faktor zehn. Prüfen Sie im Review tabellarisch: „Welche veränderlichen Daten werden geteilt?“, „Welche Sperre schützt jeweils welche?“, „Ist die Erwerbsreihenfolge der Sperren eindeutig?“ und „Wo liegen die Stopp-Pfade?“ Ein Entwurf, für den sich diese Tabelle nicht aufstellen lässt, ist noch nicht fertig, auch wenn er läuft.
Zweitens machen Sie Anomalien beobachtbar, statt sie zu verbergen. Erkennen Sie Anomalien beim Warten auf Sperren mit dem Timeout von Monitor.TryEnter und protokollieren Sie sie,4 verschlucken Sie unbeobachtete Ausnahmen aus an den Thread-Pool übergebener Arbeit nicht, sondern zeichnen Sie sie auf, und sorgen Sie dafür, dass Sie bei einem Hang einen vollständigen Dump erfassen können, um die Stacks aller Threads zu prüfen — der Kampf gegen einen Bug, der „nur gelegentlich auftritt“, entscheidet sich daran, wie viele Informationen Sie aus dem einen Auftreten gewinnen können. Die Einrichtung von Dumps und Logging wird in „Entwurf für Windows-Anwendungen, um bei einem Absturz Logs und Dumps zu hinterlassen“ behandelt.
Drittens rütteln Sie unter Last daran. Stresstests, die es auf der Entwicklungsmaschine wahrscheinlicher machen, ein ungünstiges Interleaving zu treffen — etwa mit mehr Parallelität als Kernen über längere Zeit laufen lassen, die Verarbeitungsreihenfolge randomisieren, künstlich Verzögerungen einfügen —, sind ein realistisches Mittel, um Konflikte vor der Auslieferung aufzuspüren. Ein Bug, der unter dem Debugger verschwindet, reproduziert sich oft in einem Release-Build unter hoher Last.
10. Zusammenfassung — Checkliste, bevor Sie weitere Threads hinzufügen
Letztlich ist die Best Practice für Multithreading-Programmierung nicht „die Fähigkeit, Synchronisierung korrekt zu schreiben“, sondern „ein Entwurf, der es erlaubt, ganz auf das Schreiben von Synchronisierung zu verzichten“. Können Sie vor dem Start die folgenden acht Fragen beantworten, lassen sich größere Zwischenfälle fast vollständig vermeiden.
- Ist dieser Vorgang CPU-gebunden oder I/O-gebunden (im letzteren Fall lautet die Antwort async/await, nicht ein Thread)?
- Sind Sie im Begriff,
new Threadzu schreiben (lässt sich das mit Task,Paralleloder dem Thread-Pool ausdrücken)? - Welche veränderlichen Daten werden zwischen Threads geteilt — können Sie sie auflisten?
- Lässt sich diese gemeinsame Nutzung durch „Aufteilen“, „Unveränderlichmachen“ oder „Weitergabe über eine Warteschlange“ beseitigen?
- Ist für jede verbleibende gemeinsam genutzte Datenmenge genau eine zugehörige Sperre festgelegt?
- Ist die Erwerbsreihenfolge der Sperren für alle Threads eindeutig, und vermeiden Sie externe Aufrufe während eine Sperre gehalten wird?
- Wird
CancellationTokenan jeden lang laufenden Vorgang übergeben, und können Sie den Stopp-Pfad erklären? - Ist der Code, der die UI berührt, auf dem UI-Thread gebündelt?
Multithreading-Bugs zeigen sich nicht an dem Tag, an dem Sie den Code schreiben — sie zeigen ihre Zähne beim Kunden, lange nachdem Sie sie vergessen haben. Anders gesagt: Arbeiten Sie diese Checkliste bereits in der Entwurfsphase durch, können Sie die teuerste Art von Störung — „stürzt gelegentlich ab“, „friert einmal im Monat ein“ — ausmerzen, bevor Sie überhaupt eine Zeile Code schreiben.
Verwandte Artikel
- Praktische Best Practices für Multithreading: C++-Edition
- Praktische Best Practices für Multithreading: C-Edition
- Praktische Best Practices für Multithreading: Java-Edition
- C# async/await-Praxis-Entscheidungstabelle – Task.Run und ConfigureAwait
- async und der UI-Thread in WPF/WinForms auf einen Blick
- Die Tiefen von Windows-I/O (Teil 3) — E/A-Abschlussports (IOCP) und der .NET-Thread-Pool
- Grundlagen von COM STA/MTA – Threading-Modelle und wie man Hänger vermeidet
- Warum Sie unter Windows Event-Waits gegenüber Sleep(1) bevorzugen sollten
- Fallstricke von Shared Memory und praktische Best Practices
Verwandte Beratungsleistungen
Die KomuraSoft LLC übernimmt Design-Reviews von Geschäftsanwendungen einschließlich der Einführung von Multithreading, die Ursachenuntersuchung schwer reproduzierbarer Fehler wie „stürzt/friert gelegentlich ein“ (Dump-Analyse und Lokalisierung von Konflikten) sowie technische Beratung zur Parallelisierung und Asynchronisierung bestehender Anwendungen. Wir sind gerne schon in einer Phase wie „bitte prüfen, ob bei diesem Entwurf Konflikte auftreten können“ dabei.
- Technische Beratung & Design-Review
- Fehleruntersuchung & Ursachenanalyse
- Windows-Anwendungsentwicklung
- Kontakt
Referenzlinks
-
Microsoft Learn, Task Parallel Library (TPL). Dazu, dass die TPL seit .NET Framework 4 das empfohlene Mittel für Multithreading- und Parallelcode ist; dazu, dass sie den Parallelitätsgrad dynamisch an die verfügbaren Prozessoren anpasst; dazu, dass sie das Aufteilen der Arbeit, die Einplanung auf dem Thread-Pool, die Behandlung von Abbrüchen und die Zustandsverwaltung übernimmt; dazu, dass eine Schleife mit geringer Arbeit pro Durchlauf durch den Overhead der Parallelisierung langsamer werden kann; sowie dazu, dass auch bei Verwendung der TPL ein grundlegendes Verständnis von Sperren, Deadlocks und Race Conditions empfohlen wird. ↩ ↩2
-
Microsoft Learn, The managed thread pool. Dazu, dass die ThreadPool-Klasse einen vom System verwalteten Pool von Worker-Threads bereitstellt, sodass sich Entwickler auf die Aufgaben der Anwendung statt auf die Thread-Verwaltung konzentrieren können; sowie dazu, dass .NET den Thread-Pool umfassend nutzt — für TPL-Operationen, den Abschluss asynchroner I/O-Vorgänge, Timer-Rückrufe, registrierte Waits, Socket-Verbindungen und mehr. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Dazu, dass eine parallele Schleife langsamer als eine sequenzielle sein kann und stets gemessen werden sollte; dazu, dass Schreibzugriffe auf gemeinsamen Speicher innerhalb einer parallelen Schleife vermieden werden sollten, wobei die Überladung mit Thread-lokalem Zustand empfohlen wird; sowie dazu, dass es keine Garantie gibt, dass jede Iteration von For/ForEach parallel ausgeführt wird, sodass Code, der zwischen Iterationen wartet, in einen Deadlock geraten kann. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Zur Definition von Race Condition (Beispiel: Das Inkrementieren eines Zählers zerfällt in Lesen, Addieren und Zurückschreiben, wobei ein Ergebnis überschrieben und verloren geht) und Deadlock; dazu, dass statt Thread.Abort kooperative Abbruchsteuerung verwendet werden sollte; dazu, dass ein Typ oder this nicht als Sperrobjekt verwendet werden darf und ab .NET 9 / C# 13 eine dedizierte System.Threading.Lock-Instanz zu verwenden ist; dazu, dass die C#-lock-Anweisung Monitor.Exit in einem finally-Block garantiert; zur Erkennung von Deadlocks über den Timeout von Monitor.TryEnter; dazu, dass die Interlocked-Klasse für einfache Zustandsänderungen schneller ist; sowie zur Entwurfsrichtlinie, dass statische Daten standardmäßig threadsicher und Instanzdaten standardmäßig nicht threadsicher sein sollten. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, System.Threading.Channels library. Dazu, dass ein Channel eine FIFO-Struktur für das Producer/Consumer-Modell ist, die die Synchronisierung intern verwaltet; dazu, dass sich mit CreateBounded ein Channel mit Kapazitätsgrenze erstellen lässt; dazu, dass das Standardverhalten beim Erreichen der Grenze das Warten (Wait) der schreibenden Seite ist, wobei sich auch andere FullMode-Werte wie DropOldest wählen lassen; sowie dazu, dass Backpressure entsteht, wenn Schreiben schneller ist als Lesen. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Dazu, dass die Collections unter System.Collections.Concurrent Threadsicherheit durch feingranulare Sperren oder lockfreie Mechanismen erreichen; sowie dazu, dass ConcurrentQueue und ConcurrentStack ohne Sperren, mit Interlocked-Operationen implementiert sind und dadurch häufigem Hinzufügen und Entfernen aus mehreren Threads standhalten. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Zum Ablauf des kooperativen Abbruchmodells mit CancellationTokenSource und CancellationToken; dazu, dass Abbruch Kooperation statt Zwang ist und die Art des Stoppens der Listener entscheidet; zu den drei Überwachungsmitteln Polling, Callback-Registrierung und Wait-Handle; dazu, dass Task das über ThrowIfCancellationRequested ausgelöste OperationCanceledException als abgeschlossenen Abbruch behandelt; zur Komposition mehrerer Token über ein verknüpftes Token; sowie dazu, dass eine Bibliothek öffentliche Methoden bereitstellen sollte, die ein CancellationToken entgegennehmen. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Dazu, dass für das Stoppen eines Threads CancellationToken verwendet werden sollte; dazu, dass Thread.Abort unter .NET Core und .NET 5 und später PlatformNotSupportedException auslöst und ab .NET 5 zusätzlich eine Compile-Zeit-Warnung zur Veraltung (SYSLIB0006) erzeugt; sowie dazu, dass Drittanbietercode, der auf kooperative Abbruchsteuerung nicht reagiert, zwangsweise beendet werden sollte, indem man ihn in einem separaten Prozess ausführt und Process.Kill verwendet. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. Dazu, dass der Zugriff auf WinForms-Steuerelemente nicht threadsicher ist und Operationen von mehreren Threads zu inkonsistentem Zustand, Konflikten, Deadlocks und Einfrieren führen; dazu, dass alle Steuerelemente auf demselben Thread erstellt und darauf zugegriffen werden müssen, wobei Windows einen dedizierten UI-Thread für die Zustellung von Systemnachrichten verlangt; sowie dazu, dass von einem anderen Thread aus sicher über Control.Invoke, ab .NET 9 über Control.InvokeAsync oder über BackgroundWorker aufgerufen werden sollte. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Dazu, dass in WPF nur ein einziger Thread UI-Elemente ändern darf und ein Hintergrund-Thread Arbeitselemente beim Dispatcher des UI-Threads registrieren muss, um sie in Auftrag zu geben; dazu, dass Dispatcher.Invoke synchron ist, während InvokeAsync und BeginInvoke asynchron sind; sowie dazu, dass der Dispatcher Arbeit über eine priorisierte Warteschlange verarbeitet. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Dazu, dass
Parallel.For/Parallel.ForEachDatenparallelität mit nahezu demselben Schreibgefühl wie eine for-Schleife bietet; dazu, dass weder das Erstellen von Threads noch das Einreihen von Arbeitselementen nötig ist und bei einer einfachen Schleife auch keine Sperren erforderlich sind; sowie dazu, dass die TPL die Datenquelle aufteilt, auf mehreren Threads verarbeitet und bei ungleicher Last neu verteilt. ↩ -
Microsoft Learn,
BlockingCollection<T>Class. Dazu, dass BlockingCollection eine Producer/Consumer-Implementierung mit Blockierung und Kapazitätsgrenze ist; dazu, dass die Kapazitätsgrenze verhindert, dass die produzierende Seite der konsumierenden zu weit vorauseilt; sowie dazu, dass sie nicht für asynchronen Zugriff konzipiert ist und für einen asynchronen Producer/Consumer die Verwendung vonChannel<T>erwogen werden sollte. ↩ ↩2 -
Microsoft Learn, Overview of synchronization primitives. Dazu, dass Monitor wechselseitigen Ausschluss über ein Sperrobjekt bereitstellt und Thread-Affinität besitzt; dazu, dass in C# statt Monitor direkt die lock-Anweisung verwendet werden sollte; dazu, dass ReaderWriterLockSlim Schreibzugriffe exklusiv macht und gleichzeitige Lesezugriffe erlaubt; sowie dazu, dass SemaphoreSlim ein leichtgewichtiger Semaphor nur innerhalb eines Prozesses ist, während Semaphore benannt ist und sich für die Synchronisierung zwischen Prozessen eignet. ↩ ↩2 ↩3
-
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Dazu, dass die C#-lock-Anweisung und der Lock-Typ Thread-Affinität besitzen und deshalb nicht über einen await hinweg verwendet werden können (weil sich der die Fortsetzung ausführende Thread vor und nach dem await ändern kann); dazu, dass für wechselseitigen Ausschluss in asynchronem Code eine SemaphoreSlim mit der Anzahl 1 über WaitAsync und ein Release in finally verwendet werden sollte; sowie dazu, dass ein bounded Channel für Drosselungszwecke eine Alternative darstellt. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Praktische Multithreading-Best-Practices: C-Edition — Sicher schreiben nach Win32-API-Konvention
Bei Multithreading in C mit Win32 sind Thread-Erstellung über _beginthreadex, SRW-Locks und Bedingungsvariablen, Interlocked-Funktionen s...
Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
In C++ ist Multithreading eine Welt, in der eine Datenrace zu undefiniertem Verhalten wird. Dieser Artikel behandelt die Falle des std::t...
Praktische Best Practices für Multithreading: Java-Edition — Konventionen für das Zeitalter der virtuellen Threads
In Java besteht die bewährte Praxis für Multithreading darin, niemals Threads direkt zu erstellen, sondern auf ExecutorService und virtue...
WMI/CIM aus C# und PowerShell verwenden — Praxisleitfaden für Hardwareinformationen, Prozessüberwachung und Remoteabfragen
WMI/CIM ist die Standardlösung, um die Seriennummer eines PCs auszulesen, freien Festplattenspeicher zu überwachen und den Start von Proz...
Volumeschattenkopie (VSS): Funktionsweise und Praxis ── Warum sich Dateien in Benutzung sichern lassen
Dateien in Benutzung lassen sich wegen Freigabekonflikten eigentlich nicht kopieren – wie schafft es Backup-Software trotzdem? Dieser Art...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
Technische Beratung und Design-Review
Klärung von Änderungsstrategie, Entwurf und Umgang mit bestehenden Assets.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Warum sollte man lock(this) oder lock(typeof(MyClass)) vermeiden?
- Weil das gesperrte Objekt auch außerhalb des eigenen Codes sichtbar ist. this ist die eigene Instanz selbst, sodass jeder externe Code, der diese Instanz referenzieren kann, dasselbe Objekt sperren kann – das führt zu unbeabsichtigten Konflikten oder Deadlocks. typeof(MyClass) ist noch gefährlicher, weil es pro Anwendungsdomäne nur ein einziges Type-Objekt gibt, sodass die Sperre mit völlig unabhängigem Code geteilt wird. Verwenden Sie als Sperrobjekt eine dedizierte, nicht nach außen sichtbare Instanz. Ab .NET 9 / C# 13 wird empfohlen, dafür eine Instanz des eigens dafür vorgesehenen Typs System.Threading.Lock zu verwenden.
- Wie viele Threads darf ich erstellen? Was ist die optimale Thread-Anzahl?
- Die zeitgemäße Antwort lautet: „Legen Sie die Thread-Anzahl nicht selbst fest.“ Verwenden Sie Task und die Parallel-Klasse, dann passt der Thread-Pool den Parallelitätsgrad automatisch an die Anzahl der CPU-Kerne und die aktuelle Auslastung an. Ein Design, das wiederholt new Thread von Hand aufruft, neigt dazu, auf Kundenrechnern mit abweichender Kernzahl über- oder unterzudimensionieren. Als Richtwert sollten Sie nicht auf eine Zahl, sondern auf die Art der Arbeit achten: Berechnungen, die die CPU voll auslasten, werden durch Parallelisierung über die Kernzahl hinaus nicht schneller, und bei Vorgängen, die überwiegend auf I/O warten, ist von vornherein nicht mehr Threads die richtige Lösung, sondern asynchrones I/O mit async/await.
- Macht das Hinzufügen von volatile etwas threadsicher?
- Nein. Was volatile garantiert, ist eine Ordnung (Acquire/Release-Semantik) – dass der Zugriff auf dieses Feld nicht mit umgebenden Speicheroperationen umsortiert wird –, nicht aber die Atomarität einer zusammengesetzten Operation wie „lesen, berechnen, zurückschreiben“. Selbst wenn beispielsweise mehrere Threads ++ auf einem volatile int-Zähler ausführen, gehen Inkremente verloren. Verwenden Sie für das Erhöhen/Verringern eines Zählers oder ein Compare-and-Swap die Interlocked-Klasse, und verwenden Sie lock, wenn mehrere Variablen gemeinsam geschützt werden müssen. volatile kommt praktisch nur für den einfachen Fall infrage, in dem – wie bei einem Stopp-Flag – ein Thread schreibt und die anderen nur lesen, und selbst dieses Flag wird heute standardmäßig als CancellationToken ausgedrückt.
- Wie erkenne ich, ob ein selten auftretender Fehler auf Multithreading zurückzuführen ist?
- Es gibt drei Anzeichen, die Sie misstrauisch machen sollten: „dieselbe Operation reproduziert sich mal, mal nicht“, „das Problem verschwindet, sobald ein Debugger angehängt oder zusätzliches Logging eingefügt wird“ und „es tritt nur bei hoher Last oder direkt nach dem Start auf“. Ein zeitabhängiger Fehler ist dadurch gekennzeichnet, dass sich das Ergebnis bei jedem Lauf ändert – das ist genau die Definition eines Race Condition. Zur Eingrenzung listen Sie zunächst alle gemeinsam genutzten veränderlichen Daten auf und tragen für jede in einer Tabelle ein, welche Sperre sie schützt. Schon ein einziger ungeschützter Zugriff macht die betreffende Stelle verdächtig. Bei einem Hang erfassen Sie die Stacks aller Threads und prüfen, ob sie sich gegenseitig im Warten auf Sperren im Kreis blockieren. Pausieren Sie dazu im Visual-Studio-Debugger und betrachten Sie die „Parallelen Stacks“, oder erfassen Sie in der Produktionsumgebung einen Dump zur Analyse.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.