Concurrency and Thread Safety

Learning Objectives

Identify shared mutable state and data races.

Apply confinement, immutability, locks and atomics.

Avoid deadlocks, livelocks and starvation.

Build a producer–consumer queue and reason about the C++ memory model and other runtimes.

Prerequisites: Modules 13, 59.

Intuition and Definition

A data race occurs when two threads access the same memory concurrently, at least one writes, and there's no synchronization — undefined behaviour in C++. Strategies, from best to most error-prone:

Don't share — thread confinement (each object owned by one thread), message passing.

Share immutable data — no synchronization needed.

Synchronize mutable sharing — mutexes (std::mutex, lock_guard, scoped_lock), condition variables, atomics.

Hazards: deadlock (threads wait for each other's locks forever), livelock (threads keep reacting to each other without progress), starvation (a thread never gets the resource), reentrancy bugs (a callback re-enters an object while it holds a lock).

Memory model with atomics and orderings; std::thread, std::jthread, mutexes

synchronized/lock, volatile for visibility, concurrent collections

The GIL serializes bytecode in CPython (free-threaded builds exist since 3.13), but compound operations still race; use threading.Lock, queue.Queue

Single-threaded event loop; Web Workers/worker threads communicate by messages; SharedArrayBuffer + Atomics for shared memory

Why It Matters

Concurrency bugs are intermittent, platform-dependent and often appear only under load. Object design — who owns which state, which objects are immutable — is the main defence.

Syntax and Language Notes

std::scoped_lock lock(mA, mB);         // locks several mutexes without deadlock (std::lock algorithm)
std::atomic<int> hits{0}; hits.fetch_add(1, std::memory_order_relaxed);

Complete Example

A thread-safe bounded producer–consumer queue, a lock-protected counter vs an atomic one, and deadlock-free locking of two accounts:

#include <atomic>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <optional>
#include <queue>
#include <thread>
#include <vector>

template <class T>
class BoundedQueue {                                   // monitor object: all access under one mutex
    std::queue<T> q_;
    std::mutex m_;
    std::condition_variable notFull_, notEmpty_;
    const size_t cap_;
    bool closed_ = false;
public:
    explicit BoundedQueue(size_t cap) : cap_(cap) {}
    void push(T v) {
        std::unique_lock lock(m_);
        notFull_.wait(lock, [&] { return q_.size() < cap_; });   // back-pressure
        q_.push(std::move(v));
        notEmpty_.notify_one();
    }
    std::optional<T> pop() {
        std::unique_lock lock(m_);
        notEmpty_.wait(lock, [&] { return !q_.empty() || closed_; });
        if (q_.empty()) return std::nullopt;                      // closed and drained
        T v = std::move(q_.front()); q_.pop();
        notFull_.notify_one();
        return v;
    }
    void close() { std::lock_guard lock(m_); closed_ = true; notEmpty_.notify_all(); }
};

struct Account { std::mutex m; long long cents; };
void transfer(Account& from, Account& to, long l
…
consumed checksum 500500 (expected 500500)
mutex counter 80000, atomic counter 80000 (expected 80000)
a=80000 b=120000 total=200000

Step-by-Step Walkthrough

BoundedQueue is a monitor: every operation takes the mutex; condition variables block producers when full (back-pressure) and consumers when empty. close() lets consumers exit once drained.

Three consumers share the work; the checksum equals 1+…+1000 = 500500 regardless of scheduling.

A plain int incremented from 8 threads without synchronization would be a data race (lost updates, undefined behaviour); the mutex and atomic versions both give exactly 80000.

transfer(a, b) and transfer(b, a) lock the same two mutexes in opposite orders — the classic deadlock. std::scoped_lock acquires both with a deadlock-avoidance algorithm.

The total stays 200000 (5000×7 moved one way, 5000×3 the other): invariants hold under concurrency.

Common Mistakes

"It's just a read" — a read concurrent with a write is still a data race.

Locking inside getters but not across compound operations (check-then-act: if (!map.contains(k)) map.insert(k) across two calls).

Calling unknown code (callbacks, virtuals) while holding a lock — deadlocks and reentrancy bugs.

Thread-safe singleton creation ≠ thread-safe singleton use (Module 27).

Relying on the Python GIL for compound operations (counter += 1 is not atomic).

Overusing memory_order_relaxed without understanding — fine for counters, wrong for publishing data.

Trade-offs and Alternatives

Actor model (each actor owns its state and processes messages one at a time — Akka, Orleans, Erlang), immutable message passing, thread confinement (UI threads), and lock-free structures (hard; use proven libraries). Document each class's thread-safety guarantee: *not thread-safe*, *thread-compatible* (const methods safe concurrently), or *thread-safe*.

Real-World Use

Logging back ends with background flush threads, telemetry pipelines (producer–consumer), connection pools, game engines' job systems, AUTOSAR OS tasks with protected resources (priority ceiling), web servers.

Practice

Q1. (Easy) What is a data race?

Answer: Concurrent accesses to the same memory, at least one a write, without synchronization.

Q2. (Medium) Why does wait take a predicate?

Answer: Condition variables can wake spuriously, and the condition may change before the woken thread runs; the predicate is rechecked under the lock.

Q3. (Advanced) How else can you avoid the transfer deadlock without std::scoped_lock?

Answer: Impose a global lock order (e.g. always lock the account with the lower id/address first), or use std::lock(a.m, b.m) followed by adopt_lock guards, or redesign so each account is owned by one thread/actor and transfers are messages.

Interview Question

Q1. "Design a thread-safe cache class. What do you lock, and what can go wrong?"

Answer: Protect the map with a mutex (or a shared_mutex for read-heavy loads) and make every compound operation (get-or-compute) atomic with respect to the map; avoid holding the lock while computing an expensive value (compute outside, then insert-if-absent, or store a future so concurrent callers wait for one computation); never call user callbacks under the lock; bound the size with an eviction policy; return values (or immutable shared pointers), not references into the map that could dangle after eviction. Pitfalls: check-then-act races, deadlocks with nested locks, thundering herd on misses, and contention on a single lock (shard the map).

Quick Quiz

Q1. Which C++ facility locks several mutexes deadlock-free?

Answer: std::scoped_lock (or std::lock).

Q2. Is counter += 1 atomic in CPython?

Answer: No — it's several bytecodes; another thread can interleave.

Q3. What is back-pressure in a bounded queue?

Answer: Producers block (or fail) when the queue is full, slowing them to the consumers' pace.

Summary

Prefer not sharing, then sharing immutable data, then synchronized sharing.

Use monitors, condition variables, atomics and deadlock-free locking; document thread-safety guarantees.

Related: Modules 13, 27, 59, 61.