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.
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
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.
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
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.
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 form | What the thread receives | Which object the function works on |
|---|---|---|
| Plain argument such as some_vec | The argument as given, not a reference wrapper | Not 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 created | The 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
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.
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.
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.
- 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 finishjoin()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 failsCalling
join()pauses the calling thread at that point. It does not stop or cancel the joined thread.Do this insteadThink 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 failsThe 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 insteadStart the thread first, then call join() on it to wait for its completion.
Common mistake Assuming a started thread is finished
Why it failsStarting a thread does not, by itself, tell the program when that work is finished.
Do this insteadUse 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 failsCode that depends on a thread's completion should be placed after that thread's
join()call.Do this insteadMove 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 failsA reference parameter such as vector<double>& v names the caller's object, so the function works on the original data.
Do this insteadRemember that the name in the function and the caller's name refer to the same object.
Checking Your Understanding of Joins
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 itsjoin().