Returning Results From Threads
The thread constructor is a variadic template that accepts an arbitrary sequence of arguments.
A Two-Thread Program to Trace
This article starts with a small program that launches two threads and then waits for both of them to finish. Before reading any explanation, follow the execution trace of the program and watch which work each thread is responsible for. You do not need to memorize every line. Watch for three places where the program decides what a thread will do: the moment each thread object is created, the arguments handed over at that moment, and the point where main waits for the threads to finish.
Keep one question in mind while you read the code: when a thread is created, where does the data it works on come from, and does the thread get the original object or only a copy of its value?
- The first thread is built from the function f and the vector wrapped in std::ref
- The second thread is built from a function object F that stores a reference to vec2
join()makes main wait until each worker has finished
What do you think happens?
After both threads finish, what does main print?
Reveal answer
2 2
Each worker doubles the vector it was given a reference to. Both some_vec and vec2 started with a first element of 1.0, so after both threads finish, each first element is 2.0. Because both threads work on the original objects rather than copies, main sees the doubled values.
Reading the Constructor's Argument List
The thread constructor is a variadic template. In plain terms, this means the constructor does not fix how many arguments it takes. It can accept an arbitrary sequence of arguments, so one brace-initialized list can contain a callable followed by whatever values that callable needs.
The list in braces is read in order. The first element identifies what the thread should execute, and the remaining elements are the inputs that will be supplied to it. The constructor does not need to know in advance how many inputs exist, because the variadic design accepts whatever sequence is written. The first argument names the work to run, and the compiler builds the function object that the thread will execute.
The diagram shows the path of only one argument. The second thread follows a different path, which the next section explains. Notice that the first element is never handed to the function as an input. It is the callable itself, and everything after it is forwarded as input.
Sharing the Original Vector With ref()
By default the variadic template treats an argument as a value. For a thread that works on a large container, that default is often not what you want when the thread should operate on the original object. The ref() function, which comes from the functional header, tells the template to treat the object as a reference instead.
The source describes this wrapper as unfortunate, because it is needed at all. The constructor cannot otherwise tell that the caller wants the thread to share the object rather than receive its own version of it. Writing ref(some_vec) inside the braces is the explicit signal that the thread should work on the same object that main created.
Follow the vector some_vec through the first thread. It starts as an ordinary object in main. The ref() wrapper marks it for the constructor, and the constructor passes it along as the argument to f. Inside f, the parameter is a reference to a vector of doubles, so the function operates on the same object that main created. This is why main can read the changed values after the thread finishes.
Function Objects as Thread Work
The second thread in the program is constructed from F{vec2} rather than from a plain function name. F is a struct that stores a reference to a vector in a member and defines an application operator. When a thread is constructed from an object like this, the thread runs that object as if it were called, which means the thread executes the operator defined in F on the stored vector.
| Aspect | Plain function f | Function object F{vec2} |
|---|---|---|
| What is written in the constructor | The function name f | An object F that stores vec2 and defines an operator |
| Where the vector comes from | Passed in as an argument, wrapped with ref() | Stored as a reference inside the object |
| What the compiler builds | A function object for the thread to execute | A function object for the thread to execute |
| What the thread ends up doing | Runs the algorithm written in f | Runs the algorithm written in the operator of F |
Two ways to give a thread its work, when both perform the same algorithm
If the operator of F and the function f perform the same algorithm, then handing either one to a thread is roughly equivalent. In both cases the compiler is building a function object for the thread to execute. The difference lies in how that object is written, not in what the thread ends up doing.
Locating Where a Result Appears
A result produced by a worker thread does not become visible at the moment the worker writes it. In the program above, main creates both threads, the workers run their operations on the shared data, and main then waits at join() for each thread to finish. The values that main reads afterward are the first place where the result is visible to main. If the wrong value appears there, the cause is in the work the thread performed or in what it was given, so that is where you should look first.
Read results only after the threads you depend on have finished. In the program, the waiting step is what separates the moment a worker writes from the moment main can rely on what it reads.
Practice Tracing the Argument Path
Suppose you change the first thread to std::thread t1{f, some_vec}, without std::ref. Trace the path of some_vec through the constructor. Does the worker change the vector that main reads after join(), and which argument rule explains your answer?
Hints
- Ask whether the constructor treats the argument as a value or as a reference by default.
- Find the step where the worker's parameter is connected to the object created in main.
- Compare what main reads after
join()with the result from the original program.
Mistakes When Passing Data to Threads
Common mistake Assuming a worker thread changes the original object without ref()
What it looks likestd::thread t1{f, some_vec}; followed by reading some_vec in main after join()Why it failsBy default the variadic template treats an argument as a value, so the thread works on its own version of the object.
Do this insteadWrap the object with ref(some_vec) when the thread should operate on the original.
Common mistake Forgetting that the first argument is the work, not an input
Why it failsThe first element of the braced list identifies what the thread executes. Everything after it is forwarded as input to that callable.
Do this insteadWrite the callable first, then the values it needs, in the order its parameters expect.
Common mistake Treating a function object and a function as different kinds of thread work
Why it failsWhen both perform the same algorithm, the compiler builds a function object for the thread in either case, so the behavior is roughly equivalent.
Do this insteadChoose the form that makes the code clearer, such as a plain function for a simple task or a function object when it needs to store a reference to data.
Common mistake Reading a worker's result before the thread has finished
Why it failsThe value may not yet reflect the work the thread performs, so a wrong value appearing early does not mean the algorithm is broken.
Do this insteadWait for the threads to finish, then check the values, so you can tell whether the error is in the work or in the timing.
Key Takeaways
- The thread constructor is a variadic template: it accepts an arbitrary sequence of arguments, reads the first one as the work to run, and forwards the rest as inputs.
- By default an argument is treated as a value;
ref()marks it so the thread works on the original object. - A function and a function object can both serve as thread work, and when they perform the same algorithm they are roughly equivalent from the thread's point of view.
- Results from a worker thread become reliable for main after the program waits for the threads to finish, so that waiting point is where to look first when a value is wrong.