Race Conditions and Basic Protection with a Mutex

Learn about C++ race conditions and how to prevent them when updating shared data. Use std::mutex and the RAII-style std::lock_guard for thread safety.

When multiple threads access and modify the same variable at the same time, your program may behave unpredictably.

That’s called a race condition — two or more threads racing to update shared data.

Let’s see it happen.


Example: Incrementing a shared counter

Here’s a simple example with two threads incrementing the same variable:

#include <iostream>
#include <thread>

int counter = 0;

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

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final counter: " << counter << std::endl;
}

Expected output

You might think the result will always be:

Final counter: 200000

But if you run it multiple times, you’ll see something like:

Final counter: 183451

or

Final counter: 197820

What’s happening?

Both threads are reading, updating, and writing the same variable counter concurrently.

The operation ++counter is not atomic — it’s counter = counter + 1, a sequence of steps:

  1. Load the right-hand-side counter into a register.
  2. Add 1.
  3. Write the new value back to the left-hand-side counter.

If both threads interleave these steps, e.g. they load the same counter value at the same time, updates go wrong. That’s the race condition.

Fixing data race with a mutex

To make ++counter safe, we can protect it with a mutex (std::mutex from <mutex>).

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex m;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        m.lock(); 
        ++counter;
        m.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Final counter: " << counter << std::endl;
}

Now the output will always be:

Final counter: 200000

because only one thread can hold the lock at a time. More precisely, here's how it works:

  • At some point, the mutex m is available to be acquired, no thread reached m.lock() yet.
  • Then one thread comes first to m.lock() to acquire m, it blocks the code scoped by m.lock() and m.unlock() to ensure ++counter to be done completely only in this thread.
  • After m.unlock(), the mutex m is available to be acquired again by any threads.

A safer way: std::lock_guard

Calling lock() and unlock() manually works. But if you forget to unlock, the mutex stays locked forever. (For example, if an exception occurs during the scoped code, unlock() can not be executed.)

C++ provides a safer (RAII) wrapper: std::lock_guard.

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex m;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(m);
        ++counter;
    }
}

When lock goes out of scope, the mutex is automatically unlocked — even if an exception is thrown. Great!

Common pitfall: locking too much

Protecting shared data is necessary, but locking too broadly can hurt performance.

Only lock the smallest possible region that modifies shared data — not the entire loop body, unless you have to.

That means, don't do this

void increment() {
    std::lock_guard<std::mutex> lock(m); // lock too much
    for (int i = 0; i < 100000; ++i) {
        ++counter;
    }
}

Takeaway

From this lesson, you learned:

  • A race condition occurs when threads modify shared data without coordination.
  • std::mutex prevents simultaneous access by enforcing mutual exclusion.
  • Use std::lock_guard for exception-safe, automatic locking.
  • Always minimize the locked section to avoid unnecessary blocking.

Subscribe to Modern, High-Performance C++ Tutorials and Insights

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe