std::atomic_flag in C++: Lock-Free Boolean Flags

std::atomic_flag is an atomic boolean primitive in C++ designed to provide ultra-low-overhead synchronization between threads. Unlike other atomic types defined in <atomic>, std::atomic_flag is the only type explicitly guaranteed by the C++ standard to be strictly lock-free across all conforming implementations. This article explains how std::atomic_flag interfaces directly with hardware-level atomic instructions, how its minimal set of operations operates, and how it is used to implement foundational concurrency constructs like spinlocks without relying on operating system mutexes.

The Guaranteed Lock-Free Design

Standard atomic types, such as std::atomic<bool> or std::atomic<int>, may internally fall back to lock-based emulation (such as an internal mutex table) if the underlying target architecture lacks suitable hardware instructions.

std::atomic_flag avoids this uncertainty. The C++ standard mandates that std::atomic_flag must never employ locks. It represents a minimal 1-bit state: it is either set (true) or clear (false). By restricting functionality to a bare minimum, C++ guarantees that the compiler can map operations on std::atomic_flag directly to dedicated CPU-level atomic instructions.

Hardware-Level Instruction Mapping

Modern CPU architectures provide hardware-assisted atomic instructions to modify memory while preventing race conditions from cache hierarchies and multi-core access. std::atomic_flag leverages these CPU capabilities directly:

  • x86/x64 Architecture: On x86 processors, modifying an std::atomic_flag typically compiles down to the XCHG (exchange) instruction with a register or memory operand, or a BTS (bit test and set) instruction prefixed with LOCK. The XCHG instruction on x86 inherently asserts the processor's bus lock or cache-line lock protocol, guaranteeing that no other core can read or write that cache line during the operation.
  • ARM Architecture: On modern ARM processors (ARMv8 and later), atomic flag operations translate to instructions like LDREX/STREX (load-linked/store-conditional pairs) or modern atomic swap instructions like SWP (introduced with ARMv8.1 Large System Extensions). These instructions monitor exclusive memory access at the hardware cache controller level.

Because these operations are handled entirely within the CPU's memory bus and cache coherency protocols (such as MESI/MOESI), they execute without invoking system calls or operating system scheduling routines.

Core Operations and Memory Ordering

Historically (prior to C++20), std::atomic_flag offered only two fundamental operations to ensure maximum hardware portability:

  1. test_and_set(): Atomically reads the current flag value, sets it to true, and returns the previous value.
  2. clear(): Atomically resets the flag value to false.

Both functions accept a std::memory_order argument. This allows developers to fine-tune memory barriers and CPU instruction reordering:

#include <atomic>

std::atomic_flag flag = ATOMIC_FLAG_INIT;

// Set the flag and retrieve the previous state
bool previous_state = flag.test_and_set(std::memory_order_acquire);

// Reset the flag
flag.clear(std::memory_order_release);

Using std::memory_order_acquire on the setting operation prevents subsequent memory reads and writes from being reordered before the flag acquisition. Using std::memory_order_release ensures that preceding reads and writes are committed to memory before the flag is cleared.

Implementing a Basic Spinlock

Because std::atomic_flag maps to atomic test-and-set hardware instructions, its primary use case is creating simple, high-performance spinlocks for low-contention critical sections:

#include <atomic>

class Spinlock {
private:
    std::atomic_flag lock_flag = ATOMIC_FLAG_INIT;

public:
    void lock() {
        // Busy-wait until the flag is successfully acquired
        while (lock_flag.test_and_set(std::memory_order_acquire)) {
            // Optional: Pause CPU pipeline to reduce power and bus contention
            #if defined(__x86_64__) || defined(_M_X64)
            __builtin_ia32_pause();
            #endif
        }
    }

    void unlock() {
        lock_flag.clear(std::memory_order_release);
    }
};

In this implementation, the test_and_set loop continues until it successfully transitions the flag from unset to set, guaranteeing exclusive access to the calling thread.

C++20 Enhancements

C++20 expanded std::atomic_flag to make it more versatile while preserving its lock-free guarantee:

  • test(): Reads the current value of the flag without modifying it, reducing unnecessary cache coherency traffic compared to repeatedly calling test_and_set().
  • wait() and notify_one() / notify_all(): Enables blocking synchronization. A thread can pause execution until the flag changes value, allowing the operating system to sleep the thread rather than wasting CPU cycles spinning continuously.
  • Default Initialization: The initialization macro ATOMIC_FLAG_INIT is no longer mandatory in C++20; std::atomic_flag is initialized to the cleared state by default.