Skip to content
C++
Library
since C++11
Intermediate

Lock-Free Programming

C++ lock-free programming — compare_exchange, memory orders, Treiber stack, ABA problem, safe reclamation, and when to prefer mutexes.

Lock-Free Programmingsince C++11

Lock-free programming uses std::atomic operations — primarily compare_exchange — to coordinate concurrent threads without mutexes, guaranteeing system-wide forward progress even when individual threads are indefinitely suspended.

Overview

Lock-free has a precise meaning in concurrent systems theory. The progress hierarchy from weakest to strongest is:

cpp
Obstruction-free → Lock-free → Wait-free
(blocking)          (≥1 thread  (all threads
                    progresses) progress)

Lock-free guarantees that at least one thread completes its operation in a finite number of steps, regardless of how other threads are scheduled. It does not prevent starvation of an individual thread — a thread in a CAS retry loop can spin indefinitely if it keeps losing races.

Check whether an atomic type is actually lock-free before trusting it:

cpp
#include <atomic>

std::atomic<int>    a;
std::atomic<double> b;

bool a_lf = a.is_lock_free();          // C++11: runtime query
bool b_lf = b.is_lock_free();          // double is not lock-free on all platforms

// C++17: compile-time query
static_assert(std::atomic<int>::is_always_lock_free);
static_assert(std::atomic<void*>::is_always_lock_free);
// std::atomic<double>::is_always_lock_free is false on some platforms

The standard only guarantees lock-freedom for std::atomic_flag (C++11). All other specializations are implementation-defined, though atomic<int>, atomic<long>, and pointer types are lock-free on every mainstream architecture.

compare_exchange — The Core Primitive

compare_exchange_weak and compare_exchange_strong are the foundation of every lock-free algorithm. Both take expected by reference: on failure, expected is updated to the current atomic value so the caller can retry without a separate load.

cpp
std::atomic<int> val{0};

// compare_exchange_weak: may spuriously fail on LL/SC architectures (ARM, PowerPC, RISC-V)
// Spurious failure means: val == expected but CAS returns false anyway.
// Always use inside a retry loop — the compiler/hardware exploits this for efficiency.
int expected = 0;
bool ok = val.compare_exchange_weak(expected, 42,
                                    std::memory_order_acq_rel,   // success order
                                    std::memory_order_acquire);  // failure order

// compare_exchange_strong: only fails if val != expected.
// Use when the transformation inside the loop is expensive and spurious retries hurt.
ok = val.compare_exchange_strong(expected, 42);

// Canonical CAS loop: atomically apply a function to the current value
int old_val = val.load(std::memory_order_relaxed);
int new_val;
do {
    new_val = expensive_transform(old_val);
} while (!val.compare_exchange_weak(old_val, new_val,
                                    std::memory_order_release,
                                    std::memory_order_relaxed));
// Postcondition: val was changed atomically from old_val to new_val
// old_val holds the value that was in val at the moment of the winning CAS

The two-order form (C++11) takes success_order then failure_order. Constraints: failure order cannot be release or acq_rel, and must not be stronger than the success order.

Memory Orders

cpp
// relaxed — atomicity only; no ordering constraints relative to other operations
// Correct for independent counters where only the count matters, not ordering.
hits.fetch_add(1, std::memory_order_relaxed);

// release (stores) / acquire (loads) — the standard producer-consumer pair
// A release store synchronizes-with a subsequent acquire load on the same atomic.
// All stores before the release become visible after a matching acquire.
data = compute();                                   // non-atomic write
ready.store(true, std::memory_order_release);       // publishes data

// ... on another thread:
while (!ready.load(std::memory_order_acquire));     // waits for publication
process(data);                                      // guaranteed to see compute() result

// acq_rel — for read-modify-write ops that act as both acquire and release
// Use in the success arm of compare_exchange on a node that links to prior stores.
head.compare_exchange_weak(old, node,
                           std::memory_order_acq_rel,   // success
                           std::memory_order_acquire);  // failure

// seq_cst — total order across ALL seq_cst operations globally (the default)
// Required to prevent IRIW (Independent Reads of Independent Writes) reorderings
// that release/acquire alone cannot prevent. Costs a full barrier on ARM (dmb ish).
val.store(1);     // seq_cst by default
val.load();       // seq_cst by default

Rule of thumb: use release/acquire for producer-consumer handoffs, relaxed for independent statistics, seq_cst only when you need a total order observable across multiple distinct atomic variables simultaneously.

Lock-Free Stack (Treiber Stack)

The Treiber stack is the canonical lock-free data structure, introduced by R. Kent Treiber in 1986:

cpp
template<typename T>
class TreiberStack {
    struct Node {
        T     value;
        Node* next;
    };
    std::atomic<Node*> top_{nullptr};

public:
    void push(T value) {
        Node* node = new Node{std::move(value), nullptr};
        // Load current top into node->next before entering CAS loop
        node->next = top_.load(std::memory_order_relaxed);
        // Release so that readers who acquire-load this node see value initialized
        while (!top_.compare_exchange_weak(node->next, node,
                                           std::memory_order_release,
                                           std::memory_order_relaxed))
            ;  // CAS failure updates node->next automatically — no extra load
    }

    std::optional<T> pop() {
        Node* old_top = top_.load(std::memory_order_acquire);
        while (old_top) {
            // WARNING: old_top->next is read here before we own the node.
            // A concurrent pop could delete old_top between our load and this read.
            // This is a use-after-free in any multi-threaded program that frees nodes.
            // Safe reclamation (hazard pointers, epoch-based, RCU) is required.
            Node* next = old_top->next;
            if (top_.compare_exchange_weak(old_top, next,
                                           std::memory_order_acquire,
                                           std::memory_order_acquire))
                break;  // old_top is updated to current top on CAS failure
        }
        if (!old_top) return std::nullopt;
        T value = std::move(old_top->value);
        delete old_top;  // safe: CAS success means we uniquely own this node
        return value;
    }
};

The push uses release so the initialized value is visible to threads that acquire the node in pop. The pop uses acquire on both success and failure paths: success synchronizes with the push that wrote the node; failure ensures the reloaded old_top reflects the current list state.

ABA Problem and Safe Memory Reclamation

The ABA problem: thread 1 reads top = A. Thread 2 pops A, pushes B, pops B, pushes A again (same address reused via new). Thread 1's CAS succeeds because the pointer is still A, but A->next now points into freed memory.

Worse, there is a deeper problem: safe memory reclamation (SMR). In pop, any thread that loaded old_top before the CAS might still be reading through it after another thread has freed it. Three approaches:

Tagged pointers — pack a monotonically increasing counter into the pointer's spare bits (alignment gives at least 3 on 64-bit). Prevents ABA but does not solve reclamation:

cpp
struct TaggedPtr {
    Node*    ptr;
    uint64_t tag;  // increment on every push/pop
};
// Requires 128-bit CAS: __int128 on GCC/Clang, or CMPXCHG16B intrinsic on x86-64
std::atomic<TaggedPtr> top_;

Hazard pointers — before dereferencing a pointer, publish it to a per-thread hazard slot. Before freeing, scan all slots. If a pointer appears, defer the free. C++26 standardizes this as std::hazard_pointer. Available today in Folly (folly::hazptr).

Epoch-based reclamation — threads announce their current epoch on entry. A node removed in epoch N is safe to free once all threads have left epoch N. Lower overhead than hazard pointers under read-heavy workloads. Used in folly::ConcurrentHashMap and RCU implementations.

For production use, reach for battle-tested library implementations: folly::MPMCQueue, boost::lockfree::queue, or tbb::concurrent_queue (Intel TBB). They handle reclamation correctly.

Atomic Shared Pointer (C++20)

C++20 provides a proper std::atomic<std::shared_ptr<T>> specialization, which enables clean RCU-style patterns with automatic reclamation:

cpp
#include <atomic>
#include <memory>

struct Config { std::string endpoint; int timeout_ms; };

// C++20: std::atomic<shared_ptr<T>> — proper specialization (not just free functions)
std::atomic<std::shared_ptr<const Config>> g_config{
    std::make_shared<Config>("https://api.example.com", 5000)
};

// Writer: old config lives until the last reader drops its shared_ptr
void reload(Config c) {
    g_config.store(std::make_shared<Config>(std::move(c)));
}

// Readers: lock-free snapshot, no possibility of dangling pointer
void handle_request() {
    auto cfg = g_config.load();              // atomic refcount bump
    send(cfg->endpoint, cfg->timeout_ms);   // cfg pinned for the lifetime of this scope
}                                            // shared_ptr destructor releases refcount

Before C++20, std::atomic_load and std::atomic_store free functions (C++11) provided similar semantics but were deprecated in C++20. The specialization is preferred.

Best Practices

Profile first. An uncontended std::mutex costs roughly one atomic RMW — the same as a fetch_add. Lock-free wins only under sustained high contention, and even then the margin can be single-digit percentages. Never pay the complexity tax without data.

Use compare_exchange_weak in loops. On LL/SC architectures, weak compiles to a single LDXR/STXR pair. strong must internally loop to mask spurious failures, which is wasteful when your loop already handles retries.

Prefer release/acquire over seq_cst. The default seq_cst inserts dmb ish on ARM — a full barrier. release/acquire does not, and is sufficient for all single producer-consumer handoffs. Only reach for seq_cst when you need a single total order observable across multiple unrelated atomics.

Use std::atomic_flag for spinlocks. It is the only type the standard guarantees lock-free (C++11). test_and_set() returns the previous value; clear() resets it. C++20 added test(), wait(), and notify_one()/notify_all().

cpp
class Spinlock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;  // C++11 required for guaranteed clear state
    // C++20: {} value-initialization is sufficient
public:
    void lock()   { while (flag_.test_and_set(std::memory_order_acquire)); }
    void unlock() { flag_.clear(std::memory_order_release); }
};

Common Pitfalls

Dereferencing before owning. Reading through a pointer before a successful CAS means reading memory another thread might free concurrently. The Treiber pop example above has this flaw without SMR. Every lock-free algorithm involving dynamically allocated nodes needs a reclamation strategy.

Wrong failure memory order. Using memory_order_relaxed for the failure order means you cannot rely on seeing a consistent view of the node that defeated your CAS. Use acquire as the failure order whenever you will re-read through the updated expected value.

Confusing lock-free with starvation-free. A thread losing every CAS race can spin forever. If you need progress guarantees per thread, you need wait-free algorithms or a bounded retry + fallback strategy.

Mixing relaxed stores with acquire loads expecting synchronization. A relaxed store never synchronizes-with anything — not even a subsequent seq_cst load. Every happens-before edge requires a release on the writing side.

is_always_lock_free on a platform but not another. std::atomic<double> or std::atomic<long double> may be lock-free on x86-64 but not on 32-bit ARM. Guard with static_assert(std::atomic<T>::is_always_lock_free) at compile time if portability matters.

Safe Memory Reclamation — The Problem

The pop in the Treiber stack above has a subtle lifetime hole. After a thread loads old_top, a second thread can pop and delete that same node before the first thread reads old_top->next. The CAS on the first thread may even succeed — but only after dereferencing freed memory. This is undefined behaviour regardless of whether the CAS ultimately wins or loses.

There are two distinct problems layered on top of each other:

  • Use-after-free during traversal. Any read through a pointer you do not yet own is unsafe if another thread can free that object concurrently.
  • The ABA problem. The allocator may hand the same address back for a new node. The CAS sees the same pointer value and considers it unchanged, but ->next now belongs to a completely different node.

Tagged pointers (packing a counter into alignment bits) address ABA but not use-after-free — the counter prevents the CAS from succeeding on a recycled address, but does not stop the memory from being freed between the load and the read of ->next. Real safe memory reclamation (SMR) must prevent deallocation of a node while any thread might still be reading through it. Two approaches are standard in production systems:

SchemeMechanismStandard support
Hazard pointersEach thread publishes the address it is about to dereference. A node can only be freed once no hazard slot holds its address.C++26 std::hazard_pointer
Epoch-based reclamationThreads declare an epoch on entry. A node retired in epoch N is only freed once every thread has advanced past N.Not standardized; widely used in libraries

std::hazard_pointer (C++26)

<hazard_pointer> ships two pieces: a CRTP base class that makes your node type retirable, and a RAII slot handle that protects a pointer for the duration of an operation.

API summary (P2530R3, C++26):

cpp
// hazard_pointer_obj_base<T, D = default_delete<T>>
// Inherit from this to make T retirable via the HP runtime.
// Provides retire() — schedules deferred deletion once no hazard slot holds &obj.
template<class T, class D = std::default_delete<T>>
class std::hazard_pointer_obj_base;

// hazard_pointer — RAII slot handle (single-writer, multi-reader)
// Obtained via make_hazard_pointer(); released when it goes out of scope.
class std::hazard_pointer {
public:
    template<class T> T* protect(const std::atomic<T*>& src) noexcept;
    // Atomically loads src, publishes the address into this slot, then
    // re-validates. Loops internally if the value changed between load and
    // publish. Returns the protected pointer (may be null).

    template<class T> bool try_protect(T*& ptr, const std::atomic<T*>& src) noexcept;
    // Non-looping variant; returns false if validation fails.

    template<class T> void reset_protection(const T* ptr) noexcept;
    void reset_protection(std::nullptr_t = nullptr) noexcept;
    // Clear the slot — stop protecting the current pointer.

    bool empty() const noexcept;
};

std::hazard_pointer std::make_hazard_pointer(); // acquire a slot from the runtime pool

Corrected Treiber stack pop using hazard pointers:

cpp
#include <hazard_pointer>
#include <atomic>
#include <optional>

template<typename T>
class TreiberStack {
    struct Node : std::hazard_pointer_obj_base<Node> {
        T     value;
        Node* next;
    };
    std::atomic<Node*> top_{nullptr};

public:
    void push(T value) {
        Node* node = new Node{std::move(value), nullptr};
        node->next = top_.load(std::memory_order_relaxed);
        while (!top_.compare_exchange_weak(node->next, node,
                                           std::memory_order_release,
                                           std::memory_order_relaxed))
            ;
    }

    std::optional<T> pop() {
        // One hazard slot per pop() call; RAII-cleared when hp goes out of scope.
        std::hazard_pointer hp = std::make_hazard_pointer();
        Node* old_top;

        do {
            // protect() loads top_ and publishes the address atomically.
            // No concurrent thread may reclaim this node while hp holds it.
            old_top = hp.protect(top_);
            if (!old_top) return std::nullopt;

            // Safe: old_top is protected — it cannot be freed between the
            // protect() call above and the CAS below.
            Node* next = old_top->next;

            if (top_.compare_exchange_weak(old_top, next,
                                           std::memory_order_acquire,
                                           std::memory_order_acquire))
                break;  // We own old_top exclusively after a successful CAS.
        } while (true);

        hp.reset_protection();      // Release the hazard slot before retiring.
        T value = std::move(old_top->value);
        old_top->retire();          // Schedules deletion; runtime frees when safe.
        return value;
    }
};

Key points from the corrected version:

  • Node inherits from hazard_pointer_obj_base<Node>, which adds the retire() method and registers the node with the HP runtime.
  • hp.protect(top_) does a load-publish-validate loop internally. If top_ changes between the load and the publication, protect() retries automatically, so the returned pointer is always consistent.
  • After a successful CAS we have exclusive ownership of old_top. We call reset_protection() before retire() to clear our own hazard slot — otherwise the runtime would see our own slot when scanning and defer reclamation indefinitely.
  • retire() does not call delete immediately. The HP runtime scans all hazard slots across all threads; only when no slot holds old_top's address does it invoke the deleter (default: delete).

Folly reference implementation. folly::hazptr_holder and folly::hazptr_obj_base<T> (in <folly/synchronization/Hazptr.h>) match this API closely and are available today. The typical latency for protect() in Folly on a modern server is under one nanosecond for a pre-constructed holder.

Epoch-Based Reclamation (EBR)

EBR trades per-pointer precision for lower overhead under read-heavy workloads. The key insight is that instead of tracking individual pointers, it tracks time windows (epochs) during which threads can be reading shared objects.

How it works (3-epoch variant, as described by Keir Fraser):

  1. A global epoch counter (typically 0, 1, 2, cycling) is maintained.
  2. Each thread has a local epoch and an "active" flag. On entering a critical region (any operation that touches shared nodes), the thread sets its active flag and copies the global epoch to its local epoch.
  3. When a node is removed from a data structure, it is placed on the limbo list for the current global epoch — not freed immediately.
  4. Periodically, a thread attempts to advance the global epoch. This succeeds only if every currently active thread has already observed the current epoch (i.e., all local epochs match). When the epoch advances, the limbo list from two epochs ago is safe to reclaim: any thread that could have held a reference to those nodes has since re-entered a critical region and refreshed its local epoch.

Only three limbo lists are needed at any time because the epoch cycles with period 3. Memory that was retired in epoch N cannot be freed until the global epoch reaches N+2 and all threads are confirmed past N.

Trade-offs vs. hazard pointers:

  • EBR has near-zero overhead on the reader path — just a flag store and an atomic load of the global epoch.
  • Hazard pointers pay a publish-validate loop per protected pointer, but offer finer-grained reclamation: a retired node is freed as soon as no slot holds its address, regardless of epoch boundaries.
  • EBR can stall reclamation indefinitely if a thread enters a critical region and is preempted (its local epoch stops advancing). Hazard pointers only stall reclamation for nodes that a specific thread is actively protecting.

EBR is not yet in the C++ standard. It is used in folly::ConcurrentHashMap, Intel TBB's internal node management, and the userspace RCU library (liburcu). If you need EBR today, reach for one of those libraries or the mpoeter/emr header-only implementation on GitHub.

Hazard Pointers Pattern — Rolling Your Own

Hazard pointers can be implemented without C++26. The pattern needs only std::atomic<T*> arrays and std::this_thread::get_id() or a thread-local index. Understanding the manual implementation clarifies exactly what the runtime guarantees.

A minimal two-slot hazard pointer registry for a single data structure:

cpp
// C++11
#include <atomic>
#include <array>
#include <thread>
#include <functional>
#include <vector>

static constexpr int MAX_THREADS = 64;

// Per-thread hazard slots — one pointer per thread, per protected operation.
// Must be accessed only by the owning thread (write) and reclaimer (read).
struct HazardSlot {
    std::atomic<void*> ptr{nullptr};
};
std::array<HazardSlot, MAX_THREADS> g_hazard_slots;

// Thread-local index into g_hazard_slots; assigned once per thread on first use.
inline int get_thread_slot() {
    static std::atomic<int> next_slot{0};
    thread_local int slot = next_slot.fetch_add(1, std::memory_order_relaxed);
    return slot;
}

// Protect a pointer: publish it before dereferencing so no reclaimer frees it.
template<class T>
T* hazard_protect(std::atomic<T*>& src) {
    int slot = get_thread_slot();
    T* p;
    do {
        p = src.load(std::memory_order_acquire);
        g_hazard_slots[slot].ptr.store(p, std::memory_order_release);
        // Re-validate: if src changed between load and publish, retry.
    } while (p != src.load(std::memory_order_acquire));
    return p;
}

// Release protection for the calling thread's slot.
inline void hazard_release() {
    g_hazard_slots[get_thread_slot()].ptr.store(nullptr, std::memory_order_release);
}

// Retire a pointer: free it only once no hazard slot holds its address.
// In practice, accumulate retired pointers in a thread-local list and
// scan lazily (e.g., every R retired pointers, where R = k * MAX_THREADS).
template<class T>
void hazard_retire(T* p, std::vector<std::pair<void*, std::function<void(void*)>>>& retired) {
    retired.emplace_back(p, [](void* ptr) { delete static_cast<T*>(ptr); });
    if (retired.size() < 2 * MAX_THREADS) return;  // batch threshold

    // Snapshot all hazard slots.
    std::vector<void*> hazards;
    hazards.reserve(MAX_THREADS);
    for (auto& slot : g_hazard_slots)
        if (void* h = slot.ptr.load(std::memory_order_acquire); h)
            hazards.push_back(h);

    // Keep nodes that are still protected; free the rest.
    auto it = retired.begin();
    while (it != retired.end()) {
        bool protected_ = std::find(hazards.begin(), hazards.end(), it->first) != hazards.end();
        if (!protected_) { it->second(it->first); it = retired.erase(it); }
        else             { ++it; }
    }
}

Two invariants must hold for correctness. First, the publish-validate loop in hazard_protect must bracket every dereference — you cannot load a pointer, do other work, then publish it later. Second, hazard_release() must be called before hazard_retire() on the same pointer, or the reclaimer will see the owning thread's slot and permanently defer the free.

Lock-Free MPSC Queue

A multiple-producer single-consumer (MPSC) queue needs no CAS on the consumer path. Because only one thread ever dequeues, the tail pointer is uncontested and can be updated with a plain store (with appropriate ordering). Producers race to append nodes using compare_exchange.

cpp
// C++11
#include <atomic>
#include <optional>

template<typename T>
class MPSCQueue {
    struct Node {
        T                  value;
        std::atomic<Node*> next{nullptr};
    };

    // Producers update tail_; consumer owns head_.
    // Stub node eliminates empty-queue special cases.
    Node*              head_;       // consumer-only; no atomic needed
    std::atomic<Node*> tail_;

public:
    MPSCQueue() {
        Node* stub = new Node{};    // sentinel: head == tail == stub when empty
        head_ = stub;
        tail_.store(stub, std::memory_order_relaxed);
    }

    ~MPSCQueue() {
        while (dequeue());          // drain
        delete head_;               // delete the stub
    }

    // Producer path: lock-free, contention only on tail_ swap.
    void enqueue(T value) {
        Node* node = new Node{std::move(value)};
        // Atomically swing tail_ to the new node, get back the previous tail.
        Node* prev = tail_.exchange(node, std::memory_order_acq_rel);
        // Link the previous tail to the new node.  This store is visible to
        // the consumer once the acquire on the dequeue side sees tail_.
        prev->next.store(node, std::memory_order_release);
    }

    // Consumer path: single-threaded; no atomics required on head_.
    std::optional<T> dequeue() {
        Node* next = head_->next.load(std::memory_order_acquire);
        if (!next) return std::nullopt;  // queue is empty

        // Advance head_ past the stub/previous-front node.
        // The old head_ becomes the new stub.
        T value = std::move(next->value);
        delete head_;
        head_ = next;
        return value;
    }
};

The key trick is the tail_.exchange (a single atomic RMW): each producer atomically claims the "append slot" and receives a pointer to the node it must link its new node after. There is a brief window where the linked list is disconnected — prev->next is still null after the exchange. The consumer sees this as an empty queue and must spin or retry. This is accepted MPSC behaviour and why the consumer is sometimes written as a spin on head_->next.load() rather than a one-shot check.

For production use, folly::MPMCQueue and rigtorp/MPMCQueue (header-only, MIT) are well-tested alternatives with bounded capacity and wait-free producers.

Testing Lock-Free Code

Correctness of lock-free code cannot be validated by normal unit tests. The bugs — torn reads, visibility failures, ABA — are artifacts of specific hardware memory reorderings and thread interleavings that a single-machine test run will almost never trigger. Specialized tools are required.

ThreadSanitizer (TSan) is the first line of defence. It instruments every memory access at compile time and detects data races at runtime. Compile with -fsanitize=thread -g (Clang or GCC). TSan will not catch incorrect memory_order choices that are technically data-race-free but logically wrong (e.g., relaxed stores that miss a happens-before edge), but it catches outright data races reliably.

cpp
// C++11
// Build: clang++ -std=c++17 -fsanitize=thread -fno-omit-frame-pointer -g -O1 test.cpp
// Run normally — TSan reports race reports to stderr with full stack traces.
// Note: -O1 is recommended over -O0; some races only manifest with optimizations.

#include <atomic>
#include <thread>

std::atomic<int> counter{0};

void bad_race() {
    // TSan will NOT flag this — it IS atomic.
    counter.fetch_add(1, std::memory_order_relaxed);
}

int shared = 0;  // non-atomic

void actual_race() {
    // TSan WILL flag this — non-atomic write from two threads is a data race.
    shared++;     // UB: concurrent non-atomic access
}

CDSChecker (University of California, Irvine) is a model checker for C/C++11 atomics. It exhaustively explores thread interleavings under the C++11 memory model using partial-order semantics. It finds bugs invisible to TSan — including violations of memory_order_release/acquire contracts — by replaying every possible execution. It does not require a real data race to trigger; it finds model violations directly. Run it on a stripped-down version of your algorithm with a small, bounded input.

Relacy Race Detector (Dmitriy Vyukov) takes a different approach: you annotate your lock-free code with rl::atomic<T> wrappers, compile against the Relacy library, and it systematically schedules all thread interleavings using a context-switch injection strategy. Unlike TSan, it replays executions deterministically so you can reproduce and inspect bugs at the exact interleaving that exposed them.

Use memory_order_seq_cst first, then tighten. When writing a new lock-free algorithm, start with seq_cst on every operation. This gives the strongest ordering guarantees and minimizes the chance of subtle bugs. Once the algorithm is proven correct under seq_cst, selectively weaken orders one operation at a time, re-running CDSChecker or Relacy after each change. This is safer than trying to assign the "right" order upfront.

cpp
// C++11
// Development workflow: start with seq_cst, verify, then relax.
// Step 1: all seq_cst — proves algorithm structure is correct.
head_.compare_exchange_weak(old, node,
    std::memory_order_seq_cst,   // success
    std::memory_order_seq_cst);  // failure

// Step 2: after CDSChecker/Relacy passes, relax to minimum required:
head_.compare_exchange_weak(old, node,
    std::memory_order_acq_rel,   // success: pairs with acquire in pop
    std::memory_order_acquire);  // failure: we'll re-read through 'old'

Peer review by someone who has read the C++11 memory model specification is as important as any tool. The combination of TSan (for runtime races), CDSChecker or Relacy (for model-level ordering bugs), and code review covers the full bug space.

See Also