Untangling .NET Atomics: The Status Quo

What atomics functionality currently exists in .NET?

This is the second in a series of blog posts I'm writing on how we might fix the mess that is .NET atomics.

Now that (hopefully) everyone is on the same page regarding terminology, it's time to take a look at the atomics functionality currently provided by the .NET ecosystem.

CIL volatile. Instruction

C# compiles to Common Intermediate Language (CIL), the bytecode of the .NET ecosystem. CIL has a particular prefix instruction called volatile. which, when combined with load and store instructions, imposes the volatility semantics discussed in the previous post. So, loads get acquire semantics and stores get release semantics, and both are guarded against optimizations. This prefix can be applied to instructions such as ldind.i4, stind.i8, etc. Some CIL instructions (e.g. cpblk) perform both loads and stores as part of their operation - in these cases, a single volatile. prefix applies to both the load and the store, and imposes acquire and release semantics on these, respectively.

C# volatile Keyword

The C# language features a volatile keyword that partially exposes the functionality of the CIL volatile. instruction. When applied to a field, volatile will make the C# compiler emit, for example, volatile.ldfld instead of just ldfld when loading from a non-static field, 0r volatile.stsfld instead of just stsfld when storing to a static field.

Interestingly, the volatile keyword can only be applied to fields whose types are either 32 bits wide or as wide as the system's word size. We'll get into the pros and cons of that in the next post.

Volatile Class Methods

The System.Threading.Volatile class is the most recent attempt to expose understandable atomics functionality in the framework, and it is relatively successful. This class exposes a bunch of Read and Write methods which perform loads and stores with acquire and release semantics, respectively. In addition, all of these are guaranteed to be atomic - even the 64-bit overloads on 32-bit systems. Of course, this muddies the waters yet again in regards to terminology, since volatility doesn't necessarily have anything to do with atomicity, but alas.

Interlocked Class Methods

The System.Threading.Interlocked class is where a lot of the really interesting atomics functionality exists: Exchange, CompareExchange, Add, Or, MemoryBarrier, etc. For all of the methods on this class, atomicity and sequential consistency are guaranteed. In other words, these methods are the easiest to understand and use out of all the functionality discussed so far. I won't go into full detail on what all of these do as this is fairly easy to find in the relevant documentation, but a few deserve special mention.

Read, which only has 64-bit overloads, exists specifically to enable atomic loads of 64-bit values on 32-bit systems. It's similar to 64-bit overloads of Volatile.Read, but with the stronger sequential consistency semantics. (If you're wondering why an equivalent Write method doesn't exist, it's because the 64-bit overloads of Exchange effectively fulfill the same purpose.)

MemoryBarrier inserts a full memory barrier with sequential consistency semantics.

MemoryBarrierProcessWide is an interesting beast. It inserts a full memory barrier just like MemoryBarrier but also forces all other threads in the process to immediately execute one as well, and then returns. This method can be hard to grok – I'll probably write a whole post about it later – but for now, what you need to know is that it is very expensive compared to MemoryBarrier and should generally be avoided except in some very niche situations where it's extremely useful.

Thread Class Methods

The System.Threading.Thread class provides a number of VolatileRead and VolatileWrite methods to perform reads and writes. Confusingly, in .NET Framework, these methods used to have sequential consistency semantics, despite what the names suggest. In .NET 5+, they simply delegate to the methods on the System.Threading.Volatile class, so they instead have acquire or release semantics, respectively. (This means that any code using these methods could break when ported from .NET Framework 4.x to .NET 5+.)

There is also a MemoryBarrier method which simply delegates to the same method on the System.Threading.Interlocked class.

Next, I'll cover the many issues with the functionality described here.