C++ STL Concurrency Support Guide: Thread-Safe Containers, Atomic Operations, and Synchronization Primitives

The C++ Standard Library provides comprehensive concurrency support through synchronization primitives, atomic operations, and thread-safe utilities. This guide covers all STL concurrency features, their use cases, practical examples, and best practices.

Table of Contents

  1. Introduction to STL Concurrency
  2. Synchronization Primitives
  3. Atomic Operations
  4. Thread-Safe Patterns
  5. Common Scenarios
  6. Practical Examples
  7. Common Practices
  8. Common Pitfalls and Mistakes

Introduction to STL Concurrency

What STL Provides for Concurrency

The C++ Standard Library includes:

  1. Synchronization Primitives: mutex, condition_variable, shared_mutex
  2. Lock Management: lock_guard, unique_lock, scoped_lock
  3. Atomic Operations: atomic<T> for lock-free programming
  4. Thread Management: thread, this_thread
  5. Async Operations: async, future, promise
  6. Thread-Local Storage: thread_local

Thread Safety Guarantees

STL Containers are NOT thread-safe by default:

  • Multiple threads reading: OK (if no writes)
  • Multiple threads writing: Requires synchronization
  • Mixed reads/writes: Requires synchronization

STL Concurrency Primitives ARE thread-safe:

  • std::mutex, std::atomic, etc. are thread-safe
  • Use them to protect shared data

Synchronization Primitives

std::mutex

Basic mutual exclusion lock:

#include <mutex>
#include <thread>
#include <iostream>
using namespace std;

mutex mtx;
int sharedCounter = 0;

void increment() {
    for (int i = 0; i < 1000; ++i) {
        lock_guard<mutex> lock(mtx);
        sharedCounter++;
    }
}

void mutexExample() {
    thread t1(increment);
    thread t2(increment);
    
    t1.join();
    t2.join();
    
    cout << "Counter: " << sharedCounter << endl;  // 2000
}

std::recursive_mutex

Allows same thread to lock multiple times:

#include <mutex>
#include <iostream>
using namespace std;

recursive_mutex rmtx;

void recursiveFunction(int depth) {
    lock_guard<recursive_mutex> lock(rmtx);
    cout << "Depth: " << depth << endl;
    
    if (depth > 0) {
        recursiveFunction(depth - 1);  // Can lock again
    }
}

void recursiveMutexExample() {
    recursiveFunction(3);
}

std::timed_mutex

Mutex with timeout support:

#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>
using namespace std;

timed_mutex tmtx;

void tryLockWithTimeout() {
    if (tmtx.try_lock_for(chrono::milliseconds(100))) {
        cout << "Lock acquired" << endl;
        this_thread::sleep_for(chrono::milliseconds(200));
        tmtx.unlock();
    } else {
        cout << "Failed to acquire lock" << endl;
    }
}

void timedMutexExample() {
    thread t1(tryLockWithTimeout);
    thread t2(tryLockWithTimeout);
    
    t1.join();
    t2.join();
}

std::shared_mutex (C++17)

Allows multiple readers or exclusive writer:

#include <shared_mutex>
#include <thread>
#include <vector>
#include <iostream>
using namespace std;

shared_mutex smtx;
int data = 0;

void reader(int id) {
    shared_lock<shared_mutex> lock(smtx);
    cout << "Reader " << id << " reads: " << data << endl;
}

void writer(int value) {
    unique_lock<shared_mutex> lock(smtx);
    data = value;
    cout << "Writer writes: " << value << endl;
}

void sharedMutexExample() {
    vector<thread> readers;
    
    // Multiple readers
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back(reader, i);
    }
    
    // Single writer
    thread writerThread(writer, 42);
    
    for (auto& t : readers) {
        t.join();
    }
    writerThread.join();
}

std::condition_variable

Synchronize threads based on conditions:

#include <condition_variable>
#include <mutex>
#include <queue>
#include <thread>
#include <iostream>
using namespace std;

queue<int> dataQueue;
mutex mtx;
condition_variable cv;
bool done = false;

void producer() {
    for (int i = 0; i < 5; ++i) {
        {
            lock_guard<mutex> lock(mtx);
            dataQueue.push(i);
        }
        cv.notify_one();
        this_thread::sleep_for(chrono::milliseconds(100));
    }
    
    {
        lock_guard<mutex> lock(mtx);
        done = true;
    }
    cv.notify_all();
}

void consumer() {
    while (true) {
        unique_lock<mutex> lock(mtx);
        cv.wait(lock, [] { return !dataQueue.empty() || done; });
        
        if (dataQueue.empty() && done) break;
        
        if (!dataQueue.empty()) {
            int value = dataQueue.front();
            dataQueue.pop();
            lock.unlock();
            cout << "Consumed: " << value << endl;
        }
    }
}

void conditionVariableExample() {
    thread prod(producer);
    thread cons(consumer);
    
    prod.join();
    cons.join();
}

Atomic Operations

std::atomic Basics

Lock-free atomic operations:

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
using namespace std;

atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1, memory_order_relaxed);
    }
}

void atomicExample() {
    vector<thread> threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment);
    }
    
    for (auto& t : threads) {
        t.join();
    }
    
    cout << "Counter: " << counter << endl;  // 10000
}

Memory Ordering

#include <atomic>
#include <thread>
#include <iostream>
using namespace std;

atomic<bool> ready{false};
atomic<int> data{0};

void producer() {
    data.store(42, memory_order_relaxed);
    ready.store(true, memory_order_release);  // Release semantics
}

void consumer() {
    while (!ready.load(memory_order_acquire)) {  // Acquire semantics
        this_thread::yield();
    }
    cout << "Data: " << data.load(memory_order_relaxed) << endl;
}

void memoryOrderingExample() {
    thread prod(producer);
    thread cons(consumer);
    
    prod.join();
    cons.join();
}

Atomic Operations on Different Types

#include <atomic>
#include <iostream>
using namespace std;

void atomicTypesExample() {
    // Integer types
    atomic<int> intAtomic{42};
    atomic<long> longAtomic{100L};
    
    // Boolean
    atomic<bool> boolAtomic{true};
    
    // Pointer
    int value = 42;
    atomic<int*> ptrAtomic{&value};
    
    // Operations
    intAtomic.fetch_add(1);
    boolAtomic.store(false);
    ptrAtomic.store(nullptr);
    
    // Compare and swap
    int expected = 42;
    bool success = intAtomic.compare_exchange_weak(expected, 100);
    cout << "CAS success: " << success << endl;
}

Thread-Safe Patterns

Lock Guards

RAII-based lock management:

#include <mutex>
#include <vector>
#include <thread>
using namespace std;

mutex mtx;
vector<int> sharedData;

void safePush(int value) {
    lock_guard<mutex> lock(mtx);  // Automatically unlocks
    sharedData.push_back(value);
    // Lock released here
}

void lockGuardExample() {
    vector<thread> threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(safePush, i);
    }
    
    for (auto& t : threads) {
        t.join();
    }
}

Unique Lock

Flexible lock with manual control:

#include <mutex>
#include <condition_variable>
using namespace std;

mutex mtx;
condition_variable cv;
bool ready = false;

void uniqueLockExample() {
    unique_lock<mutex> lock(mtx);
    
    // Can unlock manually
    lock.unlock();
    
    // Can lock again
    lock.lock();
    
    // Can use with condition variables
    cv.wait(lock, [] { return ready; });
    
    // Automatically unlocks
}

Scoped Lock (C++17)

Lock multiple mutexes atomically:

#include <mutex>
using namespace std;

mutex mtx1, mtx2;

void scopedLockExample() {
    // Locks both, unlocks both (prevents deadlock)
    scoped_lock lock(mtx1, mtx2);
    
    // Critical section
    // Both unlocked here
}

Once Flag

Execute function exactly once:

#include <mutex>
#include <thread>
#include <vector>
#include <iostream>
using namespace std;

once_flag flag;

void initialize() {
    cout << "Initialized once" << endl;
}

void callOnceExample() {
    vector<thread> threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([]() {
            call_once(flag, initialize);
        });
    }
    
    for (auto& t : threads) {
        t.join();
    }
    // "Initialized once" printed only once
}

Common Scenarios

Scenario 1: Thread-Safe Counter

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
using namespace std;

class ThreadSafeCounter {
    atomic<int> count_{0};
    
public:
    void increment() {
        count_.fetch_add(1, memory_order_relaxed);
    }
    
    void decrement() {
        count_.fetch_sub(1, memory_order_relaxed);
    }
    
    int get() const {
        return count_.load(memory_order_acquire);
    }
};

void counterScenario() {
    ThreadSafeCounter counter;
    vector<thread> threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([&counter]() {
            for (int j = 0; j < 100; ++j) {
                counter.increment();
            }
        });
    }
    
    for (auto& t : threads) {
        t.join();
    }
    
    cout << "Final count: " << counter.get() << endl;  // 1000
}

Scenario 2: Thread-Safe Queue

#include <queue>
#include <mutex>
#include <condition_variable>
using namespace std;

template<typename T>
class ThreadSafeQueue {
    queue<T> queue_;
    mutex mtx_;
    condition_variable cv_;
    
public:
    void push(const T& item) {
        {
            lock_guard<mutex> lock(mtx_);
            queue_.push(item);
        }
        cv_.notify_one();
    }
    
    bool pop(T& item) {
        unique_lock<mutex> lock(mtx_);
        cv_.wait(lock, [this] { return !queue_.empty(); });
        
        item = queue_.front();
        queue_.pop();
        return true;
    }
    
    bool empty() const {
        lock_guard<mutex> lock(mtx_);
        return queue_.empty();
    }
};

void queueScenario() {
    ThreadSafeQueue<int> tsq;
    
    thread producer([&tsq]() {
        for (int i = 0; i < 10; ++i) {
            tsq.push(i);
        }
    });
    
    thread consumer([&tsq]() {
        int value;
        while (!tsq.empty() || true) {
            if (tsq.pop(value)) {
                cout << "Popped: " << value << endl;
            }
        }
    });
    
    producer.join();
    consumer.join();
}

Scenario 3: Read-Write Lock Pattern

#include <shared_mutex>
#include <thread>
#include <vector>
using namespace std;

class ReadWriteData {
    int data_ = 0;
    mutable shared_mutex mtx_;
    
public:
    int read() const {
        shared_lock<shared_mutex> lock(mtx_);
        return data_;
    }
    
    void write(int value) {
        unique_lock<shared_mutex> lock(mtx_);
        data_ = value;
    }
};

void readWriteScenario() {
    ReadWriteData rwData;
    vector<thread> readers;
    
    // Multiple readers
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back([&rwData, i]() {
            for (int j = 0; j < 10; ++j) {
                int value = rwData.read();
                cout << "Reader " << i << " read: " << value << endl;
            }
        });
    }
    
    // Single writer
    thread writer([&rwData]() {
        for (int i = 0; i < 5; ++i) {
            rwData.write(i);
            this_thread::sleep_for(chrono::milliseconds(100));
        }
    });
    
    for (auto& t : readers) {
        t.join();
    }
    writer.join();
}

Scenario 4: Producer-Consumer with Condition Variable

#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
using namespace std;

template<typename T>
class ProducerConsumer {
    queue<T> buffer_;
    mutex mtx_;
    condition_variable notFull_;
    condition_variable notEmpty_;
    size_t maxSize_;
    bool done_ = false;
    
public:
    ProducerConsumer(size_t maxSize) : maxSize_(maxSize) {}
    
    void produce(const T& item) {
        unique_lock<mutex> lock(mtx_);
        notFull_.wait(lock, [this] { return buffer_.size() < maxSize_; });
        buffer_.push(item);
        notEmpty_.notify_one();
    }
    
    bool consume(T& item) {
        unique_lock<mutex> lock(mtx_);
        notEmpty_.wait(lock, [this] { return !buffer_.empty() || done_; });
        
        if (buffer_.empty() && done_) {
            return false;
        }
        
        item = buffer_.front();
        buffer_.pop();
        notFull_.notify_one();
        return true;
    }
    
    void finish() {
        lock_guard<mutex> lock(mtx_);
        done_ = true;
        notEmpty_.notify_all();
    }
};

void producerConsumerScenario() {
    ProducerConsumer<int> pc(5);
    
    thread producer([&pc]() {
        for (int i = 0; i < 10; ++i) {
            pc.produce(i);
            this_thread::sleep_for(chrono::milliseconds(50));
        }
        pc.finish();
    });
    
    thread consumer([&pc]() {
        int item;
        while (pc.consume(item)) {
            cout << "Consumed: " << item << endl;
        }
    });
    
    producer.join();
    consumer.join();
}

Practical Examples

Example 1: Thread-Safe Singleton

#include <mutex>
#include <atomic>
using namespace std;

class Singleton {
    static atomic<Singleton*> instance_;
    static mutex mtx_;
    
    Singleton() = default;
    
public:
    static Singleton* getInstance() {
        Singleton* tmp = instance_.load(memory_order_acquire);
        if (tmp == nullptr) {
            lock_guard<mutex> lock(mtx_);
            tmp = instance_.load(memory_order_relaxed);
            if (tmp == nullptr) {
                tmp = new Singleton();
                instance_.store(tmp, memory_order_release);
            }
        }
        return tmp;
    }
};

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

Example 2: Thread Pool with Atomic Counter

#include <atomic>
#include <thread>
#include <vector>
#include <functional>
#include <queue>
#include <mutex>
#include <condition_variable>
using namespace std;

class ThreadPool {
    vector<thread> workers_;
    queue<function<void()>> tasks_;
    mutex mtx_;
    condition_variable cv_;
    atomic<bool> stop_{false};
    atomic<int> activeTasks_{0};
    
public:
    ThreadPool(size_t numThreads) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers_.emplace_back([this]() {
                while (true) {
                    function<void()> task;
                    {
                        unique_lock<mutex> lock(mtx_);
                        cv_.wait(lock, [this] { 
                            return stop_ || !tasks_.empty(); 
                        });
                        
                        if (stop_ && tasks_.empty()) return;
                        
                        task = move(tasks_.front());
                        tasks_.pop();
                        activeTasks_++;
                    }
                    task();
                    activeTasks_--;
                }
            });
        }
    }
    
    template<typename F>
    void enqueue(F&& f) {
        {
            lock_guard<mutex> lock(mtx_);
            tasks_.emplace(forward<F>(f));
        }
        cv_.notify_one();
    }
    
    int getActiveTasks() const {
        return activeTasks_.load();
    }
    
    ~ThreadPool() {
        stop_ = true;
        cv_.notify_all();
        for (auto& worker : workers_) {
            worker.join();
        }
    }
};

Example 3: Lock-Free Stack

#include <atomic>
#include <memory>
using namespace std;

template<typename T>
class LockFreeStack {
    struct Node {
        shared_ptr<T> data;
        Node* next;
        Node(const T& d) : data(make_shared<T>(d)) {}
    };
    
    atomic<Node*> head_{nullptr};
    
public:
    void push(const T& data) {
        Node* new_node = new Node(data);
        new_node->next = head_.load();
        
        while (!head_.compare_exchange_weak(new_node->next, new_node)) {
            // Retry
        }
    }
    
    shared_ptr<T> pop() {
        Node* old_head = head_.load();
        while (old_head && 
               !head_.compare_exchange_weak(old_head, old_head->next)) {
            // Retry
        }
        return old_head ? old_head->data : shared_ptr<T>();
    }
};

Common Practices

1. Always Use RAII Locks

// Good: Automatic unlock
{
    lock_guard<mutex> lock(mtx_);
    // Critical section
}

// Bad: Manual unlock (error-prone)
mtx_.lock();
// Critical section
mtx_.unlock();  // What if exception occurs?

2. Minimize Lock Scope

// Good: Small critical section
void goodExample() {
    int value;
    {
        lock_guard<mutex> lock(mtx_);
        value = sharedData_;
    }
    // Expensive computation outside lock
    process(value);
}

// Bad: Large critical section
void badExample() {
    lock_guard<mutex> lock(mtx_);
    int value = sharedData_;
    process(value);  // Blocks other threads
}

3. Use Atomic for Simple Operations

// Good: Atomic for simple counter
atomic<int> counter{0};
counter.fetch_add(1);

// Overkill: Mutex for simple operation
mutex mtx;
int counter = 0;
lock_guard<mutex> lock(mtx);
counter++;

4. Consistent Lock Ordering

// Good: Always lock in same order
void function1() {
    scoped_lock lock(mtx1_, mtx2_);
}

void function2() {
    scoped_lock lock(mtx1_, mtx2_);  // Same order
}

// Bad: Different order (deadlock risk)
void bad1() {
    lock_guard<mutex> lock1(mtx1_);
    lock_guard<mutex> lock2(mtx2_);
}

void bad2() {
    lock_guard<mutex> lock2(mtx2_);  // Different order!
    lock_guard<mutex> lock1(mtx1_);
}

5. Use Condition Variables with Predicates

// Good: Always use predicate
cv.wait(lock, [] { return condition; });

// Bad: Spurious wakeups possible
cv.wait(lock);  // May wake up without condition being true

Common Pitfalls and Mistakes

Pitfall 1: Data Races

// Bad: Unsynchronized access
int counter = 0;
void increment() {
    counter++;  // Data race!
}

// Good: Synchronized
mutex mtx;
int counter = 0;
void increment() {
    lock_guard<mutex> lock(mtx);
    counter++;
}

Pitfall 2: Deadlocks

// Bad: Circular lock dependency
void function1() {
    lock_guard<mutex> lock1(mtx1_);
    lock_guard<mutex> lock2(mtx2_);
}

void function2() {
    lock_guard<mutex> lock2(mtx2_);  // Different order
    lock_guard<mutex> lock1(mtx1_);
}

// Good: Use scoped_lock or consistent order
void good1() {
    scoped_lock lock(mtx1_, mtx2_);
}

void good2() {
    scoped_lock lock(mtx1_, mtx2_);  // Same order
}

Pitfall 3: Forgetting to Unlock

// Bad: Exception may prevent unlock
void badExample() {
    mtx_.lock();
    riskyOperation();  // May throw
    mtx_.unlock();  // Never reached if exception
}

// Good: RAII automatically unlocks
void goodExample() {
    lock_guard<mutex> lock(mtx_);
    riskyOperation();  // Unlock guaranteed
}

Pitfall 4: Spurious Wakeups

// Bad: No predicate check
void badWait() {
    unique_lock<mutex> lock(mtx_);
    cv_.wait(lock);  // May wake up spuriously
    // Condition may not be true!
}

// Good: Always use predicate
void goodWait() {
    unique_lock<mutex> lock(mtx_);
    cv_.wait(lock, [] { return condition; });
    // Condition guaranteed to be true
}

Pitfall 5: Atomic Doesn’t Protect the Object

// Bad: Atomic only protects the pointer, not the object
atomic<MyClass*> ptr{nullptr};

void badExample() {
    MyClass* obj = ptr.load();
    obj->modify();  // Not thread-safe!
}

// Good: Protect object access
void goodExample() {
    MyClass* obj = ptr.load();
    lock_guard<mutex> lock(objMtx_);
    obj->modify();  // Thread-safe
}

Pitfall 6: Wrong Memory Ordering

// Bad: May not see updates
void badExample() {
    atomic<bool> ready{false};
    int data = 0;
    
    thread t1([&]() {
        data = 42;
        ready.store(true, memory_order_relaxed);  // No ordering guarantee
    });
    
    thread t2([&]() {
        while (!ready.load(memory_order_relaxed)) {}
        // data may not be 42!
        cout << data << endl;
    });
}

// Good: Proper ordering
void goodExample() {
    atomic<bool> ready{false};
    int data = 0;
    
    thread t1([&]() {
        data = 42;
        ready.store(true, memory_order_release);  // Release semantics
    });
    
    thread t2([&]() {
        while (!ready.load(memory_order_acquire)) {}  // Acquire semantics
        cout << data << endl;  // Guaranteed to see 42
    });
}

Pitfall 7: Race Condition in Initialization

// Bad: Check-then-act race
if (instance_ == nullptr) {
    instance_ = new Singleton();  // Race condition!
}

// Good: Double-checked locking
Singleton* getInstance() {
    Singleton* tmp = instance_.load(memory_order_acquire);
    if (tmp == nullptr) {
        lock_guard<mutex> lock(mtx_);
        tmp = instance_.load(memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton();
            instance_.store(tmp, memory_order_release);
        }
    }
    return tmp;
}

Summary

STL concurrency support provides:

  • Synchronization primitives: mutex, condition_variable, shared_mutex
  • Lock management: RAII-based lock_guard, unique_lock, scoped_lock
  • Atomic operations: Lock-free programming with atomic<T>
  • Thread utilities: thread, this_thread, thread_local

Key takeaways:

  1. STL containers are NOT thread-safe: Protect with mutexes
  2. Always use RAII locks: lock_guard, unique_lock, scoped_lock
  3. Minimize lock scope: Keep critical sections small
  4. Use atomic for simple operations: More efficient than mutexes
  5. Prevent deadlocks: Use scoped_lock or consistent lock ordering
  6. Use predicates with condition variables: Prevent spurious wakeups
  7. Understand memory ordering: Choose appropriate memory order for atomics
  8. Avoid common pitfalls: Data races, deadlocks, race conditions

STL concurrency primitives are powerful tools for writing safe, efficient concurrent code when used correctly.