线程传参
需要预先学习:
1. 传参的本质:构造时拷贝
std::thread 的构造函数把实参按值拷贝(或移动)到线程内部,然后在线程入口函数中使用这些拷贝。这一点决定了传参的大部分规则:
#include <iostream>
#include <thread>
void print(int n)
{
std::cout << n << std::endl;
}
int main()
{
int a = 100;
std::thread t(print, a); // a 被拷贝一份传给线程, 线程内部用的是副本
a = 200; // 修改主线程的 a, 不影响线程内的副本
t.join();
return 0;
}
输出 100 而不是 200——线程拿到的是构造时的副本。参数在 std::thread 构造的那一刻就被拷贝/移动了,之后主线程再怎么改原变量,线程内部都不受影响。
注:拷贝发生在
std::thread构造时,而不是线程真正开始执行时。所以即使主线程在t启动后立刻修改实参,线程看到的仍是旧值(对值参数而言)。
这个"构造时拷贝"的语义带来两个方向的问题:
- 想让线程看到外部变量的最新值 / 修改外部变量 → 需要引用传递(
std::ref) - 想让线程安全地拥有一个外部对象 → 需要指针 + 生命周期管理(或智能指针)
下面分别展开。
2. 值传递
最简单的场景:线程入口只需要参数的一份独立拷贝,不关心外部变量。直接传值即可,安全无坑:
#include <iostream>
#include <thread>
void add(int a, int b)
{
std::cout << a << " + " << b << " = " << a + b << std::endl;
}
int main()
{
std::thread t(add, 3, 4); // 两个 int 分别拷贝进线程
t.join();
return 0;
}
3 + 4 = 7
注意一个容易误判的点:即使线程入口的形参是引用,传参时不加包装也不会真的传引用:
#include <iostream>
#include <thread>
void modify(int& x) // 形参是引用
{
x += 10;
}
int main()
{
int a = 5;
std::thread t(modify, a); // 错误!a 被拷贝成临时 int, 无法绑定到 int&
t.join();
std::cout << a << std::endl;
return 0;
}
在G++环境下直接编译报错:
error: static assertion failed: std::thread arguments must be invocable after conversion to rvalues
原因:std::thread 内部把实参包装成 decay 后的副本(int 副本),传给形参 int& 时无法绑定到临时值。要让线程入口能修改外部变量,必须显式用引用包装器。
3. 引用传递:std::ref 与 std::cref
std::ref(x) 生成一个 std::reference_wrapper,它内部保存 x 的指针,拷贝它等于拷贝指针——指向的还是同一个对象。std::thread 拿到 reference_wrapper 后,调用入口函数时会自动解引用,于是形参 int& 就绑定到了原来的 x 上:
#include <functional>
#include <iostream>
#include <thread>
void modify(int& x)
{
x += 10;
}
int main()
{
int a = 5;
std::thread t(modify, std::ref(a)); // 传引用包装器
t.join();
std::cout << a << std::endl; // 15, a 被线程修改了
return 0;
}
std::cref(x) 同理,但绑定的是 const T&,用于只读共享场景。
典型应用:线程入口有输出参数(out 参数),比如把计算结果写回调用方:
#include <functional>
#include <iostream>
#include <thread>
void compute(int n, int& result) // result 是输出参数
{
result = n * n;
}
int main()
{
int result = 0;
std::thread t(compute, 7, std::ref(result));
t.join();
std::cout << "result = " << result << std::endl; // result = 49
return 0;
}
引用传递必须保证对象在线程执行期间存活。如果线程被 detach,而引用绑定的是主线程的局部变量,主线程一返回对象就销毁,线程访问悬空引用——未定义行为:
#include <functional>
#include <iostream>
#include <thread>
void slow_use(int& x)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << x << std::endl; // 如果 main 已经返回, x 悬空
}
int main()
{
int a = 42;
std::thread t(slow_use, std::ref(a));
t.detach(); // main 返回后 a 销毁, 子线程还在用
return 0;
}
所以规则是:用 std::ref 传引用时,要么保证对象生命周期覆盖整个线程(如 join 等待),要么用 std::shared_ptr 让线程自己持有对象(见下文智能指针一节)。
注:
std::ref也常用于把std::mutex、std::atomic等不可拷贝对象传进线程(它们禁拷贝、但可以传引用)。
4. 指针传递与生命周期
传指针和传引用本质是同一类问题:指针指向的对象必须在线程执行期间存活。
#include <iostream>
#include <thread>
struct Data
{
int value;
};
void process(Data* d)
{
std::cout << d->value << std::endl;
}
int main()
{
Data d{100};
std::thread t(process, &d); // 传栈上对象的地址
t.join(); // join 保证 d 在线程执行期间存活, 安全
return 0;
}
上面的代码 join 了所以安全。危险的写法是把指向局部变量的指针传给 detach 线程,或者线程还在跑,局部变量就先销毁:
#include <iostream>
#include <thread>
void process(int* p)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << *p << std::endl; // 悬空指针, 未定义行为
}
int main()
{
int value = 42;
std::thread t(process, &value);
t.detach(); // 主线程立刻返回, value 销毁
return 0;
}
堆对象同样有隐患——裸指针不表达所有权,谁释放、何时释放说不清:
#include <iostream>
#include <thread>
void process(int* p)
{
std::cout << *p << std::endl;
delete p; // 线程负责释放
}
int main()
{
int* p = new int(42);
std::thread t(process, p);
t.join(); // 这里 join 了, 释放时机安全
return 0;
}
一旦 main 里忘记 join 或者自己也 delete 了一次,就是双释放或悬空指针。裸指针 + 多线程 = 所有权模糊,现代 C++ 的解法是用智能指针(下文)。
注意:C++ 的
this指针也是指针,把this传进线程等同于传普通指针,同样要保证对象存活。常见 bug:对象先析构,后台线程还在调用它的成员函数。
5. 字符串字面量的坑
const char* 的拷贝拷贝的是指针,不是字符串内容。字符串字面量有静态存储期所以本身不会失效,但如果传入的是 std::string 的 c_str() 指针,而那个 std::string 在线程使用前被修改或销毁,指针就悬空了:
#include <iostream>
#include <string>
#include <thread>
void print(const char* s)
{
std::cout << s << std::endl;
}
int main()
{
std::string name = "hello";
std::thread t(print, name.c_str()); // 危险! 只拷贝了指针
name = "modified"; // string 重新分配内存, 原缓冲区可能失效
t.join(); // 线程可能读到 "modified" 或垃圾
return 0;
}
正确做法:传 std::string 本身(值拷贝一份完整的字符串),让入口形参也接收 std::string:
#include <iostream>
#include <string>
#include <thread>
void print(std::string s) // 形参改为 std::string, 线程持有完整副本
{
std::cout << s << std::endl;
}
int main()
{
std::string name = "hello";
std::thread t(print, name); // 拷贝整个字符串进线程
name = "modified"; // 主线程随便改, 不影响线程内的副本
t.join();
return 0;
}
输出始终是 hello。同理,const char* 字面量本身(如 "literal")因为是静态存储期、永不失效,直接传是安全的;需要警惕的是指向可变缓冲区(c_str()、char[])的指针。
6. 成员函数与 this
入口是成员函数时,第一个参数必须是调用该函数的对象指针(或引用包装器),后面才是真正的参数:
#include <iostream>
#include <thread>
class Counter
{
public:
void add(int n) // 成员函数
{
value_ += n;
}
int value() const
{
return value_;
}
private:
int value_ = 0;
};
int main()
{
Counter c;
std::thread t(&Counter::add, &c, 5); // 相当于 c.add(5), 在子线程中执行
t.join();
std::cout << c.value() << std::endl; // 5
return 0;
}
这里 &c 就是 this 指针,因此对象的生命周期问题同样适用:c 必须在子线程执行期间存活,否则成员函数访问的是已析构对象。
注意值传递对象指针的替代写法——传对象本身的值拷贝(线程操作副本):
Counter c;
std::thread t(&Counter::add, c, 5); // c 被拷贝进线程, 线程改的是副本
t.join();
std::cout << c.value() << std::endl; // 0, 原对象没变
这在语义上等于"在副本上调用成员函数",适合"线程独立处理一份数据"的场景,天然避免了共享竞争。但要求 Counter 可拷贝,且拷贝有意义。
7. 移动语义:move-only 类型
std::thread 构造时对实参做 decay 拷贝,但如果类型不可拷贝、只能移动(如 std::unique_ptr、std::thread 本身、std::promise),就必须用 std::move 显式转移所有权:
#include <iostream>
#include <memory>
#include <thread>
struct Task
{
int id;
};
void run(std::unique_ptr<Task> t) // 按值接收 unique_ptr, 独占所有权
{
std::cout << "task " << t->id << std::endl;
}
int main()
{
auto task = std::make_unique<Task>(Task{42});
std::thread t(run, std::move(task)); // 所有权移入线程
t.join();
return 0;
}
如果不 move 直接传 task,编译报错(unique_ptr 拷贝构造被删除):
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&)'
std::move 之后,task 变为空指针,对象的所有权完全属于线程。这实际上是一种很优雅的设计:用 unique_ptr 把堆对象的所有权"交付"给线程,线程结束时对象自动释放,生命周期问题彻底消失。
lambda 捕获 move-only 对象同理,需要 std::move 进捕获列表(C++14 起):
auto task = std::make_unique<Task>(Task{7});
std::thread t([task = std::move(task)] { // 初始化捕获, 移动进 lambda
std::cout << "task " << task->id << std::endl;
});
t.join();
注:
std::promise、std::packaged_task等标准库设施也是 move-only 的,向线程传递它们时都要std::move(《多线程编程进阶》中会用到)。
8. 智能指针传参
8.1 std::shared_ptr:共享所有权,自动保命
std::shared_ptr 是按值拷贝传参的最佳选择:每次拷贝引用计数 +1,线程持有自己的副本,只要线程还握着它,对象就不会被释放。这直接解决了"线程使用的对象生命周期不可控"的问题:
#include <iostream>
#include <memory>
#include <thread>
struct Data
{
int value = 100;
};
void process(std::shared_ptr<Data> d) // 线程持有 shared_ptr 副本
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << d->value << std::endl; // 对象保证存活
}
int main()
{
auto d = std::make_shared<Data>();
std::thread t(process, d); // 拷贝 shared_ptr, 引用计数 +1
d.reset(); // main 放弃自己的引用, 但线程还持有
t.detach(); // 即使 detach 也安全, 对象由线程保活
return 0;
}
这段代码即使 detach 也安全:main 里的 d.reset() 只是让计数从 2 降到 1,对象在线程执行完、线程内的 shared_ptr 析构时才释放。shared_ptr 让"线程拥有它用到的数据"成为显式语义,是工程中推荐的做法。
8.2 陷阱:shared_ptr 与引用混用
传 std::shared_ptr 时不要为了省拷贝而用 std::ref 传引用——除非你能绝对保证引用指向的 shared_ptr 对象存活,否则一旦外部的 shared_ptr 析构(引用计数归零、对象释放),线程里解引用就是悬空访问:
void process(std::shared_ptr<Data>& d) // 接收引用
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << d->value << std::endl; // 若外部的 d 已析构, 悬空
}
int main()
{
auto d = std::make_shared<Data>();
std::thread t(process, std::ref(d)); // 传引用包装器
d.reset(); // 计数归零, 对象释放!
t.join(); // 线程解引用已释放的对象, 未定义行为
return 0;
}
按值拷贝 shared_ptr 才是正确姿势——拷贝本身就把线程"拴"在了对象上。
8.3 陷阱:把 this 和 shared_ptr 一起用
类成员函数内部把 shared_from_this() 传给线程是安全的(前提是类继承 std::enable_shared_from_this 且对象由 shared_ptr 管理),它保证"成员函数执行期间对象存活":
#include <iostream>
#include <memory>
#include <thread>
class Worker : public std::enable_shared_from_this<Worker>
{
public:
void start()
{
// 把 shared_ptr 版本的 this 传给线程, 对象由线程保活
std::thread t([self = shared_from_this()] {
self->work();
});
t.detach();
}
void work()
{
std::cout << "working..." << std::endl;
}
};
int main()
{
auto w = std::make_shared<Worker>();
w->start();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return 0; // w 析构, 但线程内的 self 仍持有对象, work 安全执行
}
注意:
shared_from_this()只能在对象已经被某个shared_ptr管理时调用;如果对象是栈上创建的(Worker w;),调用它会抛std::bad_weak_ptr。
9. 传参常见错误汇总
1. 传引用忘了 std::ref,编译失败
void f(int& x) {}
std::thread t(f, a); // 错误! 实参被拷贝成右值, 无法绑定 int&
2. 传 c_str() 给线程,字符串被修改/销毁后悬空
std::string s = "abc";
std::thread t(f, s.c_str()); // 拷贝的是指针, 不是内容
3. 局部变量 + detach,悬空引用/悬空指针
int x = 1;
std::thread t(f, std::ref(x)); // 或 &x
t.detach(); // x 随 main 返回销毁, 线程访问悬空
4. move-only 类型不 move,编译失败
std::unique_ptr<T> p;
std::thread t(f, p); // 错误! unique_ptr 不可拷贝
std::thread t(f, std::move(p)); // 正确
5. 成员函数忘记传对象
std::thread t(&Counter::add, 5); // 错误! 第一个实参必须是对象指针
6. 共享同一可变对象却无同步
void f(int& x) { x++; }
int counter = 0;
std::thread t1(f, std::ref(counter));
std::thread t2(f, std::ref(counter));
// 两个线程并发修改 counter, 数据竞争, 未定义行为
最后一个问题不是传参能解决的——多个线程共享可变数据必须加锁或使用原子操作,这是《数据共享问题与锁》和《原子操作》的主题。
需要进一步学习:


