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

Data races, mutexes and lock_guard

The door of the shared fridge has a chalk tally of how many bowls of soup have been sold. After serving a bowl, a cook wipes the number and writes one more. One day both cooks come to the board at the same moment: both see 5, both write 6. Two bowls were sold, but the board went up by one.

The same failure happens in a program. Two threads increment one counter, 100000 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:

  1. read the value;
  2. add one;
  3. write the result.

The steps of two threads can interleave in any order. If both threads read 5, both write 6, and one increment is lost, as with the chalk on the fridge door.

Try it yourself: press "Step A" and "Step B" in different orders and watch what counter holds once both threads finish.

LiveTwo threads, one ++countercounter = 5
shared variable
5
counter
thread A
  1. readmov eax, [counter]
  2. addadd eax, 1
  3. writemov [counter], eax
register: —
thread B
  1. readmov eax, [counter]
  2. addadd eax, 1
  3. writemov [counter], eax
register: —
++counter on an int is three instructions (clang, x86-64, -O0)

counter = 5. In each thread ++counter is three steps. Press Step A and Step B in any order: can you lose an increment?

You choose the order of the steps

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. Interviewers like to ask this: why do two threads that increment one int lose increments?

Mutexes

The cooks agree on a rule: only the one holding the single piece of chalk may write on the board. Whoever wants to write a number waits until the chalk is put back. A mutex works the same way: it lets one thread at a time reach the data.

std::mutex from the <mutex> header has two methods:

  • lock() locks the mutex; if another thread already holds it, lock() waits;
  • unlock() releases the mutex, and the next thread can lock it.

This is where the chalk analogy ends: a cook sees that the board is busy. A thread that has not locked the mutex notices nothing and writes to memory anyway. A mutex protects data only if every thread locks it before touching that data.

lock_guard

A mutex is more reliably locked through std::lock_guard, which is RAII: the constructor calls lock(), and the destructor calls unlock(). The chalk goes back in place by itself as soon as the cook steps away from the board.

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

The mutex is locked and unlocked by hand, and there is an early return in the middle of the function:

C++
std::mutex m;
int total = 0;

void add(int x) {
    m.lock();
    if (x < 0) {
        return;  // ✗
    }
    total += x;
    m.unlock();
}

The program builds without warnings. But after add(-1) the mutex stays locked: the return skipped unlock(). Any other thread that calls add will wait forever. Replace the lock() and unlock() pair with std::lock_guard<std::mutex> guard(m);, and the mutex is released on any exit from the function.

Key points

  • ++counter is three steps: read, add, write; the steps of two threads can interleave, and an increment is lost.
  • A data race is simultaneous access to a variable without synchronization, where at least one thread writes; it is undefined behavior.
  • A mutex lets one thread at a time reach the data: lock() locks it, unlock() releases it.
  • std::lock_guard releases the mutex on any exit from its scope, an early return included.

Subscribe to take the course

The course is free. Sign in with Telegram and subscribe to the channel to open every lesson.

Sign in another way