Atomics and Synchronization — std::atomic, Memory Order, std::mutex, std::condition_variable, Lock-Free Programming
In this tutorial, you will learn about Atomics and Synchronization. We cover key concepts, practical examples, and best practices to help you master this topic.
C++ std::atomic provides lock-free atomic operations on fundamental types with memory ordering constraints, while std::mutex, std::condition_variable, and std::barrier provide higher-level synchronization for thread coordination.
What You'll Learn
You will use std::atomic for lock-free variables, understand memory ordering (relaxed, acquire, release, acq_rel, seq_cst), implement spinlocks and lock-free data structures, use std::mutex, std::shared_mutex, and std::condition_variable for thread synchronization, apply std::call_once for thread-safe initialization, and avoid deadlocks with std::lock and consistent ordering.
Why It Matters
Data races are undefined behavior in C++ — the compiler and hardware assume no races exist. Atomics provide the tools to write correct concurrent code without undefined behavior. C++'s memory model (C++11) defines exactly how threads interact through shared memory, enabling portable concurrent programming across all platforms.
Learning Path
graph LR
A["62: Concurrency & Threads"] --> B["63: Atomics & Synchronization"]
B --> C["64: File I/O & Serialization"]
C --> D["65: Build Systems (CMake)"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
Basic std::atomic
std::atomic<T> provides atomic operations on type T without explicit locking.
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> counter(0);
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1); // Atomic increment
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter.load() << "\n"; // 1000000
// Without atomic: undefined behavior, likely wrong value
// Common atomic operations
std::atomic<int> value(10);
int expected = 10;
bool success = value.compare_exchange_weak(expected, 20);
std::cout << "CAS success: " << success << "\n"; // true
std::cout << "Value: " << value.load() << "\n"; // 20
// Exchange
int old = value.exchange(30);
std::cout << "Old: " << old << ", New: " << value.load() << "\n";
// Fetch-and-add/sub/and/or/xor
std::atomic<int> flags(0);
flags.fetch_or(1); // Set bit 0
flags.fetch_or(4); // Set bit 2
std::cout << "Flags: " << flags.load() << "\n"; // 5
}
Memory Ordering
The default memory order std::memory_order_seq_cst is correct but expensive. Weaker orders improve performance.
#include <iostream>
#include <atomic>
#include <thread>
// Relaxed ordering: only guarantees atomicity, no synchronization
std::atomic<int> relaxed_counter(0);
void relaxed_increment() {
for (int i = 0; i < 1000; ++i) {
relaxed_counter.fetch_add(1, std::memory_order_relaxed);
}
}
// Release-Acquire ordering: synchronizes between threads
std::atomic<bool> ready(false);
std::atomic<int> data(0);
void producer() {
data.store(42, std::memory_order_relaxed); // Non-atomic data
ready.store(true, std::memory_order_release); // Publish
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { // Wait for publish
// Spin
}
std::cout << "Data: " << data.load(std::memory_order_relaxed) << "\n";
// Guaranteed to see 42 because acquire synchronizes with release
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
Memory Order Reference
| Order | Purpose | Cost |
|---|---|---|
relaxed |
Atomicity only, no ordering | Cheapest |
consume |
Data dependency ordering (rarely used) | Low |
acquire |
Read: subsequent reads/writes can't move before | Medium |
release |
Write: preceding reads/writes can't move after | Medium |
acq_rel |
Read-modify-write: acquire + release combined | Medium |
seq_cst |
Sequential consistency: total global order | Most expensive |
Spinlock Implementation
A simple spinlock using std::atomic_flag (guaranteed lock-free).
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
class Spinlock {
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// Spin (busy-wait)
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
};
Spinlock spin;
int shared_data = 0;
void worker() {
for (int i = 0; i < 10000; ++i) {
std::lock_guard<Spinlock> lock(spin);
++shared_data;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back(worker);
}
for (auto& t : threads) t.join();
std::cout << "Shared data: " << shared_data << "\n"; // 80000
}
std::mutex and std::lock_guard
Mutexes provide blocking mutual exclusion.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <map>
class ThreadSafeMap {
std::map<int, std::string> map_;
mutable std::mutex mutex_;
public:
void insert(int key, const std::string& value) {
std::lock_guard<std::mutex> lock(mutex_);
map_[key] = value;
}
std::string find(int key) const {
std::lock_guard<std::mutex> lock(mutex_);
auto it = map_.find(key);
if (it != map_.end()) return it->second;
return "not found";
}
// Multiple mutex operations with std::lock (deadlock-free)
static void transfer(ThreadSafeMap& from, ThreadSafeMap& to,
int key, const std::string& value) {
std::lock(from.mutex_, to.mutex_);
std::lock_guard<std::mutex> lock1(from.mutex_, std::adopt_lock);
std::lock_guard<std::mutex> lock2(to.mutex_, std::adopt_lock);
from.map_.erase(key);
to.map_[key] = value;
}
};
int main() {
ThreadSafeMap map;
map.insert(1, "one");
map.insert(2, "two");
std::cout << map.find(1) << "\n"; // one
std::cout << map.find(3) << "\n"; // not found
}
std::shared_mutex (Reader-Writer Lock)
Multiple readers can hold the lock simultaneously; writers have exclusive access.
#include <iostream>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <chrono>
class SharedData {
int value_ = 0;
mutable std::shared_mutex mutex_;
public:
void write(int v) {
std::unique_lock lock(mutex_); // Exclusive access
value_ = v;
std::cout << "Writer set value to " << v << "\n";
}
int read() const {
std::shared_lock lock(mutex_); // Shared access
return value_;
}
};
int main() {
SharedData data;
// Multiple readers can read simultaneously
auto reader = [&data](int id) {
for (int i = 0; i < 5; ++i) {
int val = data.read();
std::cout << "Reader " << id << " saw " << val << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
};
auto writer = [&data]() {
for (int i = 0; i < 3; ++i) {
data.write(i * 100);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
};
std::thread w(writer);
std::vector<std::thread> readers;
for (int i = 0; i < 3; ++i) readers.emplace_back(reader, i);
w.join();
for (auto& r : readers) r.join();
}
std::condition_variable
Condition variables allow threads to wait for a condition to become true.
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <chrono>
template <typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable cv_;
public:
void push(T value) {
{
std::lock_guard lock(mutex_);
queue_.push(std::move(value));
}
cv_.notify_one(); // Wake one waiting consumer
}
T pop() {
std::unique_lock lock(mutex_);
cv_.wait(lock, [this]() { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
bool tryPop(T& value) {
std::lock_guard lock(mutex_);
if (queue_.empty()) return false;
value = std::move(queue_.front());
queue_.pop();
return true;
}
};
int main() {
ThreadSafeQueue<int> queue;
std::thread producer([&queue]() {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
queue.push(i);
std::cout << "Produced: " << i << "\n";
}
});
std::thread consumer([&queue]() {
for (int i = 0; i < 5; ++i) {
int value = queue.pop();
std::cout << "Consumed: " << value << "\n";
}
});
producer.join();
consumer.join();
}
std::call_once for Thread-Safe Initialization
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
class LazyResource {
std::once_flag initFlag_;
std::string data_;
void initialize() {
std::cout << "Initializing resource (once)\n";
data_ = "Expensive-to-create resource";
}
public:
const std::string& getData() {
std::call_once(initFlag_, &LazyResource::initialize, this);
return data_;
}
};
int main() {
LazyResource resource;
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back([&resource, i]() {
std::cout << "Thread " << i << ": " << resource.getData() << "\n";
});
}
for (auto& t : threads) t.join();
// "Initializing resource (once)" appears exactly once
}
Lock-Free Stack (Simplified)
A simple lock-free stack using atomic operations.
#include <iostream>
#include <atomic>
#include <thread>
template <typename T>
class LockFreeStack {
struct Node {
T value;
Node* next;
};
std::atomic<Node*> head_{nullptr};
public:
void push(T value) {
Node* node = new Node{std::move(value), nullptr};
node->next = head_.load(std::memory_order_relaxed);
while (!head_.compare_exchange_weak(
node->next, node,
std::memory_order_release,
std::memory_order_relaxed)) {
// CAS failed: node->next already updated by compare_exchange_weak
}
}
bool pop(T& value) {
Node* node = head_.load(std::memory_order_acquire);
while (node && !head_.compare_exchange_weak(
node, node->next,
std::memory_order_release,
std::memory_order_relaxed)) {
// Retry
}
if (!node) return false;
value = std::move(node->value);
delete node; // Note: ABA problem ignored for simplicity
return true;
}
};
int main() {
LockFreeStack<int> stack;
std::thread producer([&stack]() {
for (int i = 0; i < 1000; ++i) stack.push(i);
});
std::thread consumer([&stack]() {
int value;
for (int i = 0; i < 1000; ++i) {
while (!stack.pop(value)) {
std::this_thread::yield();
}
}
});
producer.join();
consumer.join();
std::cout << "Lock-free stack test passed\n";
}
Common Mistakes
Mistake 1: Race condition with mutex
if (!queue.empty()) { // Can change between check and pop!
auto val = queue.front();
queue.pop();
}
Use std::condition_variable or lock the entire check+pop sequence.
Mistake 2: Forgetting volatile for non-atomic shared flags
bool ready = false; // Compiler may optimize away the read
Use std::atomic<bool> instead. volatile is not sufficient for thread synchronization.
Mistake 3: Using relaxed ordering when acquire/release is needed
std::atomic<bool> flag;
flag.store(true, std::memory_order_relaxed); // Other thread may never see it
Use release/acquire for signaling between threads.
Mistake 4: Deadlock with multiple mutexes
Lock mutexes in a consistent order or use std::lock(m1, m2).
Mistake 5: Spurious wakeup without predicate in condition_variable
cv.wait(lock); // May wake up without notification
cv.wait(lock, []{ return condition; }); // Safe with predicate
Practice Questions
What does
std::memory_order_acquireguarantee? Answer: No reads or writes after the acquire can be reordered before the acquire operation.What is the difference between std::mutex and std::shared_mutex? Answer: mutex is exclusive-only. shared_mutex allows multiple concurrent readers with exclusive writers.
What is a spinlock and when would you use it? Answer: A spinlock busy-waits in a tight loop. Use it for very short critical sections to avoid OS context switch overhead.
How does compare_exchange_weak differ from compare_exchange_strong? Answer: weak may spuriously fail (return false even when expected matches), allowing better Code Generation in loops. Use weak in loops, strong for single attempts.
What is the ABA problem in lock-free programming? Answer: A pointer value changes from A to B and back to A. The CAS cannot detect the intermediate change. Use tagged pointers or hazard pointers.
FAQ
Mini Project
Implement a thread-safe, multi-producer, multi-consumer (MPMC) queue using std::mutex and std::condition_variable:
#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <string>
// Your MPMC queue
int main() {
MPMCQueue<std::string> queue(10); // Max 10 items
// 2 producers, 3 consumers
std::vector<std::thread> producers;
std::vector<std::thread> consumers;
for (int i = 0; i < 2; ++i) {
producers.emplace_back([&queue, i]() {
for (int j = 0; j < 5; ++j) {
std::string msg = "P" + std::to_string(i) + "-" + std::to_string(j);
queue.push(msg);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
});
}
std::atomic<int> consumed(0);
for (int i = 0; i < 3; ++i) {
consumers.emplace_back([&queue, &consumed]() {
std::string msg;
while (consumed < 10) {
if (queue.pop(msg, std::chrono::milliseconds(100))) {
std::cout << "Consumed: " << msg << "\n";
++consumed;
}
}
});
}
for (auto& t : producers) t.join();
for (auto& t : consumers) t.join();
std::cout << "All " << consumed << " messages processed\n";
}
This project demonstrates real-world C++ synchronization used in thread pools, task schedulers, and message passing systems. Compare with Java's BlockingQueue interface.
What's Next
You now understand atomics and synchronization — the building blocks of concurrent C++. Next, you will learn file I/O and Serialization, covering std::fstream, binary vs text I/O, and serialization formats like JSON and binary archives.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro