Skip to main content

Thread Synchronization Primitives

Adapted from: Jason Gregory, Game Engine Architecture, 3rd ed. (CRC Press, 2018), Chapter 4, Section 4.6 — rewritten and condensed for teaching.

Introduction to Concurrent Programming established that eliminating data races means making critical operations atomic, and that a mutex is the standard tool for doing so. Every OS that supports concurrency ships a toolkit of thread synchronization primitives that provide two services: making operations atomic, and letting threads coordinate by sleeping until woken. This lesson works through the standard primitives — mutexes, critical sections, condition variables, and semaphores — and what each one is actually for.

These tools aren't free

Every primitive covered here is ultimately backed by the kernel, so using one typically means a kernel call — a context switch into protected mode that can cost on the order of 1,000 clock cycles. That cost is exactly why some programmers reach for hand-rolled atomics or lock-free techniques instead once profiling says it matters — but a solid grasp of these primitives is the prerequisite for knowing when that trade-off is worth making.


Mutexes​

A mutex ("mutual exclusion") is an OS object with exactly two states — unlocked and locked (also called released/acquired, or signaled/nonsignaled). Its defining guarantee: only one thread can hold the lock at any instant. Wrap every critical operation on a shared object in the same mutex's lock/unlock, and those operations become mutually exclusive — atomic with respect to each other.

A typical mutex API looks like this:

FunctionPurpose
create() / init()Create the mutex
destroy()Destroy the mutex
lock() / acquire()Blocking. Locks the mutex; puts the calling thread to sleep if another thread already holds it
try_lock() / try_acquire()Non-blocking. Attempts to lock; returns immediately either way
unlock() / release()Releases the lock — usually, only the thread that locked it may unlock it

When a mutex is released, if any threads are asleep waiting on it, the kernel wakes exactly one of them up. Here's the same atomic-increment example implemented three ways:

// POSIX threads
#include <pthread.h>
int g_count = 0;
pthread_mutex_t g_mutex;

inline void IncrementCount() {
pthread_mutex_lock(&g_mutex);
++g_count;
pthread_mutex_unlock(&g_mutex);
}
// C++11 standard library — std::mutex wraps the same kernel object,
// but its constructor/destructor handle init/destroy for you.
#include <mutex>
int g_count = 0;
std::mutex g_mutex;

inline void IncrementCount() {
g_mutex.lock();
++g_count;
g_mutex.unlock();
}
// Windows — a mutex is an opaque kernel object referenced by a HANDLE.
// "Locking" means waiting for it to become signaled.
#include <windows.h>
int g_count = 0;
HANDLE g_hMutex;

inline void IncrementCount() {
if (WaitForSingleObject(g_hMutex, INFINITE) == WAIT_OBJECT_0) {
++g_count;
ReleaseMutex(g_hMutex);
}
}

(All three omit the surrounding init()/destroy() calls a real program would need around thread creation/teardown, for clarity.)

Prefer std::mutex with RAII in real code

g_mutex.lock(); ... g_mutex.unlock(); has the same problem every manual resource pair does: an early return or a thrown exception between them skips the unlock() and leaves the mutex held forever. Idiomatic C++ wraps the lock in an RAII guard instead — std::lock_guard<std::mutex> lk(g_mutex); — so the unlock happens automatically when lk goes out of scope, on every exit path. (See RAII in Fundamentals of Software Engineering for Games for the general pattern this is built on.)

Critical Sections: A Cheaper Mutex​

A real mutex can be shared across processes, which means the kernel has to manage it — every lock/unlock is a kernel call, and that cost is paid even when no other thread is contending for the lock at all.

Windows offers a cheaper alternative for the common case of locking within one process: the critical section. Functionally it's just a low-cost mutex, with its own API naming:

FunctionPurpose
InitializeCriticalSection()Construct
DeleteCriticalSection()Destroy
EnterCriticalSection()Blocking lock (busy-waits briefly, then sleeps if held too long)
TryEnterCriticalSection()Non-blocking lock attempt
LeaveCriticalSection()Unlock
#include <windows.h>
int g_count = 0;
CRITICAL_SECTION g_critsec;

inline void IncrementCount() {
EnterCriticalSection(&g_critsec);
++g_count;
LeaveCriticalSection(&g_critsec);
}

The trick behind the lower cost: when a thread first hits a locked critical section, it spin-locks — busy-waits in a tight loop, no kernel call involved — for a short while, hoping the other thread releases it quickly. Only if the wait drags on does the thread actually go to sleep the expensive way, as it would with a full mutex. This works precisely because a critical section can't cross a process boundary, unlike a real mutex. Linux has a broadly similar concept called a futex ("fast userspace mutex") for the same reason.

Check: mutexes and critical sections​

Quiz

Mutexes and critical sections

1 / 3

What is the defining guarantee a mutex provides?

Pick an answer to see if you are right.


Condition Variables​

Consider the classic producer-consumer problem: a producer thread generates data, a consumer thread reads and uses it, and the consumer obviously can't consume before the producer has produced. The naive fix — a shared bool g_ready flag that the consumer polls in a loop — technically works, but wastes CPU cycles busy-waiting, spinning the consumer thread in a tight loop just to repeatedly check a flag:

// Naive polling approach — wastes CPU spinning on g_ready.
while (true) {
pthread_mutex_lock(&g_mutex);
const bool ready = g_ready;
pthread_mutex_unlock(&g_mutex);
if (ready) break;
}

A condition variable (CV) solves this properly. Despite the name, it isn't a variable holding a condition — it's really a queue of sleeping threads, plus a mechanism letting a running thread wake some of them up on demand. Its API:

FunctionPurpose
create() / init()Create the CV
destroy()Destroy the CV
wait()Blocking. Puts the calling thread to sleep on this CV
notify() / signal()Non-blocking. Wakes a thread currently asleep on this CV
Queue g_queue;
pthread_mutex_t g_mutex;
bool g_ready = false;
pthread_cond_t g_cv;

void* ProducerThreadCV(void*) {
while (true) {
pthread_mutex_lock(&g_mutex);
ProduceDataInto(&g_queue);
g_ready = true;
pthread_cond_signal(&g_cv); // wake the consumer
pthread_mutex_unlock(&g_mutex);
}
return nullptr;
}

void* ConsumerThreadCV(void*) {
while (true) {
pthread_mutex_lock(&g_mutex);
while (!g_ready) {
// sleep; the kernel releases g_mutex while we're asleep
// and re-acquires it for us automatically before we wake
pthread_cond_wait(&g_cv, &g_mutex);
}
ConsumeDataFrom(&g_queue);
g_ready = false;
pthread_mutex_unlock(&g_mutex);
}
return nullptr;
}

Two details are easy to miss but matter a lot:

  1. The consumer appears to sleep while holding a lock, which would normally be a fast route to deadlock — but the kernel quietly unlocks g_mutex the instant the thread is safely asleep, and re-locks it on the thread's behalf right before pthread_cond_wait() returns. This dance is exactly what makes condition variables safe to use this way.
  2. The while (!g_ready) loop is still necessary even though we're using a CV to wait for exactly that condition, because a thread can occasionally wake from pthread_cond_wait() spuriously — without anyone actually calling signal(). The loop re-checks the real condition every time control returns, rather than trusting that a wake-up means the condition is actually true.

Check: condition variables​

Quiz

Condition variables and busy-waiting

1 / 3

What specific problem does a condition variable solve, compared to a consumer thread that repeatedly polls a shared boolean flag in a loop?

Pick an answer to see if you are right.


Semaphores​

Where a mutex behaves like an atomic boolean flag, a semaphore behaves like an atomic counter that's never allowed to go negative — effectively a mutex generalized to let up to N threads hold it simultaneously. That makes it a natural fit for guarding access to a fixed-size pool of resources: say a rendering system with exactly four offscreen buffers available for HUD/menu drawing — a semaphore initialized to 4 ensures no more than four threads ever use the pool at once.

FunctionPurpose
init(n)Create the semaphore with initial count n
destroy()Destroy the semaphore
take() / wait()If count > 0, decrement and return immediately; if count == 0, sleep until it rises above zero
give() / post() / signal()Increment the count by one, waking a sleeping waiter if there is one

A semaphore is signaled when its count is above zero, nonsignaled at zero — which is exactly why some APIs name the take/give operations wait()/signal().

Mutex vs. Binary Semaphore​

A semaphore initialized to 1 — a binary semaphore — looks superficially identical to a mutex: both let exactly one thread through at a time. But there's a crucial behavioral difference: a mutex can only be unlocked by the thread that locked it; a semaphore's count has no such ownership rule — one thread can take() it and a completely different thread can give() it back. That single distinction drives very different use cases: a mutex makes an operation atomic; a binary semaphore signals between threads.

That signaling role makes semaphores a clean fit for producer-consumer, using two semaphores to track "used" and "free" slots in a single-item buffer:

Queue g_queue;
sem_t g_semUsed; // initialized to 0
sem_t g_semFree; // initialized to 1

void* ProducerThreadSem(void*) {
while (true) {
Item item = ProduceItem(); // local work, no locking needed
sem_wait(&g_semFree); // wait until there's room
AddItemToQueue(&g_queue, item);
sem_post(&g_semUsed); // tell the consumer data is ready
}
return nullptr;
}

void* ConsumerThreadSem(void*) {
while (true) {
sem_wait(&g_semUsed); // wait for data to be ready
Item item = RemoveItemFromQueue(&g_queue);
sem_post(&g_semFree); // tell the producer there's room again
ConsumeItem(item); // local work, no locking needed
}
return nullptr;
}

A Semaphore Is Really Built From a Mutex + a CV​

Semaphores are a genuinely higher-level construct than mutexes or condition variables — in fact, you can build one entirely out of the other two, plus a plain integer:

class Semaphore {
private:
int m_count;
pthread_mutex_t m_mutex;
pthread_cond_t m_cv;
public:
explicit Semaphore(int initialCount) : m_count(initialCount) {
pthread_mutex_init(&m_mutex, nullptr);
pthread_cond_init(&m_cv, nullptr);
}

void Take() {
pthread_mutex_lock(&m_mutex);
while (m_count == 0) {
pthread_cond_wait(&m_cv, &m_mutex); // sleep until count > 0
}
--m_count;
pthread_mutex_unlock(&m_mutex);
}

void Give() {
pthread_mutex_lock(&m_mutex);
++m_count;
if (m_count == 1) {
pthread_cond_signal(&m_cv); // wake one waiter, if any
}
pthread_mutex_unlock(&m_mutex);
}
};

Seeing the implementation makes the mutex/CV relationship concrete: Take() is just "wait (via a CV) until the shared counter (guarded by a mutex) is nonzero, then decrement it," and Give() is "increment the counter, and wake someone up if they might have been waiting."

Windows Events​

Windows offers one more option: an event object, similar in spirit to a condition variable but noticeably simpler to use directly — a thread sleeps via WaitForSingleObject() and another thread wakes it with SetEvent(), no explicit associated mutex required at the API level. Rewriting the producer-consumer example with two events (g_hFree initially signaled, g_hUsed initially not) produces code that reads almost identically to the semaphore version above, just swapping sem_wait/sem_post for WaitForSingleObject/SetEvent.

Check: semaphores​

Quiz

Semaphores vs. mutexes

1 / 3

What is the key behavioral difference between a binary semaphore and a mutex, even though both only allow one "holder" at a time?

Pick an answer to see if you are right.


Practice: build a spinlock from std::atomic​

The sandbox here can't spin up real OS threads to demonstrate contention live — but you can still build and functionally verify a real, minimal spinlock (the same style of low-cost lock underlying Windows critical sections and Linux futexes), using std::atomic<bool> and a compare-and-swap loop. Implement TryLock() so it atomically checks whether the lock is free and claims it in one indivisible step, and Unlock() so it releases the lock.

Why this matters even without real threads

compare_exchange_strong is a genuine atomic hardware instruction (compare-and-swap) — the same primitive real mutex and spin lock implementations are built on. The exercise above is single-threaded, but the object you built would behave identically if two real threads called TryLock() at the same instant: the hardware guarantees exactly one of them observes expected == false and wins.


Key takeaways
  • A mutex guarantees exclusive, one-at-a-time access and can only be unlocked by whoever locked it — use it to make an operation atomic. Prefer RAII wrappers (std::lock_guard) over bare lock/unlock in real code.
  • A Windows critical section (or Linux futex) is a cheaper, process-local mutex that spin-waits briefly before falling back to a real kernel sleep.
  • A condition variable lets a thread sleep until explicitly woken, instead of busy-waiting on a flag — but always re-check the real condition in a loop, because spurious wakeups are real.
  • A semaphore is an atomic counter (never negative) that generalizes a mutex to N simultaneous holders, and — unlike a mutex — can be given by a different thread than the one that took it, making it a natural signaling tool. It can be built entirely from a mutex + a condition variable.
  • Every one of these primitives is a kernel-backed tool with real cost; a hand-rolled spinlock built on std::atomic compare-and-swap shows the same idea implemented directly on a hardware atomic instruction.

Further Reading​