Concurrency Basics

Concurrency Basics

Start threads safely, protect shared data, and avoid common synchronization mistakes.

Concurrency Basics

Launch a thread

std::thread worker([] {
    do_work();
});
worker.join();

This is the bare minimum shape, but in modern code you usually want ownership and shutdown rules to be explicit rather than relying on every exit path calling join() correctly.

Protect data

std::mutex m;
int total = 0;

void add() {
    std::lock_guard<std::mutex> lock(m);
    ++total;
}

The lock protects the invariant around total. If several variables must stay in sync, protect the group with one rule rather than sprinkling locks around individual reads and writes.

Prefer std::jthread for owned worker threads

std::jthread worker([](std::stop_token stop) {
    while (!stop.stop_requested()) {
        poll_once();
    }
});

std::jthread joins on destruction and supports cooperative cancellation through std::stop_token, which makes thread lifetime much easier to reason about.

Producer-consumer sketch

std::mutex guard;
std::condition_variable cv;
std::queue<int> jobs;
bool done = false;

void producer() {
    {
        std::scoped_lock lock(guard);
        jobs.push(42);
    }
    cv.notify_one();
}

void consumer() {
    std::unique_lock lock(guard);
    cv.wait(lock, [&] { return done || !jobs.empty(); });
}

This is the first pattern worth learning after plain mutex locking because it introduces sleeping, wakeup conditions, and coordination around a shared queue.

Larger worked pipeline

std::mutex queue_mutex;
std::condition_variable queue_cv;
std::queue<int> jobs;
bool done = false;

void producer() {
    for (int value = 1; value <= 5; ++value) {
        {
            std::scoped_lock lock(queue_mutex);
            jobs.push(value);
        }
        queue_cv.notify_one();
    }

    {
        std::scoped_lock lock(queue_mutex);
        done = true;
    }
    queue_cv.notify_all();
}

void worker(std::stop_token stop) {
    while (!stop.stop_requested()) {
        std::unique_lock lock(queue_mutex);
        queue_cv.wait(lock, [&] { return done || !jobs.empty(); });

        if (done && jobs.empty()) {
            break;
        }

        int value = jobs.front();
        jobs.pop();
        lock.unlock();

        std::cout << "processed " << (value * 2) << '\n';
    }
}

This example shows the real concurrency workflow: one thread produces work, workers sleep efficiently until notified, and shutdown is explicit instead of accidental.

What to avoid

Also avoid detached threads unless you have a very deliberate lifetime model. Detached work is easy to start and hard to shut down or observe correctly.

Also avoid holding the queue lock while doing the expensive work itself. Lock only long enough to move shared state safely, then release it.

Practical rule

Make correctness obvious first. Parallel speedups matter only after the sequential design is correct.

Condition variables and predicates

Always wait with a predicate so spurious wakeups do not break correctness.

cv.wait(lock, [] { return ready; });

The predicate is the actual rule. notify_one() and notify_all() are only hints that a waiting thread should re-check it.

When atomics are appropriate

Use atomics for small shared states such as counters, flags, and state transitions. Reach for mutexes when you need to protect larger invariants.

Choosing your first design

Practical deadlock habit

If you ever need more than one mutex, define a lock ordering rule early and keep it consistent. Many concurrency bugs come from individually correct locks taken in different orders.

Exercises

Example in practice

#include <thread>

int main() {
    std::jthread worker([] { /* do work */ });
    return 0;
}

Try this variation

Add a stop condition or notification path. Concurrency examples become useful only once shutdown and coordination are explicit.

#include <stop_token>
#include <thread>

int main() {
    std::jthread worker([](std::stop_token token) {
        while (!token.stop_requested()) {
            break;
        }
    });
}