Sharing Data Safely Between Threads
The thread constructor is a variadic template that accepts an arbitrary sequence of arguments.
A Question to Carry Through the Lesson
When a program starts a thread, it hands that thread some work and some data. The work is usually a function, and the data is usually an object the function should operate on. Once the thread is running, a natural question arises: does the thread work on the original object that the main part of the program created, or does it receive its own separate version of that object? The answer determines whether a change made inside the thread is visible back in main, and getting it wrong is one of the most common ways a threaded program produces a result nobody expected.
This article follows a small program that starts two threads and then waits for both of them to finish. You will trace what each thread is responsible for before reading the explanations. The goal is not to memorize every line. The goal is to notice 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 the question above in mind as you trace it.
Following Two Threads Through One Program
Start by tracing the program in order. Main creates the first thread object and supplies it with a function named f and with the vector some_vec, which begins as an ordinary object owned by main. Main then creates the second thread object, this time from a function object built from F applied to a second vector, vec2. Finally, main waits for both threads to finish before it continues. Between the two creation moments and the waiting point, each thread runs its own work.
Notice what changes at each of those three decision points. At creation, the program decides which work the thread will execute. At the argument hand-off, it decides what data that work receives. At the wait, it decides when main is allowed to proceed. A wrong value, if one appears, will usually be visible only after the wait, because that is the first point where main can safely look at what the threads produced.
What do you think happens?
After the first thread has finished running f on some_vec, does main see the changes that f made to some_vec?
Reveal answer
Yes, because the vector was passed with ref(), so the thread operates on the same object that main created.
The default treatment of an argument is as a value. The ref() wrapper changes that, so that the thread works on the original vector rather than on its own version of it.
Reading the Thread Constructor Call
The thread constructor is a variadic template. In plain terms, this means the constructor does not fix how many arguments it takes. It accepts an arbitrary sequence of arguments, so one list can contain a callable followed by whatever values that callable needs. Because the list is read in order, the rule is simple: the first element identifies what the thread should execute, and every element after it is an input that will be supplied to that work.
The constructor does not need to know in advance how many inputs will follow. Whatever sequence is written is accepted, and the first element is always the one treated as the work. This is why a single constructor call can start a thread that needs no inputs, one input, or several inputs, without a separate constructor for each case.
Passing an Object by Reference with ref()
By default, the variadic template treats an argument as a value. That default is fine for small inputs, but a thread that works on a large container usually needs to 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. Writing ref(some_vec) inside the constructor's argument list is the explicit signal that the thread should share the object rather than receive its own version of it.
- f's parameter is a reference to a vector of doubles, so it operates on the object it is given
- std::
ref(some_vec)is theref()wrapper that marks the vector for sharing join()is the point where main waits for the thread to finish
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 refers to the same vector that main created, so the function changes the original rather than a copy. The diagram below contrasts this with the default value treatment, where the thread works on its own version.
Tracing a change made by the worker
Main creates some_vec holding 1.0, 2.0, and 3.0, then starts a thread on f with ref(some_vec). f doubles every element. What does main see after join() returns?
- 1Identify the object
some_vecis created in main as an ordinary vector. Nothing about it changes when the thread is created. - 2Apply the wrapper
ref(some_vec)marks the vector so the constructor passes a reference to it rather than a value. - 3Run the work
The thread calls f, whose parameter refers to the same vector. Each element is doubled in that object.
- 4Wait and read
join()makes main wait. Only after this point can main safely read the vector the thread worked on.
main sees 2.0, 4.0, and 6.0, because the thread changed the original object.
Function Objects as Thread Work
The first element of the constructor call is not limited to a plain function name. The second thread in the source program is constructed from F applied to vec2. 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. In other words, the thread executes the operator defined in F on the vector stored inside it.
- F stores a reference to a vector in its member v
operator()is the application operator the thread calls- F{vec2} is the first element handed to the constructor, so it is the work to run
| Feature | Plain function f | Function object F |
|---|---|---|
| How it is handed to the thread | By its name as the first element | By constructing an object of F as the first element |
| How the thread runs it | Calls f with the remaining arguments | Calls the operator defined in F on the stored data |
| Where the data comes from | Supplied as later arguments, such as ref(some_vec) | Stored in a member of F |
| What the thread ends up doing | The same algorithm, if written to do the same work | The same algorithm, if written to do the same work |
Both forms are function objects as far as the compiler is concerned; the difference is how the work is written.
The source makes an important comparison here. If the operator in 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. A learner should therefore not treat a function object as a different kind of thread work; it is another way of writing the same kind of work.
Mistakes When Handing Data to Threads
Common mistake Leaving out ref() when the thread should change the original object
What it looks likestd::thread t{f, some_vec}; with f changing its parameter
Why it failsWithout
ref(), the constructor treats the argument as a value, so the thread works on its own version and main'ssome_vecdoes not change.Do this insteadWrite ref(some_vec) inside the constructor's argument list when the thread must operate on the original object.
Common mistake Assuming that ref() is needed for every argument
Why it failsref()is needed when sharing is the intent. Values that the thread should receive as its own copy do not need the wrapper.Common mistake Believing a function object does different work from an equivalent function
Why it failsBoth are function objects for the thread to execute. When they perform the same algorithm they are roughly equivalent in what the thread does.
Common mistake Reading the first argument as just another input
Why it failsThe first element identifies the work to run. The elements after it are the inputs supplied to that work, so reading them in the wrong role changes what is executed.
Locating Where a Result First Appears
When a threaded program produces a wrong value, the place to look first is the point where main waits for the threads. Until main has waited, the worker may still be running, so main cannot know that the object has reached its final state. The sequence below orders the steps of the program so you can see where a result, or a mistake, first becomes visible relative to the waiting point.
Practicing a Thread Hand-Off
Program A starts one thread with f and a plain some_vec argument, with no ref() wrapper. Program B starts one thread with f and ref(some_vec). In both programs f doubles every element of its parameter. After join() returns in each program, state what main sees in some_vec for A and for B, and identify the single line where the difference is decided.
Hints
- Ask whether the thread receives the original object or only a copy of its value
- Look at the argument list inside the braces, since that is where the default treatment is overridden
- Main can safely read the vector only after the wait
Key Takeaways
- The thread constructor is a variadic template: it accepts an arbitrary sequence of arguments, reads the first element as the work to run, and supplies the rest as inputs to that work.
- By default an argument is treated as a value, so the thread works on its own version;
ref()marks an argument as a reference so the thread works on the original object. - A function and a function object can both serve as thread work; when they perform the same algorithm, the thread's behavior is roughly equivalent.
- A wrong result usually becomes visible only after main waits for the threads, so read shared objects after that wait.