Concepts / Thread Management Fundamentals

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.

  • Programming

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

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, 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.

Your codecalls operations such asjoin()Thread objecttyped handleSystem threadthe operating system'sthreadworks throughrefers to
What does the thread object contain or refer to, and how is it related to the underlying system thread? Your code works with the thread object, and the thread object is the typed handle to the system thread.

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.

Main threadstarts worker, then callsjoin()Worker threaddoes its work1. start worker2. join() called, main waits3. work finished, join() returns4. main continues
When the main thread calls join(), what runs in which order, and at what point does the main thread resume? The main thread starts the worker, reaches join() and waits, and continues only after the worker finishes.
Worked example

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?

  1. 1
    Start 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.

  2. 2
    Reach join()

    The main thread calls join() on the worker's thread object. At this point the main thread waits.

  3. 3
    Worker finishes

    The worker completes its work. join() now returns to the main thread.

  4. 4
    Main resumes

    Execution continues past the join() call, so the message is printed only after the worker's work is done.

Result

The message is printed after the worker has finished. join() is the point where the main thread synchronizes with the worker's completion.

cpp
Notes on the code
  • std::thread worker(do_work); starts a worker thread whose work is the function do_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.

Freeno lock holds the mutexHeldheld by the unique_lockFree againlock went out of scopeunique_lock createdlock goes out of scope
When a unique_lock is created and when it goes out of scope, what state does the mutex move through? The mutex starts free, becomes held when the lock is created, and is free again once the lock goes out of scope.
Before
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.

After
{
    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.

cpp
Notes on the code
  • 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.

Worked example

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?

  1. 1
    Protect the update

    The worker wraps its update to shared_total in a unique_lock, so the mutex is held while the protected work runs.

  2. 2
    Wait for the worker

    The main thread calls join() on the worker, so it waits until the worker has finished its work.

  3. 3
    Read 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.

Result

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 like

    The main thread starts a worker and immediately reads the worker's result.

    Why it fails

    Without join(), the main thread carries on without knowing whether the worker had finished.

    Do this instead

    Call 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 fails

    The thread object is a typed handle, and the system thread is what it refers to.

    Do this instead

    Think 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 like

    A unique_lock is created, the lock goes out of scope, and then shared data is updated.

    Why it fails

    The mutex is only held while the lock exists, so work outside that scope is not protected by it.

    Do this instead

    Keep 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 fails

    Separate 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 instead

    Wrap 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?

  • Immediately after the worker starts, before the worker does any work

  • Only after join() returns, which happens once the worker has finished

Reveal answer
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.

MEDIUM

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_lock exists.

Summary of Thread Management

  1. 1
    A thread object is a type-safe interface, a typed handle that your code uses to work with a system thread.
  2. 2
    join() makes the calling code wait until the thread has finished, and execution continues past the call only after that.
  3. 3
    unique_lock wraps a mutex so that the lock holds the mutex for the lifetime of its scope.
  4. 4
    Protected 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_lock to manage a mutex, keeping the protected work inside the lock's scope.
  • Combining join() and unique_lock lets threads share data while the main program waits for the results it needs.

Related concepts

More concepts starting with T