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:
- Load the right-hand-side
counterinto a register. - Add 1.
- 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
mis available to be acquired, no thread reachedm.lock()yet. - Then one thread comes first to
m.lock()to acquirem, it blocks the code scoped bym.lock()andm.unlock()to ensure++counterto be done completely only in this thread. - After
m.unlock(), the mutexmis 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::mutexprevents simultaneous access by enforcing mutual exclusion.- Use
std::lock_guardfor exception-safe, automatic locking. - Always minimize the locked section to avoid unnecessary blocking.