Synchronization Primitives

Synchronization primitives are traffic rules for threads.

Learning Objectives

Explain why synchronization is needed and use mutexes, lock guards and correct critical-section boundaries.

Use binary and counting semaphores, condition variables with predicate loops, monitors, read/write locks and spinlocks — knowing when each fits.

Understand recursive mutexes (and why they are discouraged), timed locks, barriers, latches, events (manual/auto reset), once-only and thread-safe initialization.

Reason about lock ownership, granularity, contention, ordering and fairness; choose a primitive; avoid common mistakes.

Plain-Language Explanation

Synchronization primitives are traffic rules for threads. A mutex is a single-occupancy room key: whoever holds it may enter; everyone else waits. A semaphore is a car park with N spaces. A condition variable is a waiting room with a bell: threads sleep there until someone rings because the situation they are waiting for may have changed. A barrier is a meeting point where everybody waits until the whole group has arrived.

Precise Technical Explanation

Why synchronization is needed To make compound operations atomic (mutual exclusion), to make writes visible and ordered between threads (memory synchronization), and to wait efficiently for conditions (coordination).

A mutex (mutual-exclusion lock) has lock and unlock. At most one thread holds it; others block (sleep) until it is released. Unlocking also *publishes* all writes made while holding the lock to the next thread that locks it. Examples: pthread_mutex_t, std::mutex, threading.Lock, ReentrantLock/synchronized, C# lock, sync.Mutex, Rust Mutex<T>.

Locks and lock guards Lock guards tie unlocking to scope: std::lock_guard/std::unique_lock/std::scoped_lock, Python with lock:, C# lock (obj) {}, Java synchronized blocks or try { … } finally { lock.unlock(); }, Go mu.Lock(); defer mu.Unlock(), Rust's MutexGuard. They guarantee release on every exit path, including exceptions.

The lock must cover *every* access that participates in the invariant — reads too — and nothing more than necessary. Calling slow or unknown code (I/O, callbacks) while holding a lock invites contention and deadlock.

A semaphore holds a counter: acquire/wait/P decrements it, blocking at zero; release/post/V increments it. Unlike a mutex, a semaphore has no owner — any thread may release it.

Binary versus counting semaphores A binary semaphore (0/1) can signal events between threads; a counting semaphore limits concurrent access to N resources (connection slots, rate of parallel downloads). Using a binary semaphore as a mutex loses ownership checks and priority inheritance.

A condition variable lets a thread atomically release a mutex and sleep until notified, then reacquire the mutex. Operations: wait(lock), notify_one/signal, notify_all/broadcast.

Waiting in a predicate loop Always wait in a loop that re-checks the condition: while (!ready) cv.wait(lock); or cv.wait(lock, [&]{ return ready; }). Reasons: spurious wake-ups are allowed, another thread may have consumed the condition first, and notify_all wakes threads whose condition is still false.

A monitor bundles data, a mutex and condition variables into one object whose methods run under the lock — Java's synchronized methods with wait/notify, C#'s Monitor, Python threading.Condition objects used as a monitor.

Read/write locks Many readers *or* one writer: pthread_rwlock_t, std::shared_mutex, ReentrantReadWriteLock, ReaderWriterLockSlim, sync.RWMutex, Rust RwLock. Beneficial only when reads dominate and hold the lock long enough to outweigh the extra bookkeeping; writer starvation policies vary.

A spinlock busy-waits in a loop instead of sleeping. Good only for very short critical sections on multicore systems when the holder cannot be preempted (kernels, interrupt-disabled code); in user space a preempted holder makes spinners waste whole time slices. Most user-space mutexes spin briefly then sleep (adaptive).

Recursive mutexes and why they are often discouraged A recursive (reentrant) mutex may be locked again by the thread that holds it (std::recursive_mutex, Java synchronized, threading.RLock, C# lock). They hide design problems: a method that calls back into the object while its invariants are broken can observe inconsistent state. Prefer splitting public (locking) and private (already-locked) methods.

try_lock_for, pthread_mutex_timedlock, lock.acquire(timeout=…), tryLock(timeout), Monitor.TryEnter(obj, timeout): give up after a deadline instead of waiting forever — useful for deadlock recovery and responsiveness, but timeouts must be handled.

A barrier blocks each of N threads until all N arrive, then releases them together, typically reusable for phases (pthread_barrier_t, C++20 std::barrier, threading.Barrier, CyclicBarrier, Barrier in .NET).

A latch is a one-shot countdown: threads wait until the count reaches zero (C++20 std::latch, Java CountDownLatch, .NET CountdownEvent, Go sync.WaitGroup is similar).

Events and manual-reset/auto-reset event concepts An event is a boolean flag threads can wait on. A manual-reset event stays set until reset, releasing every waiter (threading.Event,.NET ManualResetEventSlim); an auto-reset event releases one waiter and resets itself (.NET AutoResetEvent, Win32 auto-reset events).

Run initialization exactly once, even if many threads race to trigger it: pthread_once, std::call_once, C++11 thread-safe function-local statics ("magic statics"), Java's class-initialization holder idiom, .NET Lazy<T>, Go sync.Once, Rust OnceLock.

Publishing a lazily created object requires both "only once" and visibility — the reason double-checked locking without proper atomics is broken (Module 7).

Mutexes are owned: only the locking thread may unlock (error-checking mutexes enforce it; std::mutex makes violation UB). Ownership enables priority inheritance and deadlock detection; semaphores have no owner.

Coarse-grained (one lock for a whole structure) is simple and safe but limits parallelism; fine-grained (a lock per bucket or node) scales better but is harder to get right and invites deadlocks. Lock striping (N locks for N partitions) is the common middle ground.

When many threads want the same lock they queue, context-switch and bounce the lock's cache line between cores. Symptoms: high CPU in kernel futex calls, low throughput as threads are added. Reduce by shorter critical sections, finer granularity, per-thread data, or lock-free structures.

If code ever holds two locks at once, every thread must acquire them in the same global order, or deadlock is possible (Module 5).

Fairness guarantees and non-guarantees Most mutexes are *not* fair: a thread releasing and immediately re-acquiring ("barging") often wins, and waiting threads can starve. Fair locks (FIFO hand-off, new ReentrantLock(true)) avoid starvation at a significant throughput cost. Never assume waiting order.

Choosing a primitive Protect shared data Wait for a state change condition variable (with the mutex that protects the state) Limit concurrent users of N resources Wait until N threads reach a point barrier (repeating) / latch (once) Signal "something happened" event, or a queue/channel Mostly reads, rare writes, long reads read/write lock — measure first once/lazy facility Simple counter or flag atomic (Module 6) Common synchronization mistakes

Forgetting to unlock on an error path; locking too little (only writes); locking too much (I/O under a lock); inconsistent lock order; waiting on a condition variable without a predicate loop or without holding the mutex; notifying before changing the state; using different locks for the same data; returning references to protected data that escape the lock.

Why It Matters in Real Systems

Locks are the most widely used synchronization tool; correct use prevents races, wrong use causes deadlocks and latency spikes. Database engines, kernels, allocators, caches and connection pools are all built on these primitives.

Key Terms

Mutual-exclusion lock with an owner Scope-bound acquisition and release Counter with blocking acquire; no owner

Wait for a condition, releasing a mutex while asleep Return from wait without a notification

Object combining data, a lock and conditions Read/write lock Shared readers or exclusive writer Barrier / latch

Reusable rendezvous / one-shot countdown Threads competing for the same lock

Syntax and API Overview

The specification's C++ mutex example (G++ 11.2):

And the same in Python (CPython 3.13.1):

Both print 40000 on every run: every read-modify-write happens inside the critical section. std::lock_guard and with lock: release the mutex at the end of the scope, including when an exception is thrown. Locking once per increment is correct but slower than locking once per batch — measured below.

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

std::mutex m;
int counter = 0;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard<std::mutex> lock(m);
        ++counter;
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(increment);
    }
    for (auto& t : threads) t.join();

    std::cout << counter << '\n'; // 40000
}
from threading import Lock, Thread

lock = Lock()
counter = 0

def increment_many():
    global counter
    for _ in range(10_000):
        with lock:
            counter += 1

threads = [Thread(target=increment_many) for _ in range(4)]
for thread in threads:
    thread.start()
for thread in threads:
    thread.join()

print(counter)  # 40000

Complete Code Example

Condition variable with a predicate loop, counting semaphore, read/write lock, barrier, latch, call_once and timed lock — one C++20 program (G++ 11.2):

#include <barrier>
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <latch>
#include <mutex>
#include <queue>
#include <semaphore>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <atomic>

using namespace std::chrono_literals;

int main() {
    // 1. condition variable: wait for a state change in a predicate loop
    std::mutex m;
    std::condition_variable cv;
    std::queue<int> jobs;
    bool done = false;
    int consumed = 0;
    std::thread consumer([&] {
        std::unique_lock lock(m);
        for (;;) {
            cv.wait(lock, [&] { return !jobs.empty() || done; });   // re-checks after every wake-up
            while (!jobs.empty()) { consumed += jobs.front(); jobs.pop(); }
            if (done) break;
        }
    });
    for (int i = 1; i <= 100; ++i) {
        { std::lock_guard lock(m); jobs.push(i); }               // change state under the lock...
        cv.notify_one();                                         // ...then notify
    }
    { std::lock_guard lock(m); done = true; }
    cv.notify_one();
    consumer.join();
    std::cout << "condition variable: consumed sum " << consumed << " (expected 5050)\n";

   
…
condition variable: consumed sum 5050 (expected 5050)
semaphore(2): peak concurrent workers = 2
shared_mutex: 4 reads saw a valid config, final config 2
barrier: all 3 threads finished a phase
barrier: all 3 threads finished a phase
call_once from 8 threads: initializer ran 1 time(s)
try_lock_for(30ms) while another thread holds it: timed out

Line-by-Line Explanation

The consumer waits with a predicate; producers change jobs under the lock, then notify. Because of the predicate loop, a notification sent before the consumer starts waiting is never lost — the consumer checks the state first.

std::counting_semaphore<2> lets two workers in at a time; the peak counter (updated with a CAS loop) confirms it never exceeded 2.

Readers take shared_lock, the writer unique_lock; each reader sees either the old or the new value, never a torn one.

The barrier's completion function runs once per phase, when all three threads arrive — so it prints twice; the latch lets main wait until all three have started.

call_once runs the initializer exactly once despite eight racing threads.

try_lock_for returns false after 30 ms because main holds the mutex.

Output

Shown above. Every line is deterministic: each value is computed under synchronization and printed after the relevant join.

Interleaving or State Trace

Condition-variable hand-off for one item:

locks m, predicate false → wait releases m and sleeps locks m, pushes 1, unlocks wakes, reacquires m, predicate true pops 1, loops, predicate false → waits

Correctness and Safety Analysis

Every piece of shared state has exactly one protecting mechanism: jobs/done/consumed → m; config → rw; reads → reads_m; inside/peak → atomics; inits → the once flag; got → written by one thread and read after join. The general rule: *for each shared variable, name the one lock (or atomic) that protects it.*

Common Mistakes and Misconceptions

if (!ready) cv.wait(lock); instead of a loop.

Changing the predicate state without holding the mutex (the notification can be lost).

Assuming notify_one wakes a specific or the longest-waiting thread.

Using std::recursive_mutex to paper over re-entrant calls.

Holding a lock while doing network I/O.

Failure Modes and Debugging Steps

Lost wake-up (thread sleeps forever): check that the state change happens under the mutex and the wait uses a predicate.

Throughput collapses with more threads: lock contention — profile (perf lock, JFR "Java Monitor Blocked", dotnet-counters lock contention) and shrink the critical section.

Deadlock: Module 5.

Lock granularity measured — one lock per increment versus one lock per batch of 1,000 (G++ 11.2 -O1, 4 threads, one real run):

Both are correct (8,000,000); locking per increment pays the acquire/release and contention cost eight million times instead of eight thousand.

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

int main() {
    constexpr int threads = 4, per_thread = 2'000'000;
    std::mutex m;
    long counter = 0;
    auto run = [&](int batch) {
        counter = 0;
        auto t0 = std::chrono::steady_clock::now();
        std::vector<std::thread> ts;
        for (int t = 0; t < threads; ++t)
            ts.emplace_back([&] {
                for (int i = 0; i < per_thread; i += batch) {
                    std::lock_guard g(m);
                    for (int k = 0; k < batch; ++k) ++counter;   // batch increments per acquisition
                }
            });
        for (auto& t : ts) t.join();
        auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
        std::cout << "lock per " << batch << " increment(s): " << counter << " in " << ms << " ms\n";
    };
    run(1);
    run(1000);
}
lock per 1 increment(s): 8000000 in 522.041 ms
lock per 1000 increment(s): 8000000 in 23.3079 ms

Performance and Scalability Implications

An uncontended mutex costs tens of nanoseconds; a contended one costs microseconds (sleep and wake-up). Keep critical sections short, do expensive work outside them, batch updates, and partition data so threads rarely need the same lock.

Security Considerations

Locks around security checks prevent TOCTOU within a process, but locking around untrusted callbacks or slow external operations hands attackers a way to stall everyone (denial of service). Timeouts on locks in request paths limit the damage.

Platform and Runtime Caveats

C++20 <semaphore>, <latch>, <barrier> need GCC 11+, Clang 14+ (libc++ partial), MSVC 19.28+.

std::mutex is non-recursive; locking it twice from one thread is undefined behavior (often a deadlock). Python's Lock deadlocks; RLock is re-entrant.

Java synchronized and C# lock are re-entrant.

POSIX condition variables need a mutex; Windows CONDITION_VARIABLE pairs with CRITICAL_SECTION or SRWLOCK.

Real-World Applications

Connection pools (semaphores), job queues (mutex + condition variables), configuration reloads (read/write locks or atomic pointer swaps), parallel algorithms with phases (barriers), lazy singletons (once).

Best Practices

Use RAII/with/using/defer for every lock.

Document which lock protects which data.

Wait in predicate loops; change state under the mutex, then notify.

Keep critical sections small and free of I/O and callbacks.

Prefer higher-level tools (queues, channels, concurrent collections) when they fit.

Exercises

E1 · Beginner. Why must the consumer in the example check !jobs.empty() || done rather than just !jobs.empty()?

Answer: Without done, a consumer waiting on an empty queue after the producer has finished would sleep forever — the shutdown signal must be part of the predicate.

E2 · Intermediate. Replace the semaphore with a mutex and condition variable that implement the same "at most 2 inside" rule.

Answer: Keep int inside under a mutex; acquire: cv.wait(lock, [&]{ return inside < 2; }); ++inside; release: --inside; cv.notify_one(); (lock held while changing inside).

E3 · Advanced challenge. A read/write lock made a cache *slower* than a plain mutex. Give two reasons.

Answer: Readers still write the lock's internal reader count — a shared cache line that bounces between cores, so short read sections gain nothing; and rwlock bookkeeping (and writer-preference policies) costs more than a plain mutex. Read/write locks pay off only for long, read-dominated critical sections.

Quiz

? Why must condition-variable waits be in a loop?
+ Spurious wake-ups happen and another thread may consume the condition first. :: Re-check the predicate after every wake-up.
- The loop makes the wait faster. :: It is about correctness.
- notify_one requires it. :: The requirement comes from wake-up semantics.
- Mutexes are not re-entrant. :: Unrelated.

? A counting semaphore initialised to 3 is best used to...
+ allow at most three threads to use a resource at once :: It counts available permits.
- protect a shared counter :: A mutex or an atomic is the right tool.
- run code exactly once :: Use call_once / sync.Once.
- wait until three threads reach a point :: That is a barrier or latch.

? Which are true of most standard mutexes? (select all that apply)
+ Releasing a mutex publishes the writes made while holding it to the next locker. :: Lock release/acquire synchronize.
- Waiting threads acquire the lock in FIFO order. :: Most mutexes are not fair.
+ Only the owning thread may unlock it. :: Mutexes have owners; semaphores do not.
- They prevent deadlock automatically. :: Lock ordering is the programmer's job.

Summary

Mutexes make compound operations atomic and publish writes; guards make release automatic. Condition variables coordinate state changes and must be used with a predicate loop under their mutex. Semaphores count permits; barriers and latches synchronise phases; once facilities initialise safely; read/write locks and spinlocks are specialised tools to use only when measured. Granularity, contention, ordering and fairness decide whether a correct design is also a fast one.

Further Reading

Allen Downey, *The Little Book of Semaphores* (free).

cppreference: <mutex>, <condition_variable>, <semaphore>, <latch>, <barrier>, <shared_mutex>.

Butler Lampson & David Redell, "Experience with Processes and Monitors in Mesa" (1980).

Java java.util.concurrent.locks package documentation.