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_flagtypically compiles down to theXCHG(exchange) instruction with a register or memory operand, or aBTS(bit test and set) instruction prefixed withLOCK. TheXCHGinstruction 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 likeSWP(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:
test_and_set(): Atomically reads the current flag value, sets it totrue, and returns the previous value.clear(): Atomically resets the flag value tofalse.
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 callingtest_and_set().wait()andnotify_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_INITis no longer mandatory in C++20;std::atomic_flagis initialized to the cleared state by default.