「C++ 多线程」std::thread 线程函数参数传递

文章大图来源: pixiv_id=122153784

1 参数传递基本形式

在 C++ 多线程中创建一个线程,我们需要让 std::thread 构造函数接收一个线程接口函数的 函数指针 以及该线程接口函数需要的多个参数。大致形式如下:

1
2
// 伪代码。第一个参数 foo 即函数指针,后面的a,b,...参数依次为线程接口函数所需参数
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; // 注意 *p ++ 是先 p ++ 再取值。*p += 1 是先取值再 += 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() {
// shared_ptr 智能指针
std::shared_ptr<int> ptr = std::make_shared<int>(520);

// 传递 shared_ptr 智能指针
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() {
// unique_ptr 智能指针
std::unique_ptr<int> ptr = std::make_unique<int>(520);

// 传递 unique_ptr 智能指针
std::thread t(foo, std::move(ptr));
t.join();

// 调用 std::move 后,ptr 失去了所有权,因此不能再访问
// std::cout << "main(): " << *ptr << " Marisa" << "\n";

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"); // ERROR
t.join();

return 0;
}

5.3 传递临时对象示例

当传递临时类对象给线程函数时,会涉及到 对象的拷贝 或者 对象的移动:

  • 如果线程函数 按值传递对象,并且 没有移动构造函数 或者 不适合调用移动语义 时,那么会调用对象的 拷贝构造函数 来拷贝创建一个副本。

  • 如果对象支持 移动语义,或者这个对象中显式地声明了移动构造函数,那么会通过调用 移动语义 std::move 来传递对象。这样避免了不必要地拷贝。

移动语义 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";

// 传递一个临时 MyClass 对象(通过类成员函数指针创建线程)
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";

// 传递一个临时 MyClass 对象(通过类成员函数指针创建线程)
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(){
// 传递 stl 临时对象(优先调用移动构造函数)
std::thread t(foo, std::vector<int>{520, 1314}, std::string("Marisa"));
t.join();

return 0;
}


「C++ 多线程」std::thread 线程函数参数传递
https://marisamagic.github.io/2024/12/19/20241219/
作者
MarisaMagic
发布于
2024年12月19日
许可协议