Thread Management Fundamentals
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.
Two Questions Every Threaded Program Raises
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? This article answers both questions with two practices: use join() to wait for a thread to complete, and use unique_lock to manage mutexes.
Keep both questions in mind as you read. Each practice answers one of them, and the article then traces what happens to a thread and to a mutex step by step as the code runs. Watching those steps in order is the best way to build a reliable mental model before you write threaded code yourself.
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, which 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 operations for this article is join(). The diagram below shows the relationship between your code, the thread object, and the system thread it refers to.
Following the Main Thread Through join()
When you start a thread, the code that started it continues running. Sometimes the rest of the program needs the thread's work to be finished before it goes on. The practice for this situation is to use join() to wait for a 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.
Without join(), the main thread would simply carry on without knowing whether the worker had finished. That is exactly the situation join() is used to avoid. The diagram below traces the order of events for one worker.
Tracing a join() call step by step
A main thread starts a worker, then calls join() on it, then prints a message. In what order do the events happen?
- 1Start the worker
The main thread starts the worker. The code that started it continues running, so the worker and the main thread are now both active.
- 2Reach join()
The main thread calls
join()on the worker's thread object. At this point the main thread waits. - 3Worker finishes
The worker completes its work.
join()now returns to the main thread. - 4Main resumes
Execution continues past the
join()call, so the message is printed only after the worker's work is done.
The message is printed after the worker has finished. join() is the point where the main thread synchronizes with the worker's completion.
- std::thread
worker(do_work); starts a worker thread whose work is the functiondo_work worker.join(); makes the calling thread wait until the worker has finished- The final line runs only after
join()returns
Protecting Shared Data with unique_lock
A mutex is used to protect data that more than one thread may access. The practice 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.
This means the code that uses the lock is organized around the lock's lifetime rather than around separate lock and unlock calls. The protected work sits inside the scope where the lock exists. The diagram below traces the mutex through its states.
m.lock();
shared_total += 1;
m.unlock();The lock is taken and released by separate explicit calls, so the code must remember to pair them.
{
std::unique_lock<std::mutex> lock(m);
shared_total += 1;
}The unique_lock holds the mutex for the duration of its scope, and the protected work sits inside that scope.
- std::mutex m; declares the mutex that protects
shared_total - std::
unique_lock<std::mutex>lock(m); wraps the mutex; creating the lock makes the mutex held shared_total+= 1; is the protected work, which runs while the lock exists- The closing brace ends the lock's lifetime, and the mutex becomes free again
Combining join() and unique_lock
The two practices work together. Threads can share data through a protected location, while the main program uses join() to wait for the results it needs. The lock keeps each update safe, and join() keeps the main program from reading the result too early.
Sharing a total between a worker and the main thread
A worker adds to a shared total under a lock. The main thread needs the final total before it prints it. Which practice handles each concern?
- 1Protect the update
The worker wraps its update to
shared_totalin aunique_lock, so the mutex is held while the protected work runs. - 2Wait for the worker
The main thread calls
join()on the worker, so it waits until the worker has finished its work. - 3Read the result
Only after
join()returns does the main thread read and print the total, so it does not read a value that is still being built.
The lock handles safe access to the shared data, and join() handles the timing that tells the main thread when the result is ready.
Two Threads Contending for One Mutex
The purpose of protecting data with a mutex is that threads must not interfere with each other. If two threads try to protect the same data, they share one mutex. While one thread holds the mutex through its unique_lock, the protected work runs for that thread alone. The other thread must wait until the mutex is free before it can proceed with its own protected work.
The lock's scope defines the boundary. When the first thread's lock goes out of scope, the mutex becomes free again, and the waiting thread can then take it. Keeping the protected work inside the lock's scope, and no larger, keeps each thread's time holding the mutex as short as the protected work requires.
Mistakes Beginners Make with Threads and Locks
Common mistake Continuing as if the worker has finished without calling join()
What it looks likeThe main thread starts a worker and immediately reads the worker's result.
Why it failsWithout
join(), the main thread carries on without knowing whether the worker had finished.Do this insteadCall join() on the thread object before the code that depends on the worker's work continues.
Common mistake Treating the thread object as the system thread itself
Why it failsThe thread object is a typed handle, and the system thread is what it refers to.
Do this insteadThink of the thread object as the interface your code uses to control the system thread.
Common mistake Placing protected work outside the lock's scope
What it looks likeA unique_lock is created, the lock goes out of scope, and then shared data is updated.
Why it failsThe mutex is only held while the lock exists, so work outside that scope is not protected by it.
Do this insteadKeep the protected work inside the scope where the lock exists.
Common mistake Managing the mutex by hand when a unique_lock would do
Why it failsSeparate lock and unlock calls must be paired correctly, and the code is organized around those calls rather than around the lock's lifetime.
Do this insteadWrap the mutex in a unique_lock so the lock object handles holding the mutex for the protected work.
Checking Your Understanding
What do you think happens?
The main thread starts a worker, calls join() on it, and then prints a line. When does the main thread print that line?
Reveal answer
Only after join() returns, which happens once the worker has finished.
join() makes the calling code wait until the thread has finished its work, and only then does execution continue past the call.
A worker thread updates a shared counter that the main thread reads at the end. Explain which practice protects the counter update and which practice ensures the main thread reads the final value. Then state where the protected update should sit relative to the lock's scope.
Hints
- Think about which practice is about data access and which is about waiting for completion.
- The protected work must run while the
unique_lockexists.
Summary of Thread Management
- 1A thread object is a type-safe interface, a typed handle that your code uses to work with a system thread.
- 2
join()makes the calling code wait until the thread has finished, and execution continues past the call only after that. - 3
unique_lockwraps a mutex so that the lock holds the mutex for the lifetime of its scope. - 4Protected work belongs inside the lock's scope, and
join()keeps the main program from reading results too early.
At a glance
- 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.
- Use
join()to wait for a thread to complete before the code that depends on its work continues. - Use
unique_lockto manage a mutex, keeping the protected work inside the lock's scope. - Combining
join()andunique_locklets threads share data while the main program waits for the results it needs.