Skip to content

Expert · Exceptions, concurrency and building projects with CMake · topic 35 of 42 · 35 min

Concurrency 1: threads, mutexes and data races

Explanation

A thread is a separate path of execution that runs at the same time as others, using more CPU cores. std::thread (and C++20 std::jthread) starts one. When threads share data and at least one writes, you must protect it with a std::mutex (via std::lock_guard or std::scoped_lock); otherwise you have a data race, which is undefined behaviour.

Why concurrency?

Modern computers and phones have several CPU cores. A normal program uses one. Concurrency means running several tasks at overlapping times, for example resizing 1,000 photos on 8 cores, or keeping an app's screen responsive while a download runs. In C++, the basic tool is a thread: an independent sequence of instructions inside the same program, sharing the same memory.

cpp
#include <iostream>
#include <thread>

void download(int fileId) {
    std::cout << "downloading " << fileId << '\n';
}

int main() {
    std::thread t1(download, 1);
    std::thread t2(download, 2);
    t1.join();                    // wait for t1 to finish
    t2.join();
    std::cout << "all done\n";
}
The two downloads may print in either order, or even with their lines mixed, because the threads run independently. Compile with g++ -std=c++20 -pthread on Linux.
Careful: Every std::thread must be joined (wait for it) or detached before it is destroyed, otherwise the program calls std::terminate. C++20 std::jthread joins automatically in its destructor (RAII), so prefer it when your compiler supports it.

Data races

cpp
int counter = 0;
void addMany() { for (int i = 0; i < 100000; ++i) ++counter; }

std::thread a(addMany), b(addMany);
a.join(); b.join();
std::cout << counter << '\n';    // often LESS than 200000, and different each run
++counter is really three steps: read, add, write. Two threads can read the same old value and both write back value + 1, losing an update.

A data race happens when two threads access the same memory at the same time, at least one of them writes, and nothing orders the accesses. In C++, a data race is undefined behaviour: wrong totals, crashes, or bugs that appear only on some machines and only sometimes. These are among the hardest bugs to find.

Think of it like this: Two cashiers updating the same paper ledger without talking: both read "balance 1000", both add their sale of 100, and both write "1100". One sale has vanished. A mutex is the single pen for the ledger: whoever holds the pen writes, and the other waits.

Protecting shared data with a mutex

cpp
#include <mutex>
#include <thread>

int counter = 0;
std::mutex counterMutex;

void addMany() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counterMutex);   // locks here
        ++counter;
    }                                                     // unlocks here (RAII)
}
// Now counter is always 200000.
A mutex (mutual exclusion) lets only one thread at a time into the protected section. lock_guard locks in its constructor and unlocks in its destructor, even if an exception is thrown.

Deadlock

A deadlock happens when thread A holds mutex 1 and waits for mutex 2, while thread B holds mutex 2 and waits for mutex 1. Both wait forever. Avoid it by always locking mutexes in the same order, or by locking several at once with std::scoped_lock lock(m1, m2); (C++17), which uses a deadlock-avoidance algorithm.

Good habits

  • Share as little as possible. Give each thread its own data and combine the results at the end.
  • Keep locked sections short; never do slow I/O while holding a lock if you can avoid it.
  • Never call unknown code (callbacks) while holding a lock.
  • Use thread-safe tools from the standard library instead of inventing your own.
  • Compile with -fsanitize=thread (ThreadSanitizer, GCC and Clang) while testing: it reports data races with the lines involved.

How many threads?

std::thread::hardware_concurrency() returns a hint of how many threads can truly run at once (often the number of logical cores). Creating thousands of threads is wasteful: each has its own stack and switching between them costs time. For many small jobs, real projects use a thread pool (a fixed set of worker threads taking tasks from a queue), usually from a library.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Concurrency 1: threads, mutexes and data races · 1/6

Threads

Threads let one program use several CPU cores at the same time.

Prefer a video by a teacher? Find free videos on “c++ std::thread mutex lock_guard data race tutorial” on YouTube ↗

In real life: Processing photos on all cores

A photo-printing shop's software must make thumbnails of 800 wedding photos. On an 8-core computer, splitting the list into 8 parts and giving each part to a thread finishes the job several times faster than one thread. Each thread writes only its own part of the results vector, so no lock is needed for the work itself.

cpp
#include <iostream>
#include <thread>
#include <vector>

int makeThumbnail(int photoId) { return photoId * 2; }   // stands in for real image work

int main() {
    const int photos = 800;
    unsigned workers = std::thread::hardware_concurrency();
    if (workers == 0) workers = 4;                        // the hint may be unavailable
    std::vector<int> results(photos);
    std::vector<std::jthread> pool;
    for (unsigned w = 0; w < workers; ++w) {
        pool.emplace_back([&results, w, workers] {
            for (int i = static_cast<int>(w); i < photos; i += static_cast<int>(workers)) {
                results[i] = makeThumbnail(i);            // each index written by one thread only
            }
        });
    }
    pool.clear();                                         // jthreads join here
    std::cout << results[799] << '\n';                    // 1598
}
Disjoint writes need no mutex. The jthread vector joins all workers when cleared.

Practice (0/4 done)

Task 1 · Question

What does t.join() do?

Task 2 · Question

Two threads each run ++counter 100,000 times on a shared int with no lock. What is guaranteed about the final value?

Task 3 · Question

A std::thread object is destroyed while still joinable (never joined or detached). What happens?

Task 4 · Practice

Write a program where 4 threads each add the numbers in their quarter of a vector of 1,000,000 ints (all 1s). First let them all add into one shared total protected by a mutex; then change it so each thread writes its partial sum into its own slot of a vector, and main adds the 4 partial sums. Measure both with <chrono>.

Quick quiz

Question 1

1.What is a mutex for?

Question 2

2.Why use std::lock_guard instead of calling m.lock() and m.unlock() yourself?

Question 3

3.What is a deadlock?

Question 4

4.Which tool detects data races at run time?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What is a mutex for?

Answer: Allowing only one thread at a time into a section that touches shared data

Mutual exclusion prevents simultaneous access.

Why use std::lock_guard instead of calling m.lock() and m.unlock() yourself?

Answer: It unlocks automatically, even when an exception is thrown (RAII)

Manual unlock is easy to miss on an early return or exception.

What is a deadlock?

Answer: Threads waiting for each other's locks forever

Circular waiting means none can proceed.

Which tool detects data races at run time?

Answer: -fsanitize=thread (ThreadSanitizer)

ThreadSanitizer instruments memory accesses and reports races.

What does a Mutex guarantee?Related

Answer: Only one thread accesses the data at a time

Mutual exclusion is its purpose.

From Shared state: Arc, Mutex, Send and Sync

What is special about std::thread::scope?Related

Answer: Threads in it can borrow local data because they must finish inside the scope

Scoped threads are joined before the scope ends.

From Threads

Two bank balances must be updated together by several threads. Which tool?Related

Answer: One std::mutex locked around both updates

Only a lock makes the combined change appear all at once.

From Concurrency 2: atomics, async, futures and condition variables

How do you prevent the classic two-lock deadlock?Related

Answer: Always acquire locks in the same order

A global lock order breaks the cycle.

From Race conditions and synchronized

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 2 more topics in “Exceptions, concurrency and building projects with CMake” before its exam: Concurrency 2: atomics, async, futures and condition variables, Building real projects: CMake, warnings, debuggers and sanitizers.
  2. Module 9 exam: Exceptions, concurrency and CMake · 12 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Competitive programming, interviews and careers. 42 topics left in the course before the final exam.

← Previous: Exceptions: throw, try, catch and noexcept