Volatile Keyword in Multithreaded C# Delegates
This article examines the role of the volatile keyword
when managing delegate instances across multiple threads in C#. It
explains how the keyword impacts memory visibility and instruction
reordering, clarifies the immutable nature of delegates, and highlights
the critical distinction between thread visibility and thread-safe
execution to help you avoid common concurrency pitfalls.
Understanding the Volatile Keyword
In C#, the volatile keyword indicates that a field might
be modified by multiple concurrently executing threads. Applying
volatile to a field directs the compiler and runtime to
generate memory barriers (or fences) around accesses to that field. This
prevents two specific optimizations:
- Register Caching: Threads are forced to read the current value directly from main memory or the shared cache hierarchy rather than reading a stale value stored in a CPU register.
- Instruction Reordering: The compiler and the CPU are restricted from reordering read and write operations relative to the volatile read (acquire semantics) or volatile write (release semantics).
Delegate References and Immutability
To understand how volatile interacts with delegates, you
must consider how delegates function under the hood:
- Delegates are reference types inheriting from
System.MulticastDelegate. - Delegate instances are immutable. Combining (
+=) or removing (-=) handlers does not mutate the existing delegate instance; it creates a new delegate instance containing the updated invocation list and updates the field reference.
When you declare a delegate field as volatile, the
modifier applies exclusively to the reference pointer of the
delegate, not to the methods inside the invocation list.
private volatile Action? _onStateChanged;The Role of Volatile in Delegate Execution
1. Guaranteeing Fresh Pointer Visibility
When Thread A updates a delegate (e.g.,
_onStateChanged = NewHandler; or
_onStateChanged = null;), a volatile field guarantees that
Thread B immediately sees the newly assigned memory address when it
reads _onStateChanged. Without volatile,
Thread B could read a cached, out-of-date reference for an indeterminate
duration.
2. Preventing Deadlocks and Stale State in Loops
If a worker thread continuously monitors a delegate field to decide whether to invoke it or exit a loop, compiler optimizations might hoist the read out of the loop:
// Without volatile, the compiler might optimize this to read once before the loop:
while (_handler == null)
{
// Wait for assignment
}Declaring the field volatile ensures that the loop
re-evaluates the actual reference in memory on every iteration.
What Volatile Does Not Do
While volatile guarantees memory visibility, it does not
guarantee atomic operations or thread safety during delegate
invocation.
The NullReferenceException Race Condition
A common error is assuming that a volatile field makes safe-invocation checks atomic:
// Unsafe, even if _handler is volatile
if (_handler != null)
{
// Context switch can occur here; another thread sets _handler = null
_handler(); // Throws NullReferenceException
}The volatile keyword ensures that the read in the
if condition and the read at the invocation site both fetch
the latest reference, but it does not prevent another thread from
setting the field to null between those two operations.
Safe Invocation Alternatives
To safely execute a multithreaded delegate, volatile is
usually secondary to thread-safe invocation patterns:
Local Variable Snapshot: Assign the reference to a local variable before testing and invoking. Because local variables live on the thread's private stack, other threads cannot modify them.
Action? handler = _onStateChanged; if (handler != null) { handler(); }Null-Conditional Operator (
?.): In C# 6.0 and later, the null-conditional invocation automatically creates a single-read local snapshot under the hood:_onStateChanged?.Invoke();Because the JIT compiler handles this via a single read followed by invocation, it inherently avoids the null race condition.
Summary
The volatile keyword applied to a delegate field in C#
guarantees that any thread reading the delegate observes the most recent
reference write, preventing stale cached reads and unwanted memory
reorderings. However, it does not synchronize execution, prevent race
conditions during multi-step operations, or protect against
NullReferenceException without using local snapshots or the
null-conditional operator.