In a previous episode I discussed whether it is a good idea to remove a lock which protects an integer field. I said that without a lock, the read of a field can be moved arbitrarily far backwards in time on a thread, and that this can cause unexpected behaviour in a program. It is perhaps not clear why this can be a problem; it seems like a read shouldn’t have any side effects that can be noticed if it happens earlier. A number of readers asked whether making the read into a volatile read prevented the reordering problem.
The answer to the general question of whether a volatile read can be re-ordered is yes, yes it can. Though a volatile read in a loop cannot be cached once and elided, a volatile read can be moved backwards in time with respect to a volatile write.
The answer to the more specific question of whether making the field volatile makes the program I was given correct cannot really be answered because that was an oversimplified toy program. Deducing the correctness of a real program by analyzing a toy program is perhaps a bad idea.
Instead, I’ll give you an example that (1) can actually happen even on the more restrictive memory model imposed by x86 hardware, and (2) heck let’s make everything volatile while we’re at it; it won’t help! We’ll elide a bunch of locks and see what goes wrong. Check out this program fragment:
static volatile bool q = false; static volatile bool r = false; static volatile bool s = false; static volatile bool t = false; static object locker = new object(); static bool GetR() { return r; } // No lock! static void SetR() { lock(locker) { r = true; } } static void MethodOne() { q = true; if (!GetR()) s = true; } static void MethodTwo() { SetR(); if (!q) t = true; }
The rest of the program, which I have not shown, behaves as follows. First, the static initializers run normally, so the four Booleans and the monitor are all created and assigned to their given values. Then the program creates two threads. Thread one runs MethodOne
and thread two runs MethodTwo
. Both threads run to completion and these are the only invocations of the two methods. The question is: can the original thread now observe s
and t
to both be true
?
Give that some thought before you read on.
.
.
.
.
.
.
It would appear that the answer is no, by the following logic.
- If one thread runs
q=true;
before the other runsif(!q)
thent
remainsfalse
. - Otherwise, one thread runs
q=true;
after the other runsif(!q)
. Clearly that implies thatif(!GetR())
must have run afterSetR();
. That implies that!GetR()
isfalse
. Therefores
remainsfalse
. - There are only these two possible cases and in each at least one of
s
andt
remainsfalse
, therefore it is impossible that both becometrue
.
This logic is wrong; specifically the second statement is completely bogus and therefore the conclusion does not follow. As is often the case with my puzzles, the portion of the analysis marked “clearly” is the flag that indicates the error.
The lack of a lock on the read of r
means that the CPU may re-order the read by moving it arbitrarily far backwards in time even with respect to volatile writes, and an x86 will occasionally do so if the stars align correctly! An x86 will not reorder two reads with respect to each other and will not reorder two writes with respect to each other, but it has no problem reordering a read of one variable with respect to a write of another. The CPU is permitted to move the read backwards in time by pretending that you actually wrote:
static void MethodOne() { bool temp = r; q = true; if (!temp) s = true; }
Since this optimization is invisible to the code on the current thread, it is legal. But now there is an obvious interleaving in which both s
and t
become true
. First we assign false
to temp
on one thread, then all of MethodTwo
runs on the other thread, so t
is true
, and then the remainder of MethodOne
sets s
to true
.
Now imagine that your program depends on s
and t
never being both true
, and this situation happens let’s say one time out of every billion executions of this code. If it runs a thousand times a day on a thousand machines that’s one failure in every three years. How on earth would you debug it? You’d be likely to suspect hardware failure rather than a bug in the program, but there assuredly is a bug in the program. These are the nightmare scenarios you run into when you think you’re clever enough to elide locks. The moment you depart even slightly from one of the “blessed” low-lock patterns you are off in the weeds.
The right thing to do here is first, if you can avoid the shared memory situation in the first place, do so. If you cannot, lock everything all the time. In this particular case, locking the read of r
or the write of q
would likely be sufficient to ensure that s
and t
are never both true
, but I discourage the attitude that walking as close as possible to the edge of a cliff is a great way to be safe. Walk as far away from that cliff as you possibly can! If you’ve got shared memory then put a lock around all usages of it. Simply making a shared variable volatile is insufficient to ensure thread safety in all cases.
Thanks to threading expert Joe Duffy for bringing this scenario to my attention many years ago.