Concepts / Sharing Data Safely Between Threads

Sharing Data Safely Between Threads

The thread constructor is a variadic template that accepts an arbitrary sequence of arguments.

  • Programming

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?

  • Yes, because the thread worked on the original object

  • No, because the thread worked on its own copy

Reveal answer
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.

std::threadthe thread being createdffirst element: the work torunref(some_vec)later element: argumentpassed to f
Which part of the call is the function to run, and which parts are passed on to it?

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.

cpp
Notes on the code
  • 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 the ref() 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.

some_vecoriginal object in mainsome_vecoriginal object in mainv inside fa reference to some_vecparameter copythe thread's own versionrefers tocopy of
After the thread is constructed, do the worker and the caller hold the same object, or separate copies?
Worked example

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?

  1. 1
    Identify the object

    some_vec is created in main as an ordinary vector. Nothing about it changes when the thread is created.

  2. 2
    Apply the wrapper

    ref(some_vec) marks the vector so the constructor passes a reference to it rather than a value.

  3. 3
    Run the work

    The thread calls f, whose parameter refers to the same vector. Each element is doubled in that object.

  4. 4
    Wait and read

    join() makes main wait. Only after this point can main safely read the vector the thread worked on.

Result

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.

cpp
Notes on the code
  • 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
FeaturePlain function fFunction object F
How it is handed to the threadBy its name as the first elementBy constructing an object of F as the first element
How the thread runs itCalls f with the remaining argumentsCalls the operator defined in F on the stored data
Where the data comes fromSupplied as later arguments, such as ref(some_vec)Stored in a member of F
What the thread ends up doingThe same algorithm, if written to do the same workThe 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 like

    std::thread t{f, some_vec}; with f changing its parameter

    Why it fails

    Without ref(), the constructor treats the argument as a value, so the thread works on its own version and main's some_vec does not change.

    Do this instead

    Write 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 fails

    ref() 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 fails

    Both 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 fails

    The 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.

mainworker thread1. construct thread with f and ref(some_vec)2. join: main waits3. f finishes on some_vec4. main reads some_vec
In what order do main and the worker thread act, and at which step does a result first become visible to main?

Practicing a Thread Hand-Off

MEDIUM

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.

Related concepts

More concepts starting with S