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:

  1. 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.
  2. 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:

  1. 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();
    }
  2. 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.