C++11 thread 参数传递深度解析:为什么需要 std::ref 而非直接引用?

摘要: std::thread 是 C++11 并发编程的核心组件之一。本文深入探讨 std::thread 参数传递的机制,解释为什么直接传递引用会导致意想不到的问题,以及 std::ref 如何优雅地解决这一难题。

一、引言

随着多核处理器的到来,并发编程成为充分利用硬件资源、提升程序性能的关键技术。

从 C++11 标准开始,C++语言内置支持原生的多线程,std::thread 成为实现并发编程的核心。std::thread 让多线程开发变得简单起来,只要一个可调用对象(函数、函数对象或 Lambda 表达式)作为参数传递给 std::thread 的构造函数,就可以启动一个新的线程。

但是,初次接触 std::thread 在传递参数时,会提出一些疑问:为什么不能直接将一个变量的引用传递给 std::thread,而必须使用 std::ref进行包装呢?这不是增加了代码的复杂度吗?背后是不是隐藏着什么样的考量?

这就是本文要讨论的主题,std::thread 参数传递的独特机制。

二、thread 参数传递的默认行为

std::thread 的构造函数默认是按值传递,也就是对传递给线程函数的参数进行拷贝。就算线程函数声明接受的是引用类型,std::thread 在内部也会把参数值拷贝一份,然后将这份拷贝传递给线程函数。

std::thread 的构造函数:

template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );

使用了万能引用(Args&&... args),根据传入参数的左值/右值属性进行推导。问题是,std::thread 内部会将这些参数拷贝或移动到其线程自己的存储空间,然后再把这些拷贝传递给线程函数 f

示例:传递基本类型和可拷贝对象

#include <iostream>
#include <thread>
#include <string>

void printValue(int val, std::string text) 
{
    std::cout << "Thread received int: " << val << ", string: " << text << std::endl;
    // 尝试修改传入的参数的拷贝
    val += 10;
    text += " (modified in thread)";
    std::cout << "Thread modified int to: " << val << ", string to: " << text << std::endl;
}

int main() 
{
    int originalInt = 5;
    std::string originalString = "Hello";

    std::cout << "Main thread original int: " << originalInt << ", string: " << originalString << std::endl;

    // 启动线程,参数会按值拷贝
    std::thread myThread(printValue, originalInt, originalString);

    myThread.join();

    std::cout << "Main thread after join: original int: " << originalInt << ", string: " << originalString << std::endl;
    return 0;
}

输出:


会发现 main 函数中的 originalIntoriginalString 在线程执行后并没有被改变。因为 std::thread 传递给 printValue 函数的是 originalIntoriginalString拷贝,线程内部对这些拷贝的修改不会影响到主线程的原始变量。

为什么 std::thread 默认按值拷贝?

多线程开发最重要的是安全。避免并发编程中最常见、最危险的问题之一:悬空引用。

一个新创建的线程,它的执行时间点和结束时间点与创建它的线程(主线程)是异步的。主线程可能在子线程完成其工作之前就结束了,或者子线程访问的变量在主线程中已经被销毁。如果 std::thread 默认按引用传递参数,那么当主线程中原始变量的生命周期结束时,子线程仍然持有对已销毁内存的引用,就会导致悬空引用 问题,引发未定义行为和程序崩溃。拷贝参数,std::thread 就能确保线程函数拥有自己独立的数据副本,从而避免这种生命周期不匹配带来的风险。

在这里插入图片描述

三、直接传递引用遇到的陷阱

在 C++ 模板编程中,如果参数通过值传递,会发生一种叫做 “衰退” 的类型转换。对于 std::thread 构造函数,它接受的参数也遵循这一规则:

  • 数组会衰退 成指向其第一个元素的指针。
  • 函数会衰退为函数指针。
  • 引用会衰退为值类型: 这是最关键的一点,也是本文的核心。如果传递一个左值引用,在 std::thread 构造函数处理参数时,这个引用类型会“衰退”成它所引用的值类型,然后 std::thread 会对这个值类型进行拷贝。所以,就算声明一个引用变量并传递给 std::threadstd::thread 接收到的仍然是这个变量的值拷贝

示例:

#include <iostream>
#include <thread>
#include <string>

// 想通过引用修改传入的整数和字符串
void modifyData(int& num, std::string& str) 
{
    std::cout << "Thread: Initial num = " << num << ", str = " << str << std::endl;
    num += 100;
    str += " (modified by thread)";
    std::cout << "Thread: Modified num = " << num << ", str = " << str << std::endl;
}

int main() 
{
    int myInt = 10;
    std::string myString = "Original";

    std::cout << "Main: Before thread, myInt = " << myInt << ", myString = " << myString << std::endl;

    // 启动线程并传递引用
    // 表面上看,传递的是 myInt 和 myString 的引用
    std::thread t(modifyData, myInt, myString); // 注意这里,myInt和myString是左值

    t.join();

    std::cout << "Main: After thread, myInt = " << myInt << ", myString = " << myString << std::endl;

    return 0;
}

输出:

Main: Before thread, myInt = 10, myString = Original
Thread: Initial num = 10, str = Original
Thread: Modified num = 110, str = Original (modified by thread)
Main: After thread, myInt = 10, myString = Original

modifyData 函数的参数被声明为引用 (int&, std::string&),但最终 main 函数中的 myIntmyString 的值并没有被改变。这正是因为 std::thread 构造函数在接收 myIntmyString 时,发生了“衰退”: myInt 的值拷贝了一份,myString 的值也拷贝了一份,然后将这些拷贝传递给了 modifyData 函数。线程内部对这些拷贝的修改,完全不会影响到 main 函数中的原始变量。

这个陷阱的另一个棘手的地方是 编译器不会发出警告或错误。从编译器的角度来看,std::thread 构造函数接收的是值,然后它将这些值传递给线程函数。线程函数接收引用,这在语法上是完全合法的,它只是接收了这些“值拷贝”的引用。这种静默的“失败”很难排查出是逻辑错误。

但这也带来一定的好处。如果线程函数要处理一个不可拷贝不可移动 的对象,就不能直接传递给 std::thread。因为 std::thread 默认会尝试拷贝或移动参数,如果对象不支持这些操作,会出现编译错误。

#include <iostream>
#include <thread>
#include <mutex> // std::mutex 是不可拷贝/移动的

void useMutex(std::mutex& mtx) 
{
    std::lock_guard<std::mutex> lock(mtx);
    std::cout << "Thread acquired mutex." << std::endl;
}

int main() 
{
    std::mutex myMutex;
    // 编译错误:call to deleted constructor of 'std::mutex'
    // 因为 std::thread 试图拷贝 myMutex
    // std::thread t(useMutex, myMutex); // 错误!

    // t.join();
    return 0;
}

std::thread 内部的参数“衰退”机制将左值引用转换为值类型并进行拷贝的行为是出于多线程环境下数据安全性的考虑,但也成为使用 std::thread 进行参数传递时最常见的“陷阱”之一。为了解决这个问题,C++ 标准库提供了 std::refstd::cref

四、std::ref 的救赎

那要坚持传入引用参数怎么办?

std::thread 参数传递的默认行为虽然保证多线程环境下的数据安全,但也阻止了通过引用来修改外部变量或传递不可拷贝/移动的对象。为了解决这一核心问题,C++ 标准库提供了 std::refstd::cref,是专门设计用来“强制” std::thread以引用方式处理参数的工具。

std::ref 是什么?

std::ref 是一个函数模板,定义在 <functional> 头文件。返回的是一个 std::reference_wrapper<T> 类型的对象。

std::reference_wrapper 是一个轻量级的包装器,包装了一个引用。它的核心作用是:

  • 阻止参数“衰退”: std::thread 的构造函数遇到 std::reference_wrapper<T> 类型的参数时会特殊处理,将其“解包”为 T&,而不是像普通左值那样进行值拷贝。
  • 模拟引用语义: 使得原本会按值拷贝的参数,现在能够以引用语义传递给线程函数。

在这里插入图片描述

简单来说,std::ref(obj) 告诉 std::thread:“哥,求你件事,我不是想让你拷贝 obj 的值,我希望你把 obj 的引用传递给线程函数!”

如何使用 std::ref

使用 std::ref 非常简单,按引用传递的参数用 std::ref() 包裹起来就行。

示例

#include <iostream>
#include <thread>
#include <string>
#include <functional> // 包含 std::ref

// 线程函数,期望通过引用修改传入的整数和字符串
void modifyData(int& num, std::string& str) 
{
    std::cout << "Thread: Initial num = " << num << ", str = " << str << std::endl;
    num += 100;
    str += " (modified by thread)";
    std::cout << "Thread: Modified num = " << num << ", str = " << str << std::endl;
}

int main() 
{
    int myInt = 10;
    std::string myString = "Original";

    std::cout << "Main: Before thread, myInt = " << myInt << ", myString = " << myString << std::endl;

    // 使用 std::ref 包装需要按引用传递的参数
    std::thread t(modifyData, std::ref(myInt), std::ref(myString));

    t.join();

    std::cout << "Main: After thread, myInt = " << myInt << ", myString = " << myString << std::endl;

    return 0;
}

输出:

Main: Before thread, myInt = 10, myString = Original
Thread: Initial num = 10, str = Original
Thread: Modified num = 110, str = Original (modified by thread)
Main: After thread, myInt = 110, myString = Original (modified by thread)

std::cref主要用在常量引用 。和 std::ref 类似,会生成 std::reference_wrapper<const T> 对象,即一个常量引用包装器。如果想将一个参数以常量引用的方式传递给线程函数,以此来保证线程不会修改原始数据,那么 std::cref 是最合适的。

#include <iostream>
#include <thread>
#include <string>
#include <functional> // 包含 std::cref

void printData(const int& num, const std::string& str) 
{
    std::cout << "Thread: Received num = " << num << ", str = " << str << std::endl;
    // 尝试修改会编译错误:num += 1;
}

int main() 
{
    int myInt = 20;
    std::string myString = "Immutable";

    // 使用 std::cref 包装,传递常量引用
    std::thread t(printData, std::cref(myInt), std::cref(myString));

    t.join();

    return 0;
}

std::ref 不仅解决了修改外部变量的问题,也解决了传递不可拷贝/移动对象的问题。

#include <iostream>
#include <thread>
#include <mutex>
#include <functional> // 包含 std::ref

void useMutex(std::mutex& mtx) {
    std::lock_guard<std::mutex> lock(mtx);
    std::cout << "Thread acquired mutex." << std::endl;
    // 模拟一些工作
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout << "Thread released mutex." << std::endl;
}

int main() {
    std::mutex myMutex;
    // 使用 std::ref 传递 std::mutex 的引用
    std::thread t(useMutex, std::ref(myMutex));

    t.join();
    return 0;
}

std::refstd::cref 对于 std::thread 的参数传递机制非常重要,是解决“引用衰退”问题和实现真正引用传递的关键

  • 允许线程函数修改主线程中的原始变量。
  • 避免对大型对象进行不必要的拷贝。
  • 传递不可拷贝或不可移动的对象的引用。

五、其他参数传递方式

除了直接值拷贝和通过 std::ref 进行引用传递之外,C++ 在多线程编程中还提供了其他的参数传递机制。

std::move:转移所有权

要将一个资源的所有权从主线程转移到新创建的线程,std::move 就派上用场了。这对于那些不可拷贝但可移动的类型非常适用,当然也适用大型对象,避免不必要的拷贝开销。

std::move 本身不执行任何移动操作,只是将一个左值强制转换为右值引用,让编译器选择移动构造函数或移动赋值运算符。当 std::thread 构造函数接收到一个右值引用时,会尝试调用参数的移动构造函数,将资源的所有权转移到线程内部。

示例:

#include <iostream>
#include <thread>
#include <memory> // For std::unique_ptr
#include <string>

// 线程函数,接收一个 unique_ptr 的所有权
void processUniquePtr(std::unique_ptr<std::string> ptr) 
{
    if (ptr) {
        std::cout << "Thread: Received data: " << *ptr << std::endl;
    } else {
        std::cout << "Thread: Received a null unique_ptr." << std::endl;
    }
    // unique_ptr 在线程函数结束时自动释放其管理的资源
}

int main() 
{
    // 创建一个 unique_ptr
    auto myUniqueData = std::make_unique<std::string>("Hello from main!");

    std::cout << "Main: Before thread, myUniqueData is valid: " << (myUniqueData ? "true" : "false") << std::endl;

    // 使用 std::move 将 unique_ptr 的所有权转移给新线程
    std::thread t(processUniquePtr, std::move(myUniqueData));

    std::cout << "Main: After thread creation, myUniqueData is valid: " << (myUniqueData ? "true" : "false") << std::endl; // 此时 myUniqueData 已经为空

    t.join();

    std::cout << "Main: After thread join, myUniqueData is valid: " << (myUniqueData ? "true" : "false") << std::endl;

    return 0;
}

虽然 C++ 推荐使用智能指针和引用来管理资源,但凡是总会有个例外。

这种方法的优点:

  • 非常直接,没有 std::ref 包装的额外开销。
  • 可以传递 nullptr 来表示可选参数。

缺点也很明显:

  • 悬空指针风险: 如果线程访问指针所指向的数据时,原始数据在主线程中已经被销毁,就会导致未定义行为。这是使用原始指针最大的陷阱。
  • 原始指针不携带所有权语义,接收方不知道是否应该负责释放内存。
  • 不如智能指针安全和方便。

示例:

#include <iostream>
#include <thread>
#include <chrono> // For std::this_thread::sleep_for

void printDataPtr(int* dataPtr) 
{
    // 模拟线程执行时间
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    if (dataPtr) {
        // 访问 dataPtr 指向的数据,这里存在悬空指针风险
        std::cout << "Thread: Data received via pointer: " << *dataPtr << std::endl;
    } else {
        std::cout << "Thread: Received null pointer." << std::endl;
    }
}

int main() 
{
    int localData = 42;
    // 错误示例:如果线程执行时间长于 main 函数的局部变量生命周期,dataPtr 可能成为悬空指针
    // 尤其是在实际应用中,如果 localData 是一个临时对象,问题更明显
    std::thread t(printDataPtr, &localData);

    // main 函数可能很快结束,导致 localData 被销毁
    // t.join(); // 如果不 join,风险更大

    // 为了演示,这里强制等待线程完成,但在实际复杂场景中,这种风险难以避免
    t.join();

    return 0;
}

警告: 除非能够严格保证指针指向的数据在线程生命周期内始终有效,否则还是尽量避免传递原始指针。应该用比较安全的智能指针或 std::ref

Lambda 表达式的捕获机制

Lambda 表达式是 C++11 引入的强大特性,可以定义匿名函数对象,并且可以“捕获”定义范围内的变量。用 Lambda 表达式作为线程函数时,其捕获机制直接影响参数的传递方式。

  • 值捕获 [=][var] 捕获的变量会按值拷贝到 Lambda 闭包中。这与 std::thread 默认的参数拷贝行为类似。对捕获变量的修改不会影响原始变量。
  • 引用捕获 [&][&var] 捕获的变量会按引用存储在 Lambda 闭包中。这等同于使用 std::ref,允许线程修改原始变量。但同样存在悬空引用风险。
  • 移动捕获 [var = std::move(some_var)] (C++14): 变量移动到 Lambda 闭包中,非常适合传递不可拷贝但可移动的对象,或避免大型对象的拷贝。

示例:不同捕获方式

#include <iostream>
#include <thread>
#include <string>
#include <utility> // For std::move

int main() 
{
    int value = 10;
    std::string message = "Original Message";
    std::unique_ptr<int> resource = std::make_unique<int>(100);

    // 1. 值捕获
    std::thread t1([=]() { // 捕获 value 和 message 的拷贝
        int thread_value = value; // 拷贝
        std::string thread_message = message; // 拷贝
        std::cout << "Thread 1 (Value Capture): Initial value = " << thread_value
                    << ", message = " << thread_message << std::endl;
        thread_value += 1; // 修改的是拷贝
        thread_message += " (modified)";
        std::cout << "Thread 1 (Value Capture): Modified value = " << thread_value
                    << ", message = " << thread_message << std::endl;
    });
    t1.join();
    std::cout << "Main: After t1, value = " << value << ", message = " << message << std::endl; // 未改变

    std::cout << "---------------------------------" << std::endl;

    // 2. 引用捕获
    std::thread t2([&]() { // 捕获 value 和 message 的引用
        std::cout << "Thread 2 (Reference Capture): Initial value = " << value
                    << ", message = " << message << std::endl;
        value += 1; // 修改原始变量
        message += " (modified by t2)";
        std::cout << "Thread 2 (Reference Capture): Modified value = " << value
                    << ", message = " << message << std::endl;
    });
    t2.join();
    std::cout << "Main: After t2, value = " << value << ", message = " << message << std::endl; // 已改变

    std::cout << "---------------------------------" << std::endl;

    // 3. 移动捕获 (C++14)
    // 注意:resource 在这里被移动到 lambda 内部,main 线程的 resource 变为 null
    std::thread t3([moved_resource = std::move(resource)]() {
        if (moved_resource) {
            std::cout << "Thread 3 (Move Capture): Received resource with value: " << *moved_resource << std::endl;
        } else {
            std::cout << "Thread 3 (Move Capture): Received null resource." << std::endl;
        }
    });
    std::cout << "Main: After t3 creation, resource is valid: " << (resource ? "true" : "false") << std::endl; // false
    t3.join();

    return 0;
}

std::bind 与参数绑定

std::bind 定义在 <functional>,是一个通用的函数适配器,可以将函数、成员函数或函数对象与其参数绑定,生成一个新的可调用对象。

std::bind 的参数传递默认也是按值拷贝。所以,要通过 std::bind 传递引用,还是要使用 std::ref

示例:

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

void updateValue(int& num, const std::string& prefix) 
{
    num += 10;
    std::cout << prefix << ": Updated num to " << num << std::endl;
}

int main() 
{
    int myValue = 50;
    std::string myPrefix = "Thread Info";

    std::cout << "Main: Before thread, myValue = " << myValue << std::endl;

    // 使用 std::bind 绑定 updateValue 函数和其参数
    // std::ref(myValue) 确保 myValue 以引用方式传递
    // myPrefix 默认按值拷贝
    auto bound_func = std::bind(updateValue, std::ref(myValue), myPrefix);

    // 将绑定后的可调用对象传递给 std::thread
    std::thread t(bound_func);

    t.join();

    std::cout << "Main: After thread, myValue = " << myValue << std::endl; // myValue 已经被修改

    return 0;
}

虽然 Lambda 表达式比较简洁和安全,但 std::bind 在处理可调用对象是成员函数时非常有用。

六、结语

要记住, std::thread 构造函数默认对传递给线程函数的参数进行按值拷贝或移动。这是为了确保线程启动后,即使原始变量在主线程中被销毁,线程内部仍然持有其数据的独立副本,避免悬空引用/指针的问题。

“衰退”陷阱: 对于左值引用(T&),这种拷贝行为会导致“引用衰退”为值类型,使得线程函数接收到的是一个副本,而不是原始数据的引用。这也是 std::ref 存在的根本原因。

C++ 多线程编程的参数传递方式:

  • 默认按值拷贝: 安全,但不能修改外部变量。
  • std::ref / std::cref 解决引用“衰退”问题,实现真正的引用传递。
  • std::move 转移资源所有权,适用不可拷贝但可移动的对象,或优化大型对象传递。
  • 原始指针: 谨慎使用,存在严重的悬空指针风险,除非能严格保证生命周期。
  • Lambda 捕获: 提供灵活的捕获语义(值、引用、移动),是现代 C++ 中推荐的线程函数定义方式。
  • std::bind 用于函数参数绑定,与 std::ref 结合使用可实现引用传递。

最大的陷阱: 不管是通过原始指针传递,还是 Lambda 的引用捕获,最大的风险都是悬空指针/引用

在选择参数传递方式时,始终要问自己:

  1. 线程是否需要修改原始数据?选择std::ref / Lambda 引用捕获。
  2. 数据是否是独占资源,需要转移所有权?选择std::move / Lambda 移动捕获。
  3. 数据是否不可拷贝?选择std::ref / std::move
  4. 数据的生命周期如何?是否会在线程完成前被销毁?考虑 std::shared_ptrjoin()

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lion 莱恩呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值