What is the logic behind Volatile.Read and Volatile.Write?
The guarantees around volatile read and volatile write ensure that if one thread uses a volatile write to indicate that something is done, and then another thread uses a volatile read to notice that that something is done, then the second thread will see the full effects of that something.
For instance, lets say that Thread1
initializes object A
, and than does a volatile write to a flag
indicating that it's done. All of the memory operations involved in initializing the fields of object A
occur before the flag setting in the code. The guarantee is that these "cannot be moved after the volatile write" to flag
, so by the time the flag is set in memory, the whole initialized object is in memory where other threads can see it.
Now lets says that Thread2
is waiting for that object. It has a volatile read that sees flag
get set, and then reads the fields of A
and makes decisions based on what it has read. Those read operations occur after the volatile read in the code, and the volatile read guarantee ensures that they will occur after the volatile read in memory, so that Thread2
is guaranteed to see the fully initialized fields of object A
, and not anything that existed before it.
So: The writes that Thread1
does go out to memory before the volatile write to flag
, which obviously must go out to memory before Thread2
can volatile read it, and the following reads in Thread2
happen after that so it sees the properly initialized object.
That's why writes can't be delayed past volatile writes, and reads can't be moved up before volatile reads. What about vice versa?
Well, lets say that Thread2
, after it sees that A
is initialized, does some work and writes it to some memory that Thread1
is using to decide how to initialize A
. Those writes are guaranteed not to happen in memory until after Thread2
sees that A
is done, and the reads that Thread1
makes to those locations are guaranteed to happen before the flag
is set in memory, so Thread2
's writes are guaranteed not to interfere with the initialization work.
The logic behind these rules is called Memory Model.
In .NET we have quite weak memory model (see ECMA-335), which means that compiler, jit and cpu are allowed to do a lot of optimizations (as long as they keep single threaded semantics and volatile semantics) and it's really awesome in terms of possibilities for optimizations.
It's allowed for compiler/jit/cpu to make any optimizations as long as they satisfy the following:
Conforming implementations of the CLI are free to execute programs using any technology that guarantees, within a single thread of execution, that side-effects and exceptions generated by a thread are visible in the order specified by the CIL. For this purpose only volatile operations (including volatile reads) constitute visible side-effects. (Note that while only volatile operations constitute visible side-effects, volatile operations also affect the visibility of non-volatile references.)
Which means that all your code is assumed to be single-threaded unless you use implicit or explicit volatile operations.
For example,
Acquiring a lock ( System.Threading.Monitor.Enter or entering a synchronized method) shall implicitly perform a volatile read operation, and releasing a lock ( System.Threading.Monitor.Exit or leaving a synchronized method) shall implicitly perform a volatile write operation.
Which means that it's not possible to move any operations (from lock statement) above (implicit Volatile.Read prevents this) and it's not possible to move them below lock (implicit Volatile.Write prevents this). So they stay right inside the lock statement, but it's still possible for them to be reordered or optimized inside this lock statement.