The Mystery of the "Missing" Increments

When starting out with multi-threading in C++, you might encounter a baffling situation where using std::lock_guard seems to "fail" to increment a shared counter across multiple threads, whereas manually calling .lock() and .unlock() works perfectly.

If you look at the console output, you might see the counter stop at 1 with std::lock_guard, but reach 2 with manual locks. However, the issue is not that std::lock_guard is failing to lock. Instead, the behavior stems from a combination of lock scope (RAII) and a data race in the main thread.

The Core Issue 1: Lock Scope and RAII

The fundamental difference between manual locking and std::lock_guard is when the mutex is unlocked.

With std::lock_guard:

void incrementCounter(){
    lock_guard<mutex> lock(mtx); // Mutex is locked here
    counter += 1;
    this_thread::sleep_for(chrono::milliseconds(2)); 
    // Mutex is unlocked ONLY here (at the end of the function scope)
}

Because std::lock_guard uses RAII (Resource Acquisition Is Initialization), the mutex remains locked for the entire duration of the function—including during the 2-millisecond sleep.

With Manual Lock/Unlock:

void incrementCounter(){
    mtx.lock(); 
    counter += 1;
    mtx.unlock(); // Mutex is unlocked IMMEDIATELY here
    this_thread::sleep_for(chrono::milliseconds(2));
}

With manual locking, you unlock the mutex before the thread goes to sleep. This allows other threads to acquire the lock and increment the counter immediately while the first thread is sleeping.

The Core Issue 2: Data Race in the Main Thread

The reason you see different outputs in your console is because of how you print the counter in your main() function:

int main(){
    thread t1(incrementCounter);
    cout << "Thread one released " << counter << "\n"; // Data Race!
    thread t2(incrementCounter);
    cout << "thread two released " << counter << "\n"; // Data Race!
    t1.join();
    t2.join(); 
    return 0;
}

In main(), you are reading and printing counter while the child threads t1 and t2 are actively modifying it, and you are doing so without holding the mutex. This is a classic data race, which leads to undefined behavior.

Because std::lock_guard holds the lock during the sleep, the main thread's print statements execute while the second thread is blocked waiting for the lock. With manual locks, the lock is released instantly, allowing both threads to finish their increments before the main thread prints, creating the illusion that manual locking "worked" better.

The Clean, Thread-Safe Solution

To fix this code, you should:

  1. Keep using std::lock_guard (it is safer because it prevents deadlocks if an exception is thrown).
  2. Limit the scope of the lock_guard using an explicit block {} so it releases the lock before sleeping.
  3. Avoid reading the shared variable in the main thread while other threads are writing to it.

Here is the corrected, thread-safe implementation:

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

using namespace std;

int counter = 0;
mutex mtx;

void incrementCounter(){
    {
        // Limit the scope of the lock using curly braces
        lock_guard<mutex> lock(mtx);   
        counter += 1;
    } // lock_guard goes out of scope and releases the mutex here

    this_thread::sleep_for(chrono::milliseconds(2));
}

int main(){
    thread t1(incrementCounter);
    thread t2(incrementCounter);

    // Wait for both threads to finish executing safely
    t1.join();
    t2.join(); 

    // Safe to print now that threads have finished
    cout << "Final counter value: " << counter << "\n"; 
    return 0;
}

Conclusion

Always remember that std::lock_guard binds the lifetime of the lock to its surrounding scope. If you have long-running operations or sleeps inside a function, use explicit block scopes {} to release the lock as early as possible. Most importantly, never read or write to shared variables from your main thread without synchronization!