C++ mutex synchronization lock_guard thread safety
|

C++ Mutexes & Synchronization: lock_guard, unique_lock Guide 2026

Why Mutexes Exist

When multiple threads access the same data and at least one modifies it, you get a data race — undefined behavior in C++. A mutex (mutual exclusion) solves this by allowing only one thread to access the protected data at a time. The thread that locks the mutex “owns” it; other threads trying to lock it will block until the owner unlocks it.

Think of a mutex like a bathroom lock. Only one person can be inside at a time. Others wait at the door. When you’re done, you unlock and the next person enters. Simple concept, but the details of using mutexes correctly in C++ require careful attention.

std::mutex Basics

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;
int shared_counter = 0;

void safe_increment(int times) {
    for (int i = 0; i < times; ++i) {
        mtx.lock();           // Acquire the lock
        ++shared_counter;     // Only one thread at a time here
        mtx.unlock();         // Release the lock
    }
}

int main() {
    std::thread t1(safe_increment, 100000);
    std::thread t2(safe_increment, 100000);
    t1.join();
    t2.join();

    std::cout << "Counter: " << shared_counter << "
";
    // Always 200000 — no data race!
}

Manual lock()/unlock() works but is dangerous — if an exception is thrown between them, the mutex stays locked forever (deadlock). Always use RAII wrappers instead.

std::lock_guard — RAII Locking

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

class ThreadSafeCounter {
    mutable std::mutex mtx_;
    int count_ = 0;

public:
    void increment() {
        std::lock_guard<std::mutex> lock(mtx_);
        ++count_;
        // lock released automatically when 'lock' goes out of scope
        // Even if an exception is thrown!
    }

    int get() const {
        std::lock_guard<std::mutex> lock(mtx_);
        return count_;
    }
};

int main() {
    ThreadSafeCounter counter;
    std::vector<std::thread> threads;

    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([&counter]() {
            for (int j = 0; j < 10000; ++j) {
                counter.increment();
            }
        });
    }

    for (auto& t : threads) t.join();
    std::cout << "Counter: " << counter.get() << "
";  // 100000
}

std::lock_guard locks the mutex in its constructor and unlocks in its destructor. This RAII pattern is the standard way to use mutexes in C++. You cannot forget to unlock, and exceptions are handled safely.

std::unique_lock — Flexible Locking

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx;

void demo_unique_lock() {
    // Deferred locking — create without locking
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
    // ... do non-critical work ...
    lock.lock();   // Lock when ready
    // ... critical section ...
    lock.unlock(); // Unlock early if needed
    // ... more non-critical work ...

    // Try-lock: non-blocking attempt
    std::unique_lock<std::mutex> lock2(mtx, std::try_to_lock);
    if (lock2.owns_lock()) {
        std::cout << "Got the lock!
";
    } else {
        std::cout << "Lock was busy
";
    }

    // Timed lock: wait up to N milliseconds
    std::timed_mutex tmtx;
    std::unique_lock<std::timed_mutex> lock3(tmtx, std::chrono::milliseconds(100));
    if (lock3.owns_lock()) {
        std::cout << "Got timed lock
";
    }
}

// unique_lock can be moved (lock_guard cannot)
std::unique_lock<std::mutex> get_lock() {
    std::unique_lock<std::mutex> lock(mtx);
    return lock;  // Transfer ownership
}

int main() {
    demo_unique_lock();
    auto lock = get_lock();
    // lock holds the mutex
}

std::scoped_lock — Multiple Mutexes

#include <iostream>
#include <thread>
#include <mutex>

struct Account {
    std::mutex mtx;
    double balance;
    std::string name;
};

// Transfer money between accounts — needs BOTH locks
void transfer(Account& from, Account& to, double amount) {
    // scoped_lock locks BOTH mutexes atomically — no deadlock!
    std::scoped_lock lock(from.mtx, to.mtx);

    if (from.balance >= amount) {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "Transferred $" << amount
                  << " from " << from.name << " to " << to.name << "
";
    }
}

int main() {
    Account alice{.balance = 1000, .name = "Alice"};
    Account bob{.balance = 500, .name = "Bob"};

    std::thread t1(transfer, std::ref(alice), std::ref(bob), 200);
    std::thread t2(transfer, std::ref(bob), std::ref(alice), 100);
    t1.join();
    t2.join();

    std::cout << alice.name << ": $" << alice.balance << "
";
    std::cout << bob.name << ": $" << bob.balance << "
";
}

std::shared_mutex — Reader-Writer Lock

#include <iostream>
#include <thread>
#include <shared_mutex>
#include <map>
#include <string>
#include <vector>

class ThreadSafeCache {
    mutable std::shared_mutex mtx_;
    std::map<std::string, std::string> data_;

public:
    // Multiple threads can read simultaneously
    std::string get(const std::string& key) const {
        std::shared_lock lock(mtx_);  // Shared (read) lock
        auto it = data_.find(key);
        return (it != data_.end()) ? it->second : "";
    }

    // Only one thread can write at a time
    void set(const std::string& key, const std::string& value) {
        std::unique_lock lock(mtx_);  // Exclusive (write) lock
        data_[key] = value;
    }

    size_t size() const {
        std::shared_lock lock(mtx_);
        return data_.size();
    }
};

int main() {
    ThreadSafeCache cache;
    cache.set("key1", "value1");

    // Multiple readers can run concurrently
    std::vector<std::thread> readers;
    for (int i = 0; i < 5; ++i) {
        readers.emplace_back([&cache]() {
            for (int j = 0; j < 1000; ++j) {
                cache.get("key1");
            }
        });
    }
    for (auto& t : readers) t.join();
    std::cout << "Cache size: " << cache.size() << "
";
}

std::condition_variable

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });  // Wait until ready is true
    std::cout << "Worker: data is ready!
";
}

void setter() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();  // Wake up one waiting thread
}

int main() {
    std::thread t1(worker);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::thread t2(setter);

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

Producer-Consumer Pattern

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

template<typename T>
class ThreadSafeQueue {
    std::queue<T> queue_;
    mutable std::mutex mtx_;
    std::condition_variable cv_;

public:
    void push(T value) {
        {
            std::lock_guard lock(mtx_);
            queue_.push(std::move(value));
        }
        cv_.notify_one();
    }

    T pop() {
        std::unique_lock lock(mtx_);
        cv_.wait(lock, [this] { return !queue_.empty(); });
        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }

    bool empty() const {
        std::lock_guard lock(mtx_);
        return queue_.empty();
    }
};

int main() {
    ThreadSafeQueue<int> queue;

    // Producer
    std::thread producer([&queue]() {
        for (int i = 0; i < 10; ++i) {
            queue.push(i);
            std::cout << "Produced: " << i << "
";
        }
    });

    // Consumer
    std::thread consumer([&queue]() {
        for (int i = 0; i < 10; ++i) {
            int val = queue.pop();
            std::cout << "Consumed: " << val << "
";
        }
    });

    producer.join();
    consumer.join();
}

Deadlocks — Detection and Prevention

#include <mutex>
#include <thread>
#include <iostream>

std::mutex mtx_a, mtx_b;

// DEADLOCK: Thread 1 locks A then B, Thread 2 locks B then A
void deadlock_thread1() {
    std::lock_guard lock_a(mtx_a);  // Lock A
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    std::lock_guard lock_b(mtx_b);  // Wait for B (held by thread2)
}

void deadlock_thread2() {
    std::lock_guard lock_b(mtx_b);  // Lock B
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
    std::lock_guard lock_a(mtx_a);  // Wait for A (held by thread1)
    // DEADLOCK: both threads wait forever
}

// FIX 1: Consistent lock ordering (always lock A before B)
// FIX 2: std::scoped_lock (recommended)
void safe_thread1() {
    std::scoped_lock lock(mtx_a, mtx_b);  // Lock both atomically
    std::cout << "Thread 1 got both locks
";
}

void safe_thread2() {
    std::scoped_lock lock(mtx_a, mtx_b);  // Same order doesn't matter
    std::cout << "Thread 2 got both locks
";
}

// FIX 3: Try-lock with timeout
void try_lock_approach() {
    std::unique_lock lock_a(mtx_a, std::defer_lock);
    std::unique_lock lock_b(mtx_b, std::defer_lock);
    std::lock(lock_a, lock_b);  // std::lock avoids deadlock
}

std::atomic for Simple Types

#include <atomic>
#include <thread>
#include <iostream>
#include <vector>

int main() {
    std::atomic<int> counter{0};
    std::atomic<bool> flag{false};

    // Atomic operations — no mutex needed
    counter.fetch_add(1);      // counter += 1
    counter.fetch_sub(1);      // counter -= 1
    counter.store(42);         // counter = 42
    int val = counter.load();  // read counter

    // Compare-and-swap (CAS)
    int expected = 42;
    counter.compare_exchange_strong(expected, 100);
    // If counter == 42, set to 100. Otherwise, expected = counter's value.

    // Atomic flag — simplest atomic type
    std::atomic_flag spinlock = ATOMIC_FLAG_INIT;
    // spinlock.test_and_set();  // Set and return previous value
    // spinlock.clear();         // Reset

    // Use atomics for simple counters and flags
    // Use mutexes for complex data structures
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([&counter]() {
            for (int j = 0; j < 10000; ++j) ++counter;
        });
    }
    for (auto& t : threads) t.join();
    std::cout << "Counter: " << counter << "
";  // 100042
}

Thread-Safe Data Structures

#include <mutex>
#include <vector>
#include <algorithm>

template<typename T>
class ThreadSafeVector {
    std::vector<T> data_;
    mutable std::mutex mtx_;

public:
    void push_back(T value) {
        std::lock_guard lock(mtx_);
        data_.push_back(std::move(value));
    }

    size_t size() const {
        std::lock_guard lock(mtx_);
        return data_.size();
    }

    // Return a copy — safe but potentially expensive
    std::vector<T> snapshot() const {
        std::lock_guard lock(mtx_);
        return data_;
    }

    // Apply function under lock — avoids copying
    template<typename Func>
    void with_lock(Func f) {
        std::lock_guard lock(mtx_);
        f(data_);
    }
};

Common Mistakes

// MISTAKE 1: Locking too much (performance killer)
void over_locked(std::mutex& m, std::vector<int>& v) {
    std::lock_guard lock(m);
    // Don't do expensive work under the lock!
    auto result = expensive_computation();  // Other threads blocked
    v.push_back(result);
}
// Fix: compute outside the lock, only lock for the shared access
void better(std::mutex& m, std::vector<int>& v) {
    auto result = expensive_computation();
    std::lock_guard lock(m);
    v.push_back(result);
}

// MISTAKE 2: Returning reference to protected data
// The reference outlives the lock — caller can use it unsafely

// MISTAKE 3: Recursive locking with std::mutex
// std::mutex::lock() on an already-locked mutex = undefined behavior
// Use std::recursive_mutex if you must lock recursively

Practice Exercises

Exercise 1: Implement a thread-safe stack with push(), pop(), and empty() using std::mutex and std::lock_guard.

Exercise 2: Create a reader-writer cache using std::shared_mutex. Have 5 reader threads and 2 writer threads. Measure throughput compared to using a regular mutex.

Exercise 3: Implement a thread pool that maintains a queue of tasks (functions). Worker threads pull tasks from the queue using a condition variable.

Exercise 4: Write a program that demonstrates and then fixes a deadlock. Use two bank accounts and two threads performing transfers in opposite directions.

Mutexes and synchronization primitives are the foundation of safe multithreaded programming. Combined with threads and the async/future model (next lesson), you have all the tools to write correct concurrent C++.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *