In the previous lessons Volatile.Read and Volatile.Write appeared in a few places, with the promise of an explanation. Here it is. The
memory model defines in which order one thread can see the writes of another. The surprising answer: not
necessarily in the order in which they appear in the code. This lesson shows that in a small experiment, explains the few rules you need to
remember, and derives from them why a popular pattern, double-checked locking, is so often built wrong.
Why things get reordered
A processor does not like waiting for memory. A write first lands in a buffer of the core and only a little later in shared memory; in the meantime the core carries on computing and already reads the next thing. Compilers and the JIT do something similar: they may combine accesses, move them, or keep a value in a register instead of reading it again. For a single thread this is invisible, because all reorderings are built so that the thread itself notices no difference. A second thread can notice, though.
A classic experiment shows it: two threads each write one field and then read the other.
Thread A: X = 1; a = Y;
Thread B: Y = 1; b = X;
After every order you can picture with a finger on the code, at least one of the two reads a 1. That both read 0 is possible only if a read becomes visible before the write of the same thread. A program that tries it a million times:
// reordering.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("en-US");
const int Rounds = 1_000_000;
foreach (string kind in new[] { "plain", "Volatile", "MemoryBarrier" })
{
Console.WriteLine($"{kind,-14} both read 0: {Run(kind, Rounds),7:N0} of {Rounds:N0} rounds");
}
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;
}
On a test machine with an Intel processor (x64); your numbers are different:
plain both read 0: 179,790 of 1,000,000 rounds
Volatile both read 0: 186,369 of 1,000,000 rounds
MemoryBarrier both read 0: 0 of 1,000,000 rounds
In almost every fifth round both threads read the old 0, on an x64 processor, which is considered especially strict. The
write was still in the core's buffer when the read already came from memory. And, surprising to many: Volatile changes
nothing about that. Only a full memory barrier (Interlocked.MemoryBarrier) brings the number down to zero.