Concepts / Mutex Management with unique_lock

Mutex Management with unique_lock

A mutex is used to protect data that more than one thread may access.

  • Programming

Two Questions Every Threaded Program Faces

When a program runs several threads at once, two questions come up again and again. First, how does one part of the program know that a thread it started has finished? Second, how do several threads share data safely without interfering with each other? The practices covered here answer both questions: use join() to wait for a thread to complete, and use unique_lock to manage mutexes.

This article explains what each practice does and why it matters. It then traces what happens to a thread and to a mutex step by step as the code runs, so you can see the order of events rather than only memorize rules.

A Thread Object Is a Typed Handle

Definition

A thread is a type-safe interface to a system thread, so your code works with a typed handle rather than the system thread directly.

The important word is interface. A thread object in your program is not the operating system's thread itself. It is a typed handle that your code uses to work with a system thread. Because the handle is typed, the compiler can check how you use it. That is the sense in which the interface is type-safe.

Once you have a thread object, you manage the thread through its operations. The most important of these for this article is join(), which lets the rest of the program wait for the thread's work to finish.

Key point

Your code works with a typed handle. The handle is the thing you start, wait on, and manage, while the underlying system thread does the actual running.

Tracing a Thread Until join() Returns

When you start a thread, the code that started it keeps running. The worker and the main thread then proceed at the same time. Sometimes the rest of the program needs the thread's work to be finished before it goes on. In that situation, use join() to wait for the thread to complete. Calling join() on a thread object makes the calling code wait until that thread has finished its work, and only then does execution continue past the call.

Start workermain threadReach join()main thread waitsDo workworker threadWorker finishesworker threadjoin() returnsmain thread resumescontinuesworker runscompleteswaits until worker donesignals completion
How does the main thread's timeline line up with the worker thread's, and where does join() block execution?

Read the diagram from left to right in time. The main thread starts the worker, then reaches join(). At that point the main thread waits. When the worker finishes, join() returns and the main thread continues. Without join(), the main thread would simply carry on without knowing whether the worker had finished, and that is exactly the situation join() is used to avoid.

Key point

join() is a waiting point. Code placed after join() can safely depend on the work the thread performed, because execution does not pass the call until that thread has finished.

Protecting Shared Data with unique_lock

A mutex is used to protect data that more than one thread may access. The practice this guidance recommends is to use unique_lock to manage mutexes. Instead of handling the mutex directly, you wrap it in a unique_lock object. The lock object is then responsible for holding the mutex while the protected work runs.

Because the lock object takes responsibility for the mutex, the code that uses the lock is organized around the lock's lifetime. You do not scatter separate lock and unlock calls through the function. Instead, the protected work sits inside the scope where the lock exists.

Following the Mutex Through Its States

Now trace the mutex itself. It starts free. It becomes held when the unique_lock is created, and it is free again once the lock goes out of scope. The transitions are tied to the lock object's lifetime, not to a separate unlock call you have to remember.

Freeno lock object yetHeldunique_lock existsFree againlock out of scopeunique_lock createdscope exit
When does the mutex move from unlocked to locked and back, and what triggers the unlock at scope exit?

What do you think happens?

A unique_lock is created at the top of a block that protects a shared counter. When does the mutex become free again?

  • Only when you call an explicit unlock function

  • When the lock goes out of scope at the end of the block

  • When the worker thread calls join()

Reveal answer
Answer:

When the lock goes out of scope at the end of the block

The transitions are tied to the lock object's lifetime, so the mutex is released once the unique_lock goes out of scope.

The following example is an illustrative program, written for this article, that puts both practices together. A worker thread updates a shared counter while holding the mutex through a unique_lock. The main thread uses join() before it reads the result.

Code walkthrough
#include <iostream>
#include <mutex>
#include <thread>

std::mutex m;
int counter = 0;

void work() {
    std::unique_lock<std::mutex> lock(m);
    ++counter;
}  // lock goes out of scope here, releasing the mutex

int main() {
    std::thread worker(work);
    worker.join();
    std::cout << counter << "\n";
}
  1. 1
    Line by line, the program first declares the shared mutex m and the shared counter.
  2. 2
    When work() runs, unique_lock takes ownership of m, so the mutex is held while counter is incremented.
  3. 3
    When work() returns, the lock goes out of scope and the mutex is free again.
  4. 4
    In main, join() blocks until the worker has finished, so the read of counter happens after the increment.
  • std::thread worker(work) creates a thread object, which is the typed handle to a system thread
  • std::unique_lock<std::mutex> lock(m) wraps the mutex, so the lock object holds it
  • The closing brace ends the lock's scope, which is when the mutex becomes free again
  • worker.join() makes main wait until work() has finished before reading counter
Output
1

Two Threads Taking Turns on One Mutex

Consider two threads that both need the same shared data. Each one manages the mutex through its own unique_lock. In this illustrative scenario, Thread A acquires the mutex first, does its protected work, and releases it when its lock leaves scope. Thread B then acquires the mutex for its own protected work. The sequence below shows the order of these acquisitions and releases.

Thread AMutexshared by both threadsThread B1. acquires2. waits while held3. releases at scope exit4. acquired by Thread B
In what order do Thread A and Thread B acquire and release the mutex, and when does one thread have to wait?

The waiting step is the important one. While Thread A holds the mutex, Thread B cannot proceed past its own lock on that same mutex. This is what keeps the shared data from being changed by two threads in the middle of each other's work, which is the interference the guidance is meant to prevent.

Mistakes Beginners Make with Threads and Mutexes

  • Common mistake

    Reading a worker's results without calling join() first

    What it looks like

    Start a worker that fills a result variable, then print that variable immediately in main without any join() call.

    Why it fails

    Without join(), the main thread carries on without knowing whether the worker had finished, so the code may see work that is not yet done.

    Do this instead

    Call join() on the thread before any code that depends on its work.

  • Common mistake

    Assuming the thread object is the system thread itself

    What it looks like

    Treating the variable that holds a std::thread as if it were the operating system's thread and expecting it to do the work by itself.

    Why it fails

    A thread object is a typed handle your code uses to work with a system thread. It is the interface, not the underlying thread.

    Do this instead

    Think of the thread object as a handle, and manage the thread through its operations such as join().

  • Common mistake

    Touching shared data outside the lock's scope

    What it looks like

    Creating a unique_lock, closing its scope, and then updating the shared counter afterward.

    Why it fails

    The mutex is only held while the lock exists, so work placed after the scope ends is not protected.

    Do this instead

    Keep all shared-data access inside the scope where the unique_lock exists.

  • Common mistake

    Letting several threads share data with no mutex at all

    Why it fails

    Without a mutex, threads may interfere with each other while they update the same data.

Practicing the Lock and Join Pattern

MEDIUM

A worker thread adds to a shared total while holding the mutex through a unique_lock. The main thread needs the final total. Describe the order of events: where the mutex is held, when it becomes free, and where the main thread must place its join() call so that it reads the correct total.

Hints
  • Ask when the unique_lock is created and when its scope ends.
  • Ask which thread must wait, and at which call, before reading the total.

Key Takeaways

  • A thread object is a type-safe, typed handle to a system thread, and your code works with that handle rather than the system thread directly.
  • Use join() to make the calling code wait until a thread has finished, so code that depends on the thread's work runs only after that work is complete.
  • Use unique_lock to manage a mutex; the mutex is held from the lock's creation and released when the lock goes out of scope.
  • Keep protected work inside the lock's scope, and avoid reading shared results before the relevant join() call.

More concepts starting with M