Concepts / Waiting for Threads with join

Waiting for Threads with join

A thread can be given a function and its arguments, and the function then runs in a separate thread with those arguments.

  • Programming

Why Starting a Thread Is Not Enough

When a program starts one or more threads, it creates units of work that run alongside the rest of the program. Starting a thread does not, by itself, tell the program when that work is finished. If the program reaches its own end without any further coordination, it has no guarantee that its threads have completed. Managing threads means controlling this relationship, so that the program does not finish before the work it started is done.

This article uses one tool for that job: the join() method. Its purpose is to make sure a program does not exit until its threads have completed. Along the way we will look at the two ways a thread is given work to do, which are a function together with its arguments and a function object that carries its own data. We will also look at how join() changes the order in which things happen, and at the mistakes learners most often make about it.

What do you think happens?

If main reaches the end of the program without calling join() on a thread it started, is that thread guaranteed to have finished?

Reveal answer
Answer:

No. Without join() or some other coordination, the program has no guarantee that its threads have completed.

Starting a thread creates work that runs alongside the rest of the program. Only a coordinating step such as join() makes the program wait for that work.

Giving a Thread a Function and Its Arguments

A task that runs in a thread usually has work to do on some data. A thread that sums a list, sorts a collection, or updates a set of values needs access to that data. The basic idea is that a task needs data to work upon, and that data can be passed as arguments, either directly or as pointers or references to the data.

A thread can be given a function and its arguments, and the function then runs in a separate thread with those arguments. Consider the starting program's thread t1. It is created from a function, f, and an argument, ref(some_vec). When the thread starts, f runs in the new thread with that argument, so the effect is that f(some_vec) executes in the new thread. The same relationship applies to any thread built from a function and its arguments: the function is the work, and the arguments are the data it works on.

Reference Parameters and the Original Object

A function's parameter can be declared as a reference. The source gives the example vector<double>& v. The ampersand means that v is not a new copy of the vector; it names the caller's object. Because of this, a function with a reference parameter such as v works on the original data, not on a separate version of it.

some_veccreated in mainthe vectorthe original datavreference parameter of fnamesnames the same object
When f declares its parameter as a vector reference, do the two names some_vec and v refer to a copy or to the same object?

The diagram shows the key point. There is one vector. The name some_vec in main and the name v in the function both lead to that same vector. When f works on v, it is working on the vector that main created, which is why the reference parameter matters when a thread is given the function.

Function Objects as Thread Payloads

Definition

A function object is a struct or class that overloads operator(). It can carry data in its members, which lets it bring its own data along when it is used as the work given to a thread.

The starting program defines a struct F. Its constructor takes a vector reference, and it stores that reference in its member v. This is how F carries data: the vector is held inside the object. The operator() member is declared on F, and the program states that F(vec2)() executes in a separate thread. In other words, the thread t2 is given a temporary F object built from vec2, and the thread calls that object's application operator.

Notice that the two threads in the program receive data in different forms. The thread t1 receives a function and a wrapped reference, while the thread t2 receives a function object that already holds its data. Both threads work on the data they were given, but the way that data reaches each thread differs.

vec2vector in mainF objectmember v holds thereferenceoperator()runs in the thread t2passed to constructorcalled by thread
For the thread built from F{vec2}, where is the data stored, and which function does the thread call?

Passing a Reference with std::ref

The source states that std::ref is used to pass references to threads. In the starting program, the expression ref(some_vec) is how the first thread receives a reference to some_vec rather than the vector as a plain argument. The function f declares its parameter as a vector reference, so the reference that arrives in the thread refers to the same vector main created. The thread therefore operates on main's data.

Argument formWhat the thread receivesWhich object the function works on
Plain argument such as some_vecThe argument as given, not a reference wrapperNot described as the same object by ref(); the source contrasts this with ref()
Wrapped argument such as ref(some_vec)A reference to the vector main createdThe same vector main created, through the reference parameter

How the two argument forms differ for the thread t1 in the starting program

Joining a Thread

Definition

A thread is joined when the caller waits for that thread to terminate. Calling join() on a thread asks the program to pause at that point until the thread has terminated.

The join() method ensures that a program does not exit until its threads have completed. The waiting is the whole purpose of the call. join() does not itself perform the thread's work, and it is not a way to start the thread. Its job is to hold the calling thread at that point until the joined thread finishes.

Follow the order of events between two parties: the main thread that calls join(), and the worker thread being joined. Read the sequence from top to bottom, since each step happens after the one above it.

Main threadcalls join()Worker threadjoined threadstart workerjoin() reached, main waitsworker terminates, main resumes
In what order do the main thread and the worker act, and at which point does the main thread pause at join()?

The diagram shows the key point. The main thread reaches the join() call and waits. It does not continue past that point until the worker has terminated. Only after the worker terminates does the main thread resume, and only then can the program move on to whatever comes after the join.

Main reaches join()Worker still running?Main pausesMain continues pastjoin()join() never calledNo guarantee workerfinishedyesworker terminatesnoprogram may exit first
When main reaches join(), does it continue immediately or wait for the worker, and what happens if join() is never called?

Tracing a Two-Worker Program

The following example is an original illustration written for this article, not a listing from the source. It starts two worker threads and then calls join() on each of them before the main program prints its final message. The source does not present code for this, so the structure follows the standard Python threading interface, in which a thread is started and then joined. Each worker is given its function and its arguments through the args parameter.

python
Notes on the code
  • The function worker is the work given to each thread, and its arguments are the data it operates on
  • start() begins each thread; it does not wait for the worker to finish
  • join() on each thread pauses the main thread until that worker has terminated
  • The final print runs only after both join() calls have returned

Trace the program in order. Main creates the two thread objects and starts both. The two workers then run alongside each other, so the order in which their two messages appear is not guaranteed by this example. Main then reaches first.join() and waits for worker A to terminate. Once worker A has terminated, main reaches second.join() and waits for worker B. Only after both joins have finished does main print the completion message. What is guaranteed is the position of the final message relative to the joins, not the relative order of the two worker messages.

Mistakes Learners Make with join()

  • Common mistake

    Believing join() stops or cancels the thread

    Why it fails

    Calling join() pauses the calling thread at that point. It does not stop or cancel the joined thread.

    Do this instead

    Think of join() as a wait, not as a kill. The joined thread keeps running until it terminates on its own.

  • Common mistake

    Believing join() starts the thread

    Why it fails

    The waiting is the whole purpose of the call. join() does not itself perform the thread's work, and it is not a way to start the thread.

    Do this instead

    Start the thread first, then call join() on it to wait for its completion.

  • Common mistake

    Assuming a started thread is finished

    Why it fails

    Starting a thread does not, by itself, tell the program when that work is finished.

    Do this instead

    Use join() before the program depends on the thread's results or exits.

  • Common mistake

    Placing code that depends on the thread before join()

    Why it fails

    Code that depends on a thread's completion should be placed after that thread's join() call.

    Do this instead

    Move any step that needs the finished work to a position after the join().

  • Common mistake

    Thinking a reference parameter works on a copy

    Why it fails

    A reference parameter such as vector<double>& v names the caller's object, so the function works on the original data.

    Do this instead

    Remember that the name in the function and the caller's name refer to the same object.

Checking Your Understanding of Joins

MEDIUM

A main program creates a vector named data, starts a thread with a function f and the argument ref(data), and then reaches the end of main without calling join() on that thread. Explain why the program cannot assume f has finished, and describe where the join() call should go if main needs f's work to be done before it prints a result.

Hints
  • Ask whether starting a thread tells the program when its work is finished
  • Remember that join() pauses the calling thread until the joined thread terminates
  • Place any code that depends on the thread's completion after the join() call

Key Takeaways

  • A thread can be given a function and its arguments, and the function then runs in a separate thread with those arguments.
  • A reference parameter names the caller's object, and std::ref is used to pass a reference to a thread-launched function.
  • A function object is a struct or class that overloads operator() and can carry data in its members.
  • A thread is joined when the caller waits for it to terminate; join() pauses the calling thread and does not stop, cancel, or start the thread.
  • join() ensures a program does not exit until its threads have completed, so code that depends on a thread's result belongs after its join().

Related concepts

More concepts starting with W