Skip to content

68 Intermediate The Hundred

The Guard That Guards Nothing

the shortest critical section in C++

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

std::mutex m;
int counter = 0;

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

int main() {
    std::thread t1(work);
    std::thread t2(work);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << '\n';
}

Run it. What does it print?

Answer

Rarely 200000. Typical output (GCC on x86-64): counter = 193256 — a different shortfall every run.

Why

A lock guard only guards while it's alive, and this one has no name — it's a temporary, constructed and destroyed within the same statement. The mutex is locked and unlocked at the semicolon, so every ++counter runs unprotected: a data race, which is undefined behavior. Lost updates are the typical symptom; with less contention it may even appear to work. The vexing cousin std::unique_lock<std::mutex>(m); is sneakier still: with parentheses it's a declaration of a default-constructed unique_lock named m — shadowing the real mutex and holding no mutex at all. It only compiles because unique_lock has a default constructor; lock_guard spelled that way at least fails with "no matching function for call to ... lock_guard()".

GCC's -Wall flags the parenthesized form (unnecessary parentheses in declaration of 'm', from -Wparentheses) but says nothing about the unnamed temporary.

The fix

Name the guard — a named object lives to the end of its scope:

std::lock_guard<std::mutex> lock{m};
std::scoped_lock lock{m};              // C++17, template args deduced

Takeaway: a lock guard is only as long-lived as its name — no name, no scope, no lock.

Try it: g++ -std=c++17 -pthread main.cpp -o demo && ./demo

Open in Compiler Explorer ↗ Quiz this entry