Skip to content

Atomic Operation Patterns

📖 Application Scenario: The atomic patterns in this article have a high-frequency application in embedded systems—sharing variables between an ISR and the main loop without locks. If you are writing MCU firmware, reading this alongside Volume 8: Interrupt-Safe Programming will provide even greater clarity.

By this point, we have fully decomposed the std::atomic operation set, the six memory orders, fences and barriers, std::atomic_ref, and std::atomic_wait. However, taking these tools in isolation only answers the "how" question—how to perform an atomic addition, how to issue a release store, or how to wait for a value to change. Real-world engineering practice requires patterns: when facing a specific concurrency problem, which atomic operations should we choose, and what combination of memory orders will solve the problem correctly and efficiently?

In this article, we focus on several classic atomic operation patterns. These patterns were not invented in a vacuum—they come from solutions repeatedly verified in real-world systems like the Linux kernel, database engines, and high-performance network frameworks. We will deconstruct the "why" of each pattern: why it is designed this way, why the memory order cannot be weaker, and why a seemingly harmless change might introduce a bug.

The patterns we cover include: SeqLock (Sequence Locking), Double-Checked Locking, reference counting, publish-subscribe flags, lock-free min/max tracking, stop flags, and spinlocks. Each pattern is accompanied by complete code and step-by-step semantic analysis.

SeqLock: Sequence Locking Where Readers Are Never Blocked

Pattern Motivation

A classic solution to the readers-writer problem is the reader-writer lock, but its cost is high—even if there are only read operations, it requires the full overhead of a lock/unlock cycle, involving atomic operations or even system calls. In many scenarios, the read frequency is far higher than the write frequency (e.g., sensor data collection and reading, system time retrieval). We want read operations to be as lightweight as possible—ideally, completely lock-free.

SeqLock is designed for this. Its core idea is: use a spinlock to protect the writer (only one writer at a time), but do not block the reader at all—the reader determines if the data read is consistent by checking a sequence number. If the sequence number changes during the read (indicating a writer modified the data), the reader simply retries.

Implementation

Expand (58 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <iostream>

class SeqLock {
public:
    SeqLock() : sequence_(0) {}

    /// 写入者:获取写入权限
    void lock_write()
    {
        unsigned seq = sequence_.load(std::memory_order_relaxed);
        // 如果序列号是奇数,说明已经有写入者在工作
        if ((seq & 1u) != 0) {
            // 多写入者场景需要自旋等待或用额外的 mutex
            // 这里假设只有一个写入者
            return;
        }
        // 序列号加 1,变成奇数——标记"正在写入"
        sequence_.store(seq + 1, std::memory_order_release);
    }

    /// 写入者:释放写入权限
    void unlock_write()
    {
        unsigned seq = sequence_.load(std::memory_order_relaxed);
        // 序列号再加 1,变回偶数——标记"写入完成"
        sequence_.store(seq + 1, std::memory_order_release);
    }

    /// 读取者:在稳定状态下读取数据
    /// 返回读取开始时的序列号;调用者需要在读取后验证序列号是否变化
    unsigned read_begin() const
    {
        unsigned seq;
        for (;;) {
            seq = sequence_.load(std::memory_order_acquire);
            if ((seq & 1u) == 0) {
                // 偶数:没有写入者正在工作
                break;
            }
            // 奇数:有写入者正在工作,自旋等待
            // 实际实现中可以用 pause/yield 减少功耗
        }
        return seq;
    }

    /// 读取者:验证读取期间是否有写入发生
    /// 如果返回 true,说明读取是有效的
    bool read_validate(unsigned seq_before) const
    {
        unsigned seq_after = sequence_.load(std::memory_order_acquire);
        return (seq_after == seq_before) && ((seq_after & 1u) == 0);
    }

private:
    std::atomic<unsigned> sequence_;
};

Let's break down the core mechanism of this design.

The parity of the sequence number is key. An even number means "no writer is currently active, data is in a consistent state"; an odd number means "a writer is modifying data, state may be inconsistent." The writer changes the sequence number from even to odd at the start, and back to even upon completion—every successful write increments the sequence number by two.

The reader's strategy is "check-before-read + verify-after-read": first read the sequence number and confirm it is even (no active writer), then read the actual data, and finally read the sequence number again. If the sequence numbers are identical and even before and after, it means no writer intervened during the process, and the data is consistent. If they differ (or became odd), it means a write occurred during the read, and the data may be inconsistent—the reader discards this result and retries.

The release in memory_order_release and the acquire in read_begin() / read_validate() establish a happens-before relationship: all modifications by the writer to the actual data complete before the sequence_ turns back to even (release ensures previous writes aren't reordered after the store); the reader sees the data only after the sequence_ becomes even (acquire ensures subsequent reads aren't reordered before the load). This ensures the reader sees a version of the data that is fully written by the writer.

Usage Example

Expand (41 lines)Collapse
cpp
struct SensorData {
    double temperature;
    double humidity;
    double pressure;
};

SensorData g_sensor_data;
SeqLock g_seq_lock;

// 写入者线程(通常是传感器采集线程)
void writer_thread()
{
    for (int i = 0; i < 100; ++i) {
        g_seq_lock.lock_write();

        g_sensor_data.temperature = 20.0 + i * 0.1;
        g_sensor_data.humidity = 50.0 + i * 0.2;
        g_sensor_data.pressure = 1013.25 + i * 0.01;

        g_seq_lock.unlock_write();
    }
}

// 读取者线程(可以有多个)
void reader_thread(int id)
{
    for (int i = 0; i < 100; ++i) {
        SensorData local;
        unsigned seq;

        do {
            seq = g_seq_lock.read_begin();
            local = g_sensor_data;  // 拷贝数据
        } while (!g_seq_lock.read_validate(seq));

        // 现在可以安全地使用 local——它是一个一致的快照
        std::cout << "Reader " << id << ": temp=" << local.temperature
                  << " humidity=" << local.humidity
                  << " pressure=" << local.pressure << "\n";
    }
}

Note that the reader copies the data to a local variable before verifying. This is a critical detail—if we used the data directly without copying, and verification failed, the data would already be "dirty" and unusable, nor could we retry. SeqLock readers must be prepared to discard the read result at any time, so the data read must either be read-only (use and discard) or copied out before use.

Applicability Boundaries of SeqLock

There are a few limitations of SeqLock to be aware of. First, it assumes at most one writer—if multiple writers are needed, an external mutex must be wrapped around it. Second, the data type read must be trivially copyable—if the data contains pointers or complex objects, encountering a partially modified state during copying could lead to undefined behavior. Third, if writes are very frequent, readers may retry repeatedly, and performance may actually be worse than a reader-writer lock—SeqLock is suitable for "few writes, many reads" scenarios. The seqlock_t in the Linux kernel is a classic implementation of this pattern, used for time retrieval (do_gettimeofday) and other scenarios.

Double-Checked Locking: Finally Correct Since C++11

Pattern Motivation and Historical Baggage

The Double-Checked Locking Pattern (DCLP) is likely one of the most discussed patterns in multithreaded programming—not because it is the best pattern, but because it could not be implemented correctly prior to C++11. In their 2004 paper "C++ and the Perils of Double-Checked Locking," Scott Meyers and Andrei Alexandrescu analyzed in detail why it fails under the old standard. The core reasons are two-fold: compilers can reorder memory operations (writing object fields might be reordered after publishing the pointer), and the CPU itself might also reorder (relatively restricted on x86, very aggressive on ARM/PowerPC).

The formal memory model and std::atomic introduced in C++11 finally provided a portable, correct implementation for DCLP.

Correct DCLP Implementation

Expand (36 lines)Collapse
cpp
#include <atomic>
#include <mutex>
#include <iostream>

class Singleton {
public:
    static Singleton& instance()
    {
        Singleton* ptr = instance_.load(std::memory_order_acquire);
        if (ptr == nullptr) {
            std::lock_guard<std::mutex> lock(mutex_);
            ptr = instance_.load(std::memory_order_relaxed);
            if (ptr == nullptr) {
                ptr = new Singleton();
                instance_.store(ptr, std::memory_order_release);
            }
        }
        return *ptr;
    }

    void do_something()
    {
        std::cout << "Singleton::do_something()\n";
    }

private:
    Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static std::atomic<Singleton*> instance_;
    static std::mutex mutex_;
};

std::atomic<Singleton*> Singleton::instance_{nullptr};
std::mutex Singleton::mutex_;

Let's deconstruct the role of each check in this implementation.

The first check instance_.load(acquire) is performed outside the lock—if the instance is already created (the vast majority of calls take this path), it returns the pointer directly without needing to lock. memory_order_acquire guarantees that subsequent accesses to the Singleton object's members via this pointer will definitely see values initialized in the constructor. This is why this load cannot use relaxedrelaxed does not establish a happens-before relationship, and we might see an object for which memory has been allocated but construction is not yet complete.

The second check instance_.load(relaxed) is performed inside the lock—at this point we hold the mutex, so no other thread can be creating the instance simultaneously, thus relaxed is sufficient. If you feel relaxed looks unsafe, swapping it for acquire wouldn't introduce correctness issues, though theoretically it adds an unnecessary barrier.

The release semantics in instance_.store(ptr, release) are key: it guarantees that new Singleton() (including all initialization operations in the constructor) completes before the store. Combined with the acquire load in the first check, a complete release-acquire synchronization pair is established: all writes in the constructor happen-before the store, the store happens-before the other thread's acquire load, and the acquire load happens-before that thread's access to the Singleton's members. The chain is complete with no gaps.

Not Just Use Meyers' Singleton Directly

C++11 guarantees that the initialization of static local variables within a function is thread-safe. So the simplest singleton pattern is actually:

cpp
class Singleton {
public:
    static Singleton& instance()
    {
        static Singleton inst;
        return inst;
    }
private:
    Singleton() = default;
};

This code is entirely correct, and compilers typically implement it internally using std::call_once or equivalent atomic operations. So what use is DCLP?

First, the idea of DCLP is not limited to singletons—any "check-lock-recheck-initialize" pattern can use this approach. Examples include lazy initialization of a large object, on-demand allocation of thread-local storage, or lazy loading of configuration files. Second, in some extreme performance scenarios, the first check of DCLP generates lighter code than the static local variable—the latter usually requires checking a hidden std::once_flag, and the implementation of that flag might be heavier than a single atomic load.

Reference Counting: The Atomic Foundation of shared_ptr

Atomic Requirements for Reference Counting

Reference counting is another ubiquitous atomic pattern. The control block of std::shared_ptr contains a reference count and a weak reference count, both of which are atomic variables. Let's look at a simplified reference counting pointer to understand what atomic operations it needs:

Expand (92 lines)Collapse
cpp
#include <atomic>
#include <iostream>

template<typename T>
class IntrusivePtr {
public:
    IntrusivePtr() : ptr_(nullptr) {}

    explicit IntrusivePtr(T* ptr) : ptr_(ptr)
    {
        if (ptr_) {
            ptr_->add_ref();
        }
    }

    IntrusivePtr(const IntrusivePtr& other) : ptr_(other.ptr_)
    {
        if (ptr_) {
            ptr_->add_ref();
        }
    }

    IntrusivePtr(IntrusivePtr&& other) noexcept : ptr_(other.ptr_)
    {
        other.ptr_ = nullptr;
    }

    IntrusivePtr& operator=(const IntrusivePtr& other)
    {
        if (this != &other) {
            release();
            ptr_ = other.ptr_;
            if (ptr_) {
                ptr_->add_ref();
            }
        }
        return *this;
    }

    IntrusivePtr& operator=(IntrusivePtr&& other) noexcept
    {
        if (this != &other) {
            release();
            ptr_ = other.ptr_;
            other.ptr_ = nullptr;
        }
        return *this;
    }

    ~IntrusivePtr()
    {
        release();
    }

    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
    T* get() const { return ptr_; }

private:
    void release()
    {
        if (ptr_ && ptr_->release_ref()) {
            delete ptr_;
        }
        ptr_ = nullptr;
    }

    T* ptr_;
};

/// 基类:提供侵入式引用计数
class RefCounted {
public:
    RefCounted() : ref_count_(1) {}
    virtual ~RefCounted() = default;

    void add_ref()
    {
        ref_count_.fetch_add(1, std::memory_order_relaxed);
    }

    /// 返回 true 表示引用计数归零,应该销毁对象
    bool release_ref()
    {
        // acquire 保证在引用计数归零后,能看到所有之前 add_ref 的线程
        // 对对象的全部修改——确保析构时对象状态一致
        return ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1;
    }

private:
    std::atomic<int> ref_count_;
};

There are two key points regarding atomic operations in reference counting. add_ref() uses memory_order_relaxed—incrementing the reference count does not need to synchronize with other operations; we only care about the atomicity of the count itself. Even if thread A's add_ref and thread B's release_ref race, fetch_add and fetch_sub are themselves atomic and will not cause counting errors.

release_ref() using memory_order_acq_rel is a more nuanced choice. acquire semantics guarantee that when the reference count reaches zero, the current thread sees all modifications to the object by other threads prior to that point (because every object access after a add_ref implies a "holding a reference" relationship). release semantics guarantee that before destructing the object, all accesses by the current thread to the object have completed. Together, these two directions ensure the safety of destruction—the destructor sees a fully consistent object state, and no other thread is still accessing the object.

Publish-Subscribe Flag: Relaxed Counter + Acquire-Release Flag

Pattern Description

This is a very practical combination pattern: a relaxed atomic counter for statistics (no precise synchronization needed), plus a acquire-release atomic flag for notification. A typical scenario is a task queue—worker threads take tasks from a queue to execute, increment the counter after each task completes, and set the flag to notify the main thread when all are done.

Expand (44 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>

std::atomic<int> tasks_completed{0};
std::atomic<bool> all_done{false};

void worker(int num_tasks)
{
    for (int i = 0; i < num_tasks; ++i) {
        // 模拟任务处理
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        tasks_completed.fetch_add(1, std::memory_order_relaxed);
    }
}

int main()
{
    constexpr int kNumWorkers = 4;
    constexpr int kTasksPerWorker = 25;
    constexpr int kTotalTasks = kNumWorkers * kTasksPerWorker;

    std::vector<std::thread> threads;
    for (int i = 0; i < kNumWorkers; ++i) {
        threads.emplace_back(worker, kTasksPerWorker);
    }

    // 主线程等待所有任务完成
    while (!all_done.load(std::memory_order_acquire)) {
        std::cout << "Progress: " << tasks_completed.load(std::memory_order_relaxed)
                  << "/" << kTotalTasks << "\n";
        if (tasks_completed.load(std::memory_order_relaxed) >= kTotalTasks) {
            all_done.store(true, std::memory_order_release);
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }

    for (auto& t : threads) {
        t.join();
    }
    std::cout << "All " << kTotalTasks << " tasks completed!\n";
    return 0;
}

The key to this pattern is the separation of concerns. tasks_completed is only for displaying progress—it doesn't need precise synchronization, so memory_order_relaxed is sufficient. Even if the main thread occasionally reads an "old" count (off by 1 or 2), it has no impact on user experience. all_done is the true synchronization point—it uses acquire-release to guarantee that when the main thread sees all_done == true, all modifications to shared data by worker threads are visible.

This combination of "relaxed statistics + strict synchronization" is very common in engineering. Another example: a network server uses a relaxed counter to record processed requests (losing an occasional update is fine), and an acquire-release flag to notify of a shutdown signal (must guarantee all requests are processed before closing).

Lock-Free Min/Max Tracking: CAS Loop

Pattern Description

Maintaining a global maximum or minimum value, updated lock-free in a multithreaded environment—is a classic CAS (compare-and-swap) usage pattern. For example, a network server tracking the slowest request latency, or a sensor system recording extreme temperatures.

Expand (63 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <vector>
#include <random>
#include <iostream>
#include <cmath>

class MaxTracker {
public:
    explicit MaxTracker(double initial)
        : max_value_(initial)
    {}

    /// 如果新值大于当前最大值,更新最大值
    void update(double candidate)
    {
        double current = max_value_.load(std::memory_order_relaxed);
        while (candidate > current) {
            if (max_value_.compare_exchange_weak(
                    current, candidate,
                    std::memory_order_relaxed,
                    std::memory_order_relaxed)) {
                break;  // CAS 成功,更新完成
            }
            // CAS 失败,current 被自动更新为当前值,继续循环
        }
    }

    double get() const
    {
        return max_value_.load(std::memory_order_relaxed);
    }

private:
    std::atomic<double> max_value_;
};

int main()
{
    MaxTracker tracker(0.0);
    constexpr int kNumThreads = 4;
    constexpr int kUpdatesPerThread = 100000;

    auto worker = [&](int seed) {
        std::mt19937 rng(seed);
        std::uniform_real_distribution<double> dist(0.0, 100.0);
        for (int i = 0; i < kUpdatesPerThread; ++i) {
            tracker.update(dist(rng));
        }
    };

    std::vector<std::thread> threads;
    for (int i = 0; i < kNumThreads; ++i) {
        threads.emplace_back(worker, i + 42);
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Max value tracked: " << tracker.get() << "\n";
    return 0;
}

The CAS loop is the core of this pattern. We first load the current maximum value. If the candidate value is not greater than the current value, we do nothing and return. If the candidate is larger, we attempt to replace the current value with the candidate using CAS. CAS may fail—because another thread might have updated the maximum between our load and CAS. On failure, compare_exchange_weak updates current to the latest value, and we re-compare to decide if we need to try again.

Using compare_exchange_weak instead of strong here is a common optimization—in a loop, an occasional spurious failure of the weak version just means one extra iteration, but it is more efficient than strong on some platforms (especially ARM, PowerPC, and other LL/SC architectures).

All memory orders use relaxed—because we only care about the correctness of the single variable (the maximum value) itself, and don't need to establish synchronization with other variables. If max tracking is only for statistics or monitoring, strict happens-before guarantees are not needed.

However, note that the CAS operation for std::atomic<double> is not lock-free on most platforms—because double is 64-bit, while CAS on some 32-bit platforms can only handle 32 bits. If your target is a 32-bit embedded platform, this pattern may not be as efficient as expected. On 64-bit platforms, 64-bit CAS is usually lock-free.

Stop Flag: Correct Usage of atomic<bool>

Basic Pattern

The stop flag is perhaps the simplest atomic pattern—a background thread periodically checks the flag, and the main thread sets the flag and waits for the thread to exit. It looks simple, but there are details worth discussing:

Expand (28 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>

std::atomic<bool> should_stop{false};

void background_task()
{
    int count = 0;
    while (!should_stop.load(std::memory_order_acquire)) {
        // 做一些工作
        ++count;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    std::cout << "Task stopped after " << count << " iterations\n";
}

int main()
{
    std::thread t(background_task);

    std::this_thread::sleep_for(std::chrono::seconds(2));
    should_stop.store(true, std::memory_order_release);
    t.join();
    std::cout << "Main: thread joined\n";
    return 0;
}

Using memory_order_acquire and memory_order_release instead of relaxed here requires explanation. If the background thread reads some shared data after checking the stop flag (e.g., reading the latest config after sleep_for), then acquire guarantees it sees all modifications to shared data made by the flag-setting thread prior to that point. Similarly, release guarantees that all writes by the main thread before setting the flag (like updating config) are visible to the background thread.

If your stop flag is purely a boolean signal—the background thread doesn't need to read any other shared data—then relaxed is also safe. But forming the habit of using acquire/release does no harm; the performance difference is negligible (on x86, loads are ordinary reads regardless of memory order; on ARM, an acquire load is just one ldar instruction).

Low-Latency Stopping with atomic_wait

In the previous article, we introduced std::atomic::wait/notify. Here we can upgrade the stop flag to a "wait-style stop"—the background thread blocks waiting on the flag instead of polling it:

Expand (32 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>

std::atomic<bool> should_stop{false};

void waiting_task()
{
    int count = 0;
    while (!should_stop.load(std::memory_order_acquire)) {
        ++count;
        std::cout << "Working... iteration " << count << "\n";

        // 等待 100ms 或被 notify 唤醒
        should_stop.wait(false, std::memory_order_acquire);
    }
    std::cout << "Task stopped after " << count << " iterations\n";
}

int main()
{
    std::thread t(waiting_task);

    std::this_thread::sleep_for(std::chrono::seconds(2));
    should_stop.store(true, std::memory_order_release);
    should_stop.notify_one();

    t.join();
    std::cout << "Main: thread joined\n";
    return 0;
}

In this version, wait(false) blocks while should_stop is false, consuming no CPU. When the main thread store(true) + notify_one(), the background thread wakes immediately and exits. However, there is an issue: wait has no timeout—if the background thread needs to do some work periodically between wait (e.g., checking a sensor every 100ms), pure wait isn't suitable. In this case, a hybrid scheme combining sleep_for + notify is more practical: use sleep_for for periodic work most of the time, and use notify to wake the thread when immediate stopping is needed.

Spinlock: Educational Implementation and Applicable Scenarios

Basic Implementation

The spinlock is the simplest mutual exclusion primitive—a thread that fails to acquire doesn't block, but retries in a tight loop. It is generally unsuitable for production environments (explained later), but it serves as an excellent educational tool—because it demonstrates the usage of atomic_flag and the basic principles of lock-free synchronization with the least amount of code.

Expand (47 lines)Collapse
cpp
#include <atomic>
#include <thread>
#include <iostream>

class SpinLock {
public:
    SpinLock() : locked_(false) {}

    void lock()
    {
        while (locked_.exchange(true, std::memory_order_acquire)) {
            // exchange 返回旧值:如果是 true,说明锁已经被占用,继续自旋
            // 如果是 false,说明我们成功获取了锁
        }
    }

    void unlock()
    {
        locked_.store(false, std::memory_order_release);
    }

private:
    std::atomic<bool> locked_;
};

int main()
{
    SpinLock spinlock;
    int counter = 0;

    auto work = [&](int times) {
        for (int i = 0; i < times; ++i) {
            spinlock.lock();
            ++counter;
            spinlock.unlock();
        }
    };

    std::thread t1(work, 1000000);
    std::thread t2(work, 1000000);

    t1.join();
    t2.join();

    std::cout << "counter = " << counter << "\n";  // 2000000
    return 0;
}

The exchange(true, acquire) in lock() is a clever operation: it atomically sets locked_ to true while returning the previous value. If the old value is false, the lock was free and we successfully acquired it. If the old value is true, the lock is already held by someone else, and we continue looping. acquire semantics guarantee that operations after acquiring the lock are not reordered before exchange—modifications by other threads before releasing the lock are visible to the current thread.

The release semantics in unlock() guarantee that all writes in the critical section complete before releasing the lock—the next thread to acquire the lock will see these modifications.

Why Spinlocks Are Usually Not Suitable for Production

The biggest problem with spinlocks is that they consume CPU while waiting. If the critical section is very short (a few instructions), the overhead of spin-waiting may be lower than the context switch overhead of a mutex. But if the critical section is slightly longer, or if multiple threads are competing for the same lock, spinlocks cause CPU time to be wasted largely on "spinning." Even worse, on single-core systems, spinlocks are completely meaningless—the thread occupies the CPU while spinning, so the thread holding the lock never gets a chance to run to release it, resulting in deadlock.

In actual projects, prioritize std::mutex or std::shared_mutex. Only consider spinlocks when all of the following conditions are met simultaneously: the critical section is extremely short (no more than a few dozen instructions), contention is low, and it runs on a multi-core system. The Linux kernel uses spinlocks extensively in preemptible kernels—but the kernel has special scheduling guarantees (preemption disabled), which user-space does not have.

A Better Version Using atomic_flag

The SpinLock above uses std::atomic<bool>, but a more canonical approach is to use std::atomic_flag—it is the only atomic type guaranteed by the standard to be lock-free (std::atomic<bool> is theoretically not guaranteed to be lock-free):

cpp
class SpinLockFlag {
public:
    SpinLockFlag() { flag_.clear(); }

    void lock()
    {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            // test_and_set 原子地设置 flag 为 true 并返回旧值
        }
    }

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

private:
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};

test_and_set and clear are the two core operations of atomic_flag—the former atomically sets the flag to true and returns the old value, the latter atomically sets the flag to false. This version is semantically equivalent to the atomic<bool> version but guarantees lock-free behavior.

Decision Guide for Pattern Selection

With so many patterns understood, how do we choose when coding? We can decide based on the characteristics of the critical section.

If the critical section is just a simple variable read or update—like a counter, a flag, or a max value—direct std::atomic RMW operations (fetch_add, CAS, etc.) are sufficient. No mutex or spinlock is needed. This is the lightest choice with the best performance. The choice of memory order depends on whether synchronization with other variables is needed: if not, relaxed is fine; if so, use acquire/release.

If the critical section involves coordinated modification of multiple variables—like inserting an element into a map while updating a counter—std::atomic is not enough (unless you can pack multiple variables into a struct updated via CAS), so honestly use a std::mutex. Mutexes have context switch overhead, but they guarantee correctness, and overhead is low when contention is low (Linux's futex completes entirely in user space when uncontended).

If read frequency is far higher than write frequency, and the data is trivially copyable—SeqLock is a good choice. It keeps readers completely lock-free, at the cost of occasional retries. The Linux kernel uses it in many high-frequency read scenarios.

If lazy initialization or "check-lock-recheck" patterns are needed—DCLP is correct in the C++11 memory model. But if it's just a singleton, prioritize Meyers' Singleton (static local variable), as it is simpler and less error-prone.

If waiting for a condition is required—use std::atomic::wait/notify instead of busy-waiting or condition_variable. It uses futex on Linux, has latency an order of magnitude lower than condition_variable, and requires no extra mutex.

Summary

In this article, we applied all the tools learned in ch03—std::atomic operation sets, memory orders, fences, wait/notify, and atomic_ref—to seven classic concurrency patterns.

SeqLock allows readers to detect writer interference lock-free via sequence parity, suitable for "many reads, few writes, trivially copyable data" scenarios. Double-Checked Locking finally has a correct, portable implementation in the C++11 memory model—the core is the acquire load and release store of std::atomic<T*>. The reference counting pattern demonstrates the combination of fetch_add for relaxed and fetch_sub for acq_rel—the former cares only about atomicity, the latter ensures visibility at destruction. The publish-subscribe flag separates relaxed count statistics from strict synchronization notifications—each gets what it needs without dragging the other down. Lock-free min/max tracking uses a CAS loop to implement lock-free "compare-and-update." The stop flag is the simplest atomic pattern, but combined with wait/notify it can also achieve low-latency stop signals. The spinlock is a classic teaching tool but should be used cautiously in production.

These patterns are not isolated—they are often combined. A SeqLock might use a spinlock internally to protect writers; a DCLP uses an acquire-release synchronization pair internally; the destruction of a reference-counted pointer might trigger a publish-subscribe notification. Understanding the core idea of each pattern and flexibly combining them in specific scenarios is the real goal.

The next article leaves the atomic world of ch03 and enters a new topic. But before that, I suggest doing the exercises in this article—especially the implementations of SeqLock and DCLP, as they are high-frequency topics in interviews and the touchstone for testing whether you truly understand memory ordering.

Exercises

Exercise 1: Implement SeqLock

Based on the SeqLock class above, write a complete program: one writer thread updates a struct containing three double fields at 10ms intervals, and four reader threads read and print data at 1ms intervals. Run for a while and observe if readers always obtain consistent data (values of three fields come from the same write). If data appears inconsistent (e.g., temperature is from the 5th write but humidity is from the 6th), check if your read_begin / read_validate are used correctly.

Exercise 2: Implement DCLP Singleton

Implement a thread-safe configuration manager using the DCLP pattern. Requirements:

  1. Use the classic DCLP structure of std::atomic<ConfigManager*> + std::mutex
  2. Use memory_order_acquire and memory_order_release correctly in instance()
  3. Write a multi-threaded test: 8 threads call ConfigManager::instance() simultaneously, verifying that all threads get the same instance

Extra Challenge: Compare the performance of your DCLP implementation with Meyers' Singleton (static local variable). Use std::chrono to measure the time taken for 1 million instance() calls in both implementations.

Exercise 3: Lock-Free Minimum Tracker

Implement a MinTracker class that tracks a minimum value of double type using a CAS loop. Then use 4 threads to generate random numbers and call update(), finally verifying that get() returns the minimum of all numbers generated by the threads.

Hint: You need to check if atomic operations on floating-point numbers are lock-free on your current platform. Use std::atomic<double>::is_lock_free() to check. If not lock-free, performance may be lower than expected.

💡 Complete example code is available at Tutorial_AwesomeModernCPP, visit code/volumn_codes/vol5/ch03-atomic-memory-model/.

References

v0.7.0-9-g940ec1b · 940ec1b · 2026-07-05