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:
// 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.