Mutex Management with unique_lock
A mutex is used to protect data that more than one thread may access.
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
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.
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.
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.
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.
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?
Reveal 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.
#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";
}- 1Line by line, the program first declares the shared mutex m and the shared counter.
- 2When
work()runs,unique_locktakes ownership of m, so the mutex is held while counter is incremented. - 3When
work()returns, the lock goes out of scope and the mutex is free again. - 4In 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 untilwork()has finished before reading counter
1Two 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.
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 likeStart a worker that fills a result variable, then print that variable immediately in main without any join() call.
Why it failsWithout
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 insteadCall 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 likeTreating 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 failsA 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 insteadThink 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 likeCreating a unique_lock, closing its scope, and then updating the shared counter afterward.
Why it failsThe mutex is only held while the lock exists, so work placed after the scope ends is not protected.
Do this insteadKeep 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 failsWithout a mutex, threads may interfere with each other while they update the same data.
Practicing the Lock and Join Pattern
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_lockis 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_lockto 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.