Concepts / Launching and Joining Threads

Launching and Joining Threads

The program launches it with std::thread t2 {F()};, so the task handed to the thread is the object produced by F().

  • Programming

Two Pieces of Work, Two Places to Run

Concurrent code is hard to read for one simple reason: the lines of a program are written in one order, but the work they describe may run in several places at once. Before learning any vocabulary, it helps to look at a small program that does work on two separate threads. The goal of this article is to answer two questions for any concurrent code you meet: what is the unit of work, and which execution path is actually carrying it out?

The program you will trace defines a plain function f, a function object F, and a function user that launches both and then waits for them. As you read it, try to notice which lines describe work and which lines describe where that work runs.

Reading the Two-Thread Program

cpp

Read the program in two passes. The first pass looks at the work: f is a function, and F is a function object whose call operator holds its work. The second pass looks at the placement: user() constructs two std::thread objects, and each construction hands one task to its own thread. The two comments in the source are the authoritative description of where each piece of work runs: f() executes in a separate thread, and F()() executes in a separate thread.

The diagram follows the order of the statements, which is the order in which user() executes them. The two launch steps are the points where a task leaves the calling code and becomes a thread. The two join steps are the points where the calling code pauses until a thread is done. Notice that the diagram does not show the work itself in the main thread: the body of f and the body of F run in the worker threads, not in user().

What do you think happens?

After user() calls t1.join(), can t2.join() be reached while t1 is still running?

  • Yes, the second join starts immediately

  • No, the call to t1.join() must return first

Reveal answer
Answer:

No, the call to t1.join() must return first.

The source says the call to join() waits for the thread, so the caller's next statement, t2.join(), is not reached until t1 has finished. Only then does the second join begin its wait.

What Counts as a Task

Definition

A task is a computation that can potentially be executed concurrently with other computations. In C++, a task takes the form of a function or a function object. Those two shapes are the only ones the source names, and the program above uses one of each.

The important word in the definition is concurrently. A computation is not a task merely because it does work; it is a task because it is something that can be run alongside other computations. This is why the program gives each piece of work its own thread rather than calling both functions one after the other in user().

How a Thread Represents a Task

A thread is the system-level representation of a task, and it is created by constructing a std::thread with the task as its argument. Launching a task concurrently therefore means constructing a thread for it. Each launch in user() is one such act: the task is named once, and it is handed to its own thread.

Read the diagram as a relationship, not as a containment hierarchy. A std::thread object is built from a task, and the thread is the way the system sees that task while it runs. Keeping these two ideas apart prevents a common confusion: the task is the work to be done, and the thread is the execution path that carries that work out.

Where join() Pauses the Caller

Threads can be waited for using the join() method. In the program, user() calls t1.join(); and then t2.join();, each annotated with the thread it waits for. Joining is how the caller learns that a particular thread has completed its task before the caller goes on.

The diagram shows the calling thread moving through three states. It is running user(), then it is blocked inside t1.join() while t1 works, and then it resumes once t1 has finished. Only after that resumption does the statement t2.join(); begin, which means the wait for t2 starts after the wait for t1 has ended.

Worked example

Tracing user() line by line

Trace which functions run in separate threads in user(), and identify the line where the calling thread pauses until a worker finishes.

  1. 1
    Line 1: std::thread t1 {f};

    A thread is constructed with f as its task, so f runs in a separate thread. The calling thread does not run the body of f here.

  2. 2
    Line 2: std::thread t2 {F()};

    A thread is constructed with the function object produced by F(). Its call operator runs in a second separate thread.

  3. 3
    Line 3: t1.join();

    The calling thread blocks here until t1 has finished. This is the first waiting point.

  4. 4
    Line 4: t2.join();

    This line is not reached until the call on line 3 has returned. It is the second waiting point, and it waits for t2.

Result

f and F run in separate threads, and user() pauses at line 3 until t1 finishes. Line 4 is the point where user() waits for t2.

Locating the Cause of an Unexpected Result

When a concurrent program produces an unexpected result, the first question to ask is which execution path ran the line in question. Find the line that launches each task, identify the thread that task was handed to, and then check whether the line that uses the result comes after the matching join(). A join() that has not yet been reached means the caller may not yet have waited for the thread that produces the value.

Mistakes When Tracing Concurrent Code

  • Common mistake

    Treating the thread constructor as a plain function call that runs the work immediately in user()

    What it looks like

    Reading std::thread t1 {f}; as if f's body runs on the line where it appears in user()

    Why it fails

    The source says f() executes in a separate thread. Constructing the thread is the launch; the body of f is carried out on that thread.

    Do this instead

    Identify the task named in the constructor, then assign its execution to the thread that was constructed for it.

  • Common mistake

    Assuming a join() call on t2 happens at the same time as the join on t1

    What it looks like

    Believing that t1.join(); and t2.join(); both pause the caller together

    Why it fails

    The caller does not reach t2.join(); until the call to t1.join(); has returned. The waits happen one after the other.

    Do this instead

    Read the joins in statement order, and treat each join as a pause that must finish before the next line runs.

  • Common mistake

    Confusing the task with the thread

  • Common mistake

    Assuming a function object is a different kind of task from a plain function

    Why it fails

    The source names functions and function objects as the two forms a task takes, and both are passed to a thread constructor in the same way.

    Do this instead

    Treat F() and f as two shapes of the same kind of thing: a task that can run concurrently.

Practice Tracing a Launch Order

EASY

Using the user() program from this article, answer these questions: (1) Which function body runs in a separate thread when the first launch executes? (2) Which function body runs in a separate thread when the second launch executes? (3) On which line does the calling thread first pause, and what must finish before it resumes?

Hints
  • Look at the argument passed to each std::thread constructor, and remember that the argument is the task.
  • The first join is the first pause; the second join cannot be reached until the first returns.
  • The source comments say that f() and F()() each execute in a separate thread.

Key Takeaways

  • A task is a computation that can be executed concurrently with other computations, and in C++ it takes the form of a function or a function object.
  • A std::thread is the system-level representation of a task; launching a task means constructing a std::thread with the task as its argument.
  • In the two-thread program, f runs in one separate thread and F()() runs in another, while user() constructs both threads and then waits for them.
  • join() is the waiting point: the caller blocks on t1.join() until t1 has finished, and t2.join() is not reached until that wait ends.
  • To trace a bug, find the launch that handed the suspicious work to a thread, then check whether the line using the result comes after the matching join().

Related concepts

More concepts starting with L