Fast jeder Fehler in nebenläufigem Code hat dieselbe Ursache: gemeinsamer veränderlicher Zustand. Zwei Threads lesen und schreiben dieselbe Variable, dasselbe Feld, dieselbe Sammlung, und ihre Schritte verschränken sich so, dass das Ergebnis nicht stimmt. Diese Lektion zeigt, wie das aussieht, wie Sie es mit einem Test sichtbar machen und welche vier Werkzeuge .NET dagegen hat: lock, SemaphoreSlim, Interlocked und Daten, die sich gar nicht ändern.

Warum count++ nicht zählt

count++ sieht aus wie ein Schritt, sind aber drei: lesen, eins addieren, zurückschreiben. Lesen zwei Threads denselben Wert, schreiben beide denselben Wert plus eins zurück, und ein Zählschritt ist verloren (lost update). Acht Aufgaben zählen je eine Million:

C#
// zaehler.cs: eight tasks count to a million each, without and with synchronization
#:property PublishAot=false
using System.Diagnostics;
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
const int Tasks = 8;
const int PerTask = 1_000_000;

int plain = 0;
Measure("count++ ohne alles", () => plain++, () => plain);

int interlocked = 0;
Measure("Interlocked.Increment", () => Interlocked.Increment(ref interlocked), () => interlocked);

int locked = 0;
var gate = new Lock();
Measure("lock (Lock)", () =>
{
    lock (gate)
    {
        locked++;
    }
}, () => locked);

void Measure(string name, Action increment, Func<int> result)
{
    var watch = Stopwatch.StartNew();
    Task.WaitAll(Enumerable.Range(0, Tasks).Select(_ => Task.Run(() =>
    {
        for (int i = 0; i < PerTask; i++)
        {
            increment();
        }
    })));
    Console.WriteLine($"{name,-22} {result(),10:N0} von {Tasks * PerTask:N0}, {watch.Elapsed.TotalMilliseconds,5:N0} ms");
}

Auf einem Testrechner mit 32 logischen Kernen, zwei Läufe; Ihre Zahlen sind andere:

count++ ohne alles      1.605.433 von 8.000.000,    76 ms
Interlocked.Increment   8.000.000 von 8.000.000,   204 ms
lock (Lock)             8.000.000 von 8.000.000,   520 ms
count++ ohne alles      1.660.527 von 8.000.000,    80 ms
Interlocked.Increment   8.000.000 von 8.000.000,   204 ms
lock (Lock)             8.000.000 von 8.000.000,   535 ms

Ohne Synchronisation gehen vier von fünf Zählschritten verloren, und jeder Lauf verliert andere. Das ist das Tückische an diesen Fehlern: Sie sind nicht falsch, sie sind manchmal falsch, mal viel, mal wenig, mal gar nicht. Ein Test mit einem einzigen Aufruf findet sie nie.