Skip to content
Theory · Multithreading: An Overview · lesson 3 of 10

Data races, mutexes and lock_guard

Two threads increment one counter, 100,000 times each. The expected total is 200000:

C++
#include <iostream>
#include <thread>

int main() {
    int counter = 0;
    auto count_up = [&counter]() {
        for (int i = 0; i < 100000; ++i) {
            ++counter;  // ✗
        }
    };
    std::thread worker(count_up);
    count_up();
    worker.join();
    std::cout << counter << '\n';
}

The program builds without warnings, but the total is not guaranteed. A run may print less than 200000, a different number each time, but the standard does not promise even that.

Data races

++counter is not one action but three: read the value, add one, write the result. If both threads read 5, both write 6, and one increment is lost. A data race is unsynchronized access by two threads to one variable, where at least one of them writes. A data race is undefined behavior for the whole program, not only a wrong number.

Mutexes and lock_guard

A mutex lets one thread at a time reach the data. std::mutex from the <mutex> header has the methods lock() and unlock(). While one thread holds the mutex locked, lock() in another thread waits. Calling them by hand is risky: an early return skips unlock(), and the other threads wait forever.

That is why a mutex is locked through std::lock_guard, which is RAII: the constructor calls lock(), and the destructor calls unlock().

C++
std::mutex m;
auto count_up = [&counter, &m]() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> guard(m);
        ++counter;
    }
};

The mutex is released when the scope of guard ends: here, at the end of each iteration. On any other exit from the scope, such as a return, the destructor releases the mutex too. Now the program always prints 200000.

Common mistake

Two threads lock two mutexes in opposite orders:

C++
std::mutex a;
std::mutex b;

void left() {
    std::lock_guard<std::mutex> ga(a);
    std::lock_guard<std::mutex> gb(b);
}

void right() {
    std::lock_guard<std::mutex> gb(b);
    std::lock_guard<std::mutex> ga(a);  // ✗
}

If one thread runs left while another runs right, the program can hang forever. The first thread locks a and waits for b, while the second locks b and waits for a. Such mutual waiting is called a deadlock. It does not happen on every run, which makes it hard to find.

std::scoped_lock locks several mutexes at once and without a deadlock, whatever order they are passed in: std::scoped_lock lock(a, b);. It releases them at the end of its scope, too.