文章大图来源: pixiv_id=122153784
1 参数传递基本形式
在 C++ 多线程中创建一个线程,我们需要让 std::thread 构造函数接收一个线程接口函数的 函数指针 以及该线程接口函数需要的多个参数。大致形式如下:
| 12
 
 | std::thread t(foo, a, b, ...);
 
 | 
2 按值传递参数
2.1 基本原理
使用按值传递参数传递给线程接口函数时,线程接口函数收到的是 拷贝的参数的副本。
按值传递的方式非常简单直观,而且这种参数传递方式的安全性是比较高的,线程函数内部对参数的操作不会影响原始数据。
但对于大型的数据结构,拷贝的操作往往会消耗较多的时间和空间。
2.2 示例
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 
 | #include <iostream>#include <thread>
 
 
 void foo(int a, std::string s){
 std::cout << a << " " << s << "\n";
 }
 
 int main(){
 int num = 520;
 std::string str = "Marisa";
 
 
 std::thread t(foo, num, str);
 t.join();
 
 return 0;
 }
 
 | 

3 按引用传递参数(std::ref)
3.1 基本原理
在 C++ 多线程中,std::thread 构造函数在传递参数时 默认是按值传递的。这样设定的原因是为了避免一些潜在的问题,比如线程函数意外地修改外部变量导致难以追踪的错误,以及数据竞争等问题。
因此,在 C++ 多线程中,如果我们想要在线程函数内部去修改原始变量的值,传统的传递引用方式是不奏效的。故在线程函数中操作原始变量(通过引用)时,就 需要使用 std::ref
使用 std::ref 之后,我们确实能够对原属数据进行修改等操作,但是我们也改变了 C++ 多线程默认的函数传递参数机制。很多时候,多个线程(包括主线程和子线程)可能同时访问和修改同一个变量或对象,这样的传递方式一定程度上增加了数据竞争的风险。
3.2 示例
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 
 | #include <iostream>#include <thread>
 
 
 void foo(int& a, std::string& s){
 a ++ ;
 s += "Magic";
 std::cout << "foo(): " << a << " " << s << "\n";
 }
 
 int main(){
 int num = 520;
 std::string str = "Marisa";
 
 
 std::thread t(foo, std::ref(num), std::ref(str));
 t.join();
 
 
 std::cout << "main(): " << num << ' ' << str << "\n";
 
 return 0;
 }
 
 | 

4 传递指针作为参数
4.1 基本原理
在 C++ 多线程中,我们也可以指向数据的指针传递给线程函数。在线程函数中,我们可以通过指针来访问和修改数据(有点类似于按引用传递)。
与按引用传递同样的,这也存在着一定的数据竞争风险。
4.2 普通指针示例
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | #include <iostream>#include <thread>
 
 
 void foo(int* p){
 *p += 1;
 std::cout << "foo(): " << *p << " Marisa" << "\n";
 }
 
 int main(){
 int num = 520;
 
 
 std::thread t(foo, &num);
 t.join();
 
 
 std::cout << "main(): " << num << " Marisa" << "\n";
 
 return 0;
 }
 
 | 

4.3 智能指针示例
4.3.1 std::shared_ptr
std::shared_ptr 是一种 引用计数的智能指针。当传递 std::shared_ptr 给线程函数时,多个线程可以共享同一个对象的所有权。这样的机制能够确保对象在所有引用(包括线程中的引用)都释放后才被销毁。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | #include <iostream>#include <thread>
 #include <memory>
 
 void foo(std::shared_ptr<int> p) {
 *p += 1;
 std::cout << "foo(): " << *p << " Marisa" << "\n";
 }
 
 int main() {
 
 std::shared_ptr<int> ptr = std::make_shared<int>(520);
 
 
 std::thread t(foo, ptr);
 t.join();
 
 
 std::cout << "main(): " << *ptr << " Marisa" << "\n";
 
 return 0;
 }
 
 | 

4.3.2 std::unique_ptr
std::unique_ptr 是一种独占式的智能指针。它强调对象的所有权是唯一的,不能被复制。只能调用移动语义,即 std::move
注意在调用了 std::move 之后,主线程中的智能指针 ptr 就失去了所有权,所以不能再在主函数中访问数据了。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | #include <iostream>#include <thread>
 #include <memory>
 
 void foo(std::unique_ptr<int> p) {
 *p += 1;
 std::cout << "foo(): " << *p << " Marisa" << "\n";
 }
 
 int main() {
 
 std::unique_ptr<int> ptr = std::make_unique<int>(520);
 
 
 std::thread t(foo, std::move(ptr));
 t.join();
 
 
 
 
 return 0;
 }
 
 | 

4.4 使用 detach 的风险
当调用 std::thread::detach 时,子线程会在后台独立运行,与创建它的主线程分离。但在函数参数转递指针的情况下,使用 detach() 往往会造成很多问题。
- 
普通指针 在使用 detach()情况下,主线程可能先于子线程结束,那么子线程会尝试访问已经销毁的内存,进而导致程序崩溃。
 
- 
std::shared_ptr 假如主线程创建了一个 std::shared_ptr 并传递给一个被 detach 的线程。主线程先于子线程结束,并且主线程是最后一个持有该智能指针 std::shared_ptr 的地方,那么在主线程结束时,引用计数减少到零,这时候共享对象被销毁了。此时分离的子线程也会尝试访问已销毁的内存,进而导致程序崩溃。 
- 
std::unique_ptr std::unique_ptr 独占所有权,当传递 std::unique_ptr 给一个 detach 的线程时,实际上是将对象的所有权转移到了子线程。如果主线程在子线程完成之前就结束了,这时候也会出现错误(可能丢失对对象生命周期的跟踪,进而产生内存泄漏等复杂问题) 
5 传递临时变量作为参数
5.1 按值传递临时变量示例
当按值传递临时变量给线程函数时,编译器会为线程函数 创建一个临时变量的副本。线程函数可以对这个副本进行各种操作,而 原始的临时变量在传递后就被销毁。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | #include <iostream>#include <thread>
 
 
 void foo(int a, std::string s){
 std::cout << "foo(): " << a << " " << s << "\n";
 }
 
 int main(){
 
 std::thread t(foo, 520, "Marisa");
 t.join();
 
 return 0;
 }
 
 | 

5.2 按引用传递临时变量示例(std::ref)
一般情况下我们无法通过 std::ref 来传递临时变量的引用,即便是临时变量是个表达式,还是会导致报错。因此,通常我们不按引用传递临时变量。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | #include <iostream>#include <thread>
 
 
 void foo(int& a, std::string s){
 std::cout << "foo(): " << a << " " << s << "\n";
 }
 
 int main(){
 
 std::thread t(foo, std::ref(520 + 1), "Marisa");
 t.join();
 
 return 0;
 }
 
 | 

5.3 传递临时对象示例
当传递临时类对象给线程函数时,会涉及到 对象的拷贝 或者 对象的移动:
移动语义 std::move 的目的是将资源从一个对象(通常是即将销毁的临时对象,即 右值)高效地转移到另一个新对象中,通过右值引用参数(&&)来识别可以被移动的对象。当编译器确定一个对象是右值时,就比如临时对象,会优先考虑调用移动构造函数,以避免不必要的拷贝操作。
5.3.1 调用拷贝构造函数传递
在下面的程序中,定义了一个类(Class),显式地定义类中的构造函数、析构函数以及拷贝构造函数。在各个函数中,使用 std::this_thread::get_id() 来打印线程的 id,以辅助理解程序。std::this_thread::get_id()
创建线程采用了通过 类成员函数指针创建线程 的方式。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 
 | #include <iostream>#include <thread>
 
 class MyClass {
 int val;
 public:
 
 MyClass(int n) : val(n){
 std::cout << "Build():" << std::this_thread::get_id() << "\n";
 std::cout << "Build function called" << "\n";
 }
 
 
 ~MyClass(){
 std::cout << "~():" << std::this_thread::get_id() << "\n";
 std::cout << "~ function called" << "\n";
 }
 
 
 MyClass(const MyClass& other){
 val = other.val;
 std::cout << "Copy():" << std::this_thread::get_id() << "\n";
 std::cout << "Copy function called" << "\n";
 }
 
 
 void print_val(){
 std::cout << "print_val():" << std::this_thread::get_id() << "\n";
 std::cout << val << " Marisa" << "\n";
 }
 };
 
 int main(){
 std::cout << "main():" << std::this_thread::get_id() << "\n";
 
 
 std::thread t(&MyClass::print_val, MyClass(520));
 t.join();
 
 return 0;
 }
 
 | 
运行结果如下:

发现,主线程 id、构造函数 id 以及拷贝构造函数 id 均为 0x58;一次析构函数 id 为 0x58。而子线程 id 和 另一次析构函数 id 为 0xb4。
创建线程调用了一次构造函数,并调用了一次拷贝构造函数。两个不同构造函数的调用都是在主线程中就执行的,因此两个函数中的线程 id 一样且和主线程 id 也一样。
构造函数对应地调用一次析构函数,这一次调用析构函数也是在主线程中执行的,因此 线程 id 和主线程一样。
拷贝构造函数对应地调用一次析构函数,而此次调用析构函数是在线程接口函数访问完 拷贝的 Myclass 对象 之后进行调用的,因此,此时的析构函数 线程 id 和线程接口函数的线程 id 一样。
5.3.2 调用移动构造函数传递
接下来,我们在 MyClass 对象中显式声明移动构造函数,这样就 会优先去调用移动构造函数,而 不会去调用拷贝构造函数。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 
 | #include <iostream>#include <thread>
 #include <chrono>
 
 class MyClass {
 int val;
 public:
 
 MyClass(int n) : val(n){
 std::cout << "Build() thread id: " << std::this_thread::get_id() << "\n";
 std::cout << "Build function called" << "\n";
 }
 
 
 ~MyClass(){
 std::cout << "~() thread id: " << std::this_thread::get_id() << "\n";
 std::cout << "~ function called" << "\n";
 }
 
 
 MyClass(const MyClass& other){
 val = other.val;
 std::cout << "Copy() thread id: " << std::this_thread::get_id() << "\n";
 std::cout << "Copy function called" << "\n";
 }
 
 
 MyClass(MyClass&& other){
 val = other.val;
 std::cout << "Move() thread id: " << std::this_thread::get_id() << "\n";
 std::cout << "Move funtion called" << "\n";
 }
 
 
 void print_val(){
 std::cout << "print_val() thread id: " << std::this_thread::get_id() << "\n";
 std::this_thread::sleep_for(std::chrono::seconds(1));
 std::cout << val << " Marisa" << "\n";
 }
 };
 
 int main(){
 std::cout << "main() thread id: " << std::this_thread::get_id() << "\n";
 
 
 std::thread t(&MyClass::print_val, MyClass(520));
 t.join();
 
 return 0;
 }
 
 | 
运行结果如下:

发现,主线程 id、构造函数 id 以及拷贝构造函数 id 均为 0xd0;一次析构函数 id 为 0xd0。而子线程 id 和 另一次析构函数 id 为 0xd4。
和调用拷贝构造函数的情况部分地方类似。
构造函数对应地调用一次析构函数,这一次调用析构函数也是在主线程中执行的,因此 线程 id 和主线程一样。
移动构造函数对应地调用一次析构函数,此次调用析构函数是在线程接口函数访问完 移动过来的 Myclass 对象 之后进行调用的,因此,此时的析构函数 线程 id 和线程接口函数的线程 id 一样。
有所区别的是,在移动构造函数被调用后,这个临时对象就会被析构(构造函数对应地一次析构函数调用),因为它的资源已经被移动到了另一个线程执行的对象中。
5.3.3 传递 STL 临时对象
在 C++ 标准模板库(STL)中,许多容器和类都定义了移动构造函数。例如,std::vector、std::string 等常见的 STL 类型都有移动构造函数。可见移动语义在提高性能方面有重要作用。
因此,当传递临时 STL 对象给创建的 std::thread 线程的接口函数时,会优先调用移动构造函数。
需要注意的是代码中 std::string 对象,如果使用 std::string("Marisa") 这种形式传递参数时,会 自动触发移动语义;
但是如果单纯写成 "Marisa" 的形式,那么会会被 隐式地转换为std::string类型,但这个过程通常会 调用 std::string 的构造函数 来创建一个新的std::string对象,而不是移动语义。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 
 | #include <iostream>#include <vector>
 #include <thread>
 
 void foo(std::vector<int> v, std::string s){
 std::cout << "I love " << s << "\n";
 for(const auto &x : v) std::cout << x << " ";
 std::cout << "\n";
 }
 
 int main(){
 
 std::thread t(foo, std::vector<int>{520, 1314}, std::string("Marisa"));
 t.join();
 
 return 0;
 }
 
 | 
