Understanding std::ref and std::cref in C++ Threads

In C++, std::ref and std::cref are utility functions that wrap objects in std::reference_wrapper to allow passing references to callable objects and threading utilities like std::thread and std::jthread. Because threading constructors copy or move their arguments by default to ensure thread safety and avoid dangling references, passing variables directly will not bind to reference parameters in a worker function. This article explains why the C++ threading mechanism enforces by-value decay, how std::ref and std::cref bypass this behavior, and the safety considerations required when using them.

The Default Argument Passing Behavior in std::thread

When you construct a std::thread, the constructor takes the callable target and its arguments by forwarding reference (Args&&...), decaying them using std::decay_t. This design choice mirrors utilities like std::bind and std::make_tuple.

The decay ensures that values passed to the thread are copied or moved into internal thread-storage. If a worker function expects a non-const reference parameter:

void increment(int& value) {
    ++value;
}

Attempting to launch a thread by simply passing an integer variable directly will cause a compilation error:

int counter = 0;
std::thread t(increment, counter); // Compilation Error: cannot bind rvalue to non-const lvalue reference

The std::thread constructor creates a decayed copy of counter internally. When it attempts to invoke increment with this temporary copy, the language rules prevent binding a non-const lvalue reference (int&) to a temporary value (rvalue), resulting in a compiler error.

The Role of std::ref and std::cref

To bypass decay and ensure that the worker function operates on the original object rather than a copy, you must explicitly wrap the variable using std::ref or std::cref:

  • std::ref: Generates a std::reference_wrapper<T>, which behaves like a non-const reference (T&).
  • std::cref: Generates a std::reference_wrapper<const T>, which behaves like a constant reference (const T&).

The thread infrastructure copies the lightweight std::reference_wrapper object instead of the actual data. Upon executing the target function, the wrapper implicitly converts back to a reference bound to the original object.

#include <iostream>
#include <thread>
#include <functional>

void modify_data(int& val) {
    val += 42;
}

void print_data(const int& val) {
    std::cout << "Value: " << val << '\n';
}

int main() {
    int data = 10;

    // Passing by reference using std::ref
    std::thread t1(modify_data, std::ref(data));
    t1.join();

    // Passing by const reference using std::cref
    std::thread t2(print_data, std::cref(data));
    t2.join();

    return 0;
}

Key Use Cases

  1. Mutating State in Worker Threads: When a thread must update an external accumulator, buffer, or state variable managed by the parent context.
  2. Preventing Expensive Copies: When passing large objects (such as complex structs, large containers, or matrices) to read-only threads where copying overhead is prohibitive, std::cref allows passing by const& efficiently.
  3. Working with Non-Copyable Types: When an object manages exclusive resources (like std::mutex) and cannot be copied, passing it to a task requires either moving it or using std::ref.

Crucial Safety Considerations

While std::ref and std::cref are necessary for reference forwarding, they introduce concurrency risks:

  • Lifetime Management: The programmer is strictly responsible for guaranteeing that the referenced object outlives the spawned thread. If the owning scope exits while the thread is still running, the reference becomes dangling, leading to undefined behavior.
  • Data Races: Passing a non-const reference via std::ref allows concurrent mutation. If multiple threads access or modify the underlying object simultaneously, adequate synchronization mechanisms (such as std::mutex or std::atomic) must be implemented to prevent race conditions.