In den vorigen Lektionen stand an einigen Stellen Volatile.Read und Volatile.Write, mit dem Versprechen einer Erklärung. Hier ist sie. Das Speichermodell legt fest, in welcher Reihenfolge ein Thread die Schreibzugriffe eines anderen sehen kann. Die überraschende Antwort: nicht unbedingt in der Reihenfolge, in der sie im Code stehen. Diese Lektion zeigt das an einem kleinen Versuch, erklärt die wenigen Regeln, die Sie sich merken müssen, und leitet daraus ab, warum ein beliebtes Muster, das doppelt geprüfte Sperren, so oft falsch gebaut wird.

Warum umgeordnet wird

Ein Prozessor wartet nicht gern auf den Speicher. Ein Schreibzugriff landet zuerst in einem Puffer des Kerns und erst etwas später im gemeinsamen Speicher; in der Zwischenzeit rechnet der Kern weiter und liest schon das Nächste. Compiler und JIT tun Ähnliches: Sie dürfen Zugriffe zusammenfassen, verschieben oder einen Wert in einem Register behalten, statt ihn erneut zu lesen. Für einen einzelnen Thread ist das unsichtbar, denn alle Umordnungen sind so gebaut, dass er selbst keinen Unterschied merkt. Ein zweiter Thread kann ihn aber merken.

Ein klassischer Versuch zeigt es: Zwei Threads schreiben je ein Feld und lesen danach das andere.

Thread A:  X = 1;  a = Y;
Thread B:  Y = 1;  b = X;

Nach jeder Reihenfolge, die man sich mit dem Finger auf dem Code vorstellen kann, liest mindestens einer der beiden eine 1. Dass beide 0 lesen, ist nur möglich, wenn ein Lesen vor dem Schreiben desselben Threads sichtbar wird. Ein Programm, das es eine Million Mal versucht:

C#
// umordnen.cs: two threads each write one field and read the other; can both read the old value 0?
#:property PublishAot=false
using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
const int Rounds = 1_000_000;
foreach (string kind in new[] { "einfach", "Volatile", "MemoryBarrier" })
{
    Console.WriteLine($"{kind,-14} beide lasen 0: {Run(kind, Rounds),7:N0} von {Rounds:N0} Runden");
}

static int Run(string kind, int rounds)
{
    var shared = new Shared();
    int round = 0;
    int finished = 0;
    int bothZero = 0;

    var a = new Thread(() => Side(shared, kind, rounds, ref round, ref finished, first: true));
    var b = new Thread(() => Side(shared, kind, rounds, ref round, ref finished, first: false));
    a.Start();
    b.Start();
    for (int r = 1; r <= rounds; r++)
    {
        shared.X = 0;
        shared.Y = 0;
        Volatile.Write(ref round, r);                     // start round r
        while (Volatile.Read(ref finished) < 2 * r)
        {
            Thread.SpinWait(1);
        }

        if (shared.ReadByA == 0 && shared.ReadByB == 0)
        {
            bothZero++;
        }
    }

    a.Join();
    b.Join();
    return bothZero;
}

static void Side(Shared s, string kind, int rounds, ref int round, ref int finished, bool first)
{
    for (int r = 1; r <= rounds; r++)
    {
        while (Volatile.Read(ref round) < r)
        {
            Thread.SpinWait(1);
        }

        if (first)
        {
            Write(ref s.X, kind);
            s.ReadByA = Read(ref s.Y, kind);
        }
        else
        {
            Write(ref s.Y, kind);
            s.ReadByB = Read(ref s.X, kind);
        }

        Interlocked.Increment(ref finished);
    }
}

static void Write(ref int field, string kind)
{
    switch (kind)
    {
        case "Volatile":
            Volatile.Write(ref field, 1);
            break;
        case "MemoryBarrier":
            field = 1;
            Interlocked.MemoryBarrier(); // full fence: the write is visible before any later read
            break;
        default:
            field = 1;
            break;
    }
}

static int Read(ref int field, string kind) => kind == "Volatile" ? Volatile.Read(ref field) : field;

sealed class Shared
{
    public int X;
    public int Y;
    public int ReadByA;
    public int ReadByB;
}

Auf einem Testrechner mit einem Prozessor von Intel (x64); Ihre Zahlen sind andere:

einfach        beide lasen 0: 179.790 von 1.000.000 Runden
Volatile       beide lasen 0: 186.369 von 1.000.000 Runden
MemoryBarrier  beide lasen 0:       0 von 1.000.000 Runden

In fast jeder fünften Runde haben beide Threads die alte 0 gelesen, auf einem x64-Prozessor, der als besonders streng gilt. Der Schreibzugriff stand noch im Puffer des Kerns, als das Lesen schon aus dem Speicher kam. Und, für viele überraschend: Volatile ändert daran nichts. Erst eine volle Speicherbarriere (Interlocked.MemoryBarrier) bringt die Zahl auf null.