文章大图来源: pixiv_id=122153784
1 参数传递基本形式
在 C++ 多线程中创建一个线程,我们需要让 std::thread 构造函数接收一个线程接口函数的 函数指针 以及该线程接口函数需要的多个参数。大致形式如下:
1 2
| std::thread t(foo, a, b, ...);
|
2 按值传递参数
2.1 基本原理
使用按值传递参数传递给线程接口函数时,线程接口函数收到的是 拷贝的参数的副本。
按值传递的方式非常简单直观,而且这种参数传递方式的安全性是比较高的,线程函数内部对参数的操作不会影响原始数据。
但对于大型的数据结构,拷贝的操作往往会消耗较多的时间和空间。
2.2 示例
1 2 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 示例
1 2 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 普通指针示例
1 2 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 给线程函数时,多个线程可以共享同一个对象的所有权。这样的机制能够确保对象在所有引用(包括线程中的引用)都释放后才被销毁。
1 2 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 就失去了所有权,所以不能再在主函数中访问数据了。
1 2 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 按值传递临时变量示例
当按值传递临时变量给线程函数时,编译器会为线程函数 创建一个临时变量的副本。线程函数可以对这个副本进行各种操作,而 原始的临时变量在传递后就被销毁。
1 2 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 来传递临时变量的引用,即便是临时变量是个表达式,还是会导致报错。因此,通常我们不按引用传递临时变量。
1 2 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()
创建线程采用了通过 类成员函数指针创建线程 的方式。
1 2 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 对象中显式声明移动构造函数,这样就 会优先去调用移动构造函数,而 不会去调用拷贝构造函数。
1 2 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对象,而不是移动语义。
1 2 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; }
|
