C++ 从入门到精通之十

大纲

C++ 并发编程

线程开始和结束运行

特别注意

  • 进程是否执行完毕,是以主线程是否执行完为标志的。主线程一旦结束,整个进程就会终止,未执行完的子线程也会被操作系统强制结束。因此,若希望子线程继续运行,那么就必须保持主线程不结束。
  • 值得一提的是,只有当子线程被设计为必须完成的核心任务时,主线程提前结束导致子线程被操作系统强杀,这才属于程序缺陷或不合格的程序设计;若子线程设计为守护线程或完成辅助性任务,其随主线程终止属于正常行为。

thread 与 join () 的使用

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
#include <chrono>
#include <iostream>
#include <thread>

void func() {
std::cout << "sub thread start." << std::endl;
for (int i = 1; i <= 5; ++i) {
std::cout << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 创建并启动一个普通线程
std::thread t(func);

// 阻塞主线程,让主线程等待子线程执行结束,然后再往下执行
// 注意:如果这里不让主线程阻塞等待子线程执行结束,会导致程序意外退出(直接崩溃),除非子线程调用 detach()
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
main thread start.
sub thread start.
1
2
3
4
5
sub thread end.
main thread end.

thread 与 detach () 的使用

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
#include <chrono>
#include <iostream>
#include <thread>

void func() {
std::cout << "sub thread start." << std::endl;
for (int i = 1; i <= 5; ++i) {
std::cout << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 创建并启动一个普通线程
std::thread t(func);

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下,可以发现在子线程还未执行完,进程(主线程)就已经正常退出:

1
2
3
4
main thread start.
main thread end.
sub thread start.
1

特别注意

在 C++ 中,子线程调用 detach() 后,std::thread 对象与底层线程的关联会被切断,子线程转为后台运行(即变成守护线程),由 C++ 运行时接管。子线程结束运行后,C++ 运行时会自动清理其资源。特别注意,子线程调用 detach() 后,不能再调用 join(),否则程序会异常退出(崩溃);子线程是否可以调用 join(),可以调用 joinable() 来判断

线程创建的其他方式

使用函数对象创建线程

使用函数对象(仿函数)创建线程

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 <chrono>
#include <iostream>
#include <thread>

class MyClass {
public:
// 构造函数
MyClass() {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass& mc) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

// 重载 () 操作运算符
void operator()() {
std::cout << "sub thread start." << std::endl;
for (int i = 1; i <= 5; ++i) {
std::cout << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "sub thread end." << std::endl;
}

};

int main() {
std::cout << "main thread start." << std::endl;

// 定义函数对象(仿函数)
MyClass mc;

// 创建并启动一个普通线程
// 注意:这里底层会将 mc 对象复制到子线程里面去,因此即使 mc 对象提前销毁,也不会影响子线程的执行
std::thread t(mc);

// 阻塞主线程,让主线程等待子线程执行结束,然后再往下执行
// 注意:如果这里不让主线程阻塞等待子线程执行结束,会导致程序意外退出(直接崩溃),除非子线程调用 detach()
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
main thread start.
MyClass()
MyClass(const MyClass &) // 将主线程中的 MyClass 对象复制进子线程中
sub thread start.
1
2
3
4
5
sub thread end.
~MyClass() // 这里析构的是复制进子线程的 MyClass 对象
main thread end.
~MyClass() // 这里析构的是主线程中定义的 MyClass 对象 mc

特别注意

在上面的代码中,会额外调用一次类对象的拷贝构造函数,导致多拷贝一个类对象。若希望避免多拷贝一份类对象,可以改为使用 std::thread t(std::ref(mc)); 来创建子线程。

使用函数对象创建线程时,应避免引用局部变量

在下面这段代码中,最核心问题是:MyClass 的构造函数接收 int& 引用,该引用绑定到 main 函数的局部变量 i。当子线程执行 detach() 后,主线程可能先于子线程结束运行,导致局部变量 i 被销毁,而子线程中的 m_i 仍持有该悬空引用,后续在 operator() 中访问 m_i 时将产生未定义行为(通常是程序崩溃、数据错乱或看似正常等)。特别注意,在使用函数对象(仿函数)创建线程时,应避免引用局部变量,否则可能会导致悬空引用(发生未定义行为)

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
51
52
53
#include <chrono>
#include <iostream>
#include <thread>

class MyClass {
public:
// 构造函数
MyClass(int &i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

// 重载 () 操作运算符
void operator()() {
std::cout << "sub thread start." << std::endl;
for (int i = 1; i <= 5; ++i) {
std::cout << i << ", " << m_i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "sub thread end." << std::endl;
}

private:
int &m_i; // 引用
};

int main() {
std::cout << "main thread start." << std::endl;

int i = 100;

// 定义函数对象(引用了局部变量)
MyClass mc(i);

// 创建并启动一个普通线程
// 注意:这里底层会将 mc 对象复制到子线程里面去,因此即使 mc 对象提前销毁,也不会影响子线程的执行
std::thread t(mc);

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下,可以发现在子线程还未执行完,进程(主线程)就已经正常退出,并且 m_i 成员变量输出的值可能是错误的:

1
2
3
4
5
6
7
8
9
main thread start.
MyClass()
MyClass(const MyClass &) // 将主线程中的 MyClass 对象复制进子线程中
main thread end.
~MyClass() // 这里析构的是主线程中定义的 MyClass 对象 mc
sub thread start.
sub thread start.
1, 22088 // 由于在主线程中,MyClass 对象引用了局部变量,因此这里输出的 m_i 成员变量的值可能是错误的;因为主线程先结束运行,局部变量 i 所在的栈内存会跟着提前被释放掉
1, 22088 // 由于在主线程中,MyClass 对象引用了局部变量,因此这里输出的 m_i 成员变量的值可能是错误的;因为主线程先结束运行,局部变量 i 所在的栈内存会跟着提前被释放掉

使用 Lambda 创建线程

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
#include <chrono>
#include <iostream>
#include <thread>

int main() {
std::cout << "main thread start." << std::endl;

// 定义 Lambda 表达式
auto lambda = [] {
std::cout << "sub thread start." << std::endl;
for (int i = 1; i <= 5; ++i) {
std::cout << i << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
std::cout << "sub thread end." << std::endl;
};

// 创建并启动一个普通线程
// 注意:这里底层会将 mc 对象复制到子线程里面去,因此即使 mc 对象提前销毁,也不会影响子线程的执行
std::thread t(lambda);

// 阻塞主线程,让主线程等待子线程执行结束,然后再往下执行
// 注意:如果这里不让主线程阻塞等待子线程执行结束,会导致程序意外退出(直接崩溃),除非子线程调用 detach()
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
7
8
9
main thread start.
sub thread start.
1
2
3
4
5
sub thread end.
main thread end.

使用成员函数指针创建线程

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
51
52
53
54
#include <iostream>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

// 成员函数
void thread_run(MyClass &mc) {
std::cout << "sub thread start." << std::endl;
// 主线程中使用 std::ref() 强制传递引用作为线程函数参数后,这里修改的是主线程中的 m_c 对象
mc.m_i = 300;
std::cout << "sub thread end." << std::endl;
}

public:
int m_i;
};

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
MyClass m_c(200);

// 在创建子线程时,使用 std::ref() 强制传递引用作为线程函数参数
// 第一个参数是成员函数指针,第二个参数是类对象(可以是指针或者引用),第三个参数是真正传递给线程函数的参数
std::thread t(&MyClass::thread_run, &m_c, std::ref(m_c));

// 等价于上面的写法
// std::thread t(&MyClass::thread_run, std::ref(m_c), std::ref(m_c));

// 注意:如果使用下面这种写法(合法),又会多拷贝一份 m_c 对象,导致线程函数里修改的不是主线程中的 m_c 对象
// std::thread t(&MyClass::thread_run, m_c, std::ref(m_c));

// 主线程需等待子线程执行完成才继续往下执行
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
main thread start.
MyClass()
sub thread start.
sub thread end.
main thread end.
~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
#include <iostream>
#include <thread>

// 定义普通函数(带参数)
void func(const int &i, char *buf) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << buf << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
int m_i = 1;
char m_buf[] = "this is a string";

// 创建线程,并传递参数
std::thread t(func, m_i, m_buf);

// 阻塞主线程,让主线程等待子线程执行结束,然后再往下执行
// 注意:如果这里不让主线程阻塞等待子线程执行结束,会导致程序意外退出(直接崩溃),除非子线程调用 detach()
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行输出的结果如下:

1
2
3
4
5
6
main thread start.
sub thread start.
1
this is a string
sub thread end.
main thread end.
要避免的陷阱一

在下面的这段代码中,存在一个严重的悬垂指针问题,这是 C++ 多线程开发中非常经典的错误。核心问题是:参数 m_buf 传递的是指针,但在子线程执行 detach() 后,主线程可能先结束运行,导致局部变量 m_buf 被销毁,最终子线程会访问已释放的内存。

  • (1) 代码安全的部分:int & 参数

    • 虽然函数签名是 const int &i,但实际上 std::thread 构造函数会对参数做按值拷贝(除非显式使用 std::ref
    • 即使 detach() 后主线程销毁了 m_i,子线程持有的也是独立副本,访问是安全的
  • (2) 代码不安全的部分:char * 参数

    • m_buf 是局部变量,存储在
    • std::thread 只是传递了数组首地址(指针值被拷贝),并没有拷贝字符串内容
    • 当主线程执行完 return 0 后,m_buf 的内存会被释放 / 覆盖
    • 子线程若此时继续访问 buf,将发生未定义行为(程序可能会崩溃、输出乱码、看似正常等)

问题代码

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
#include <iostream>
#include <thread>

// 定义普通函数(带参数)
void func(const int &i, char *buf) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << buf << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
int m_i = 100;
char m_buf[] = "this is a string";

// 创建线程,并传递参数
std::thread t(func, m_i, m_buf);

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}

解决方案

  • 解决方案一:使用 join() 替代 detach(),让主线程等待子线程结束
1
2
3
4
5
6
7
8
9
10
11
12
13
void func(const int &i, char *buf) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << buf << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
int m_i = 100;
char m_buf[] = "this is a string";
std::thread t(func, m_i, m_buf);
t.join(); // 主线程等待子线程结束
}
  • 解决方案二:传递字符串副本
1
2
3
4
5
6
7
8
9
10
11
12
13
void func(const int &i, char *buf) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << buf << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
int m_i = 100;
char m_buf[] = "this is a string";
std::thread t(func, m_i, std::string(m_buf)); // 使用临时对象
t.detach();
}
  • 解决方案三:使用智能指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void func(const int &i, char *buf) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << buf << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
int m_i = 100;
char m_buf[] = "this is a string";
auto sp_buf = std::make_shared<char[]>(strlen(m_buf) + 1);
strcpy(sp_buf.get(), m_buf);

std::thread t([m_i, sp_buf]() {
func(m_i, sp_buf.get());
});
t.detach();
}
  • 解决方案四:使用值传递而不是引用专递(最佳实践
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 最安全的写法(不使用引用或指针,直接传递值)
void func(const int i, const std::string buf) {
std::cout << i << std::endl;
std::cout << buf << std::endl;
}

int main() {
int m_i = 100;
char m_buf[] = "this is a string";

// 传递字符串副本(使用临时对象)
std::thread t(func, m_i, std::string(m_buf));

// 现在执行 detach() 安全了,因为 string 已经被拷贝到线程内部
t.detach();

return 0;
}

总结

  • 本质问题:代码将指向局部栈数组的指针传递给分离线程(子线程),主线程退出后子线程访问已销毁的内存,导致未定义行为。
  • 类似风险:传递局部对象的引用、指向局部对象的指针、迭代器等给调用过 detach() 的子线程(分离线程)。
  • 最佳实践:除非明确知道对象生命周期大于线程生命周期,否则应避免在调用过 detach() 的子线程中使用指针或者引用,尽量传递值类型或智能指针;join()detach() 更安全,能明确控制生命周期。
要避免的陷阱二

在下面的这段代码中,存在一个严重的悬空引用问题,这是 C++ 多线程开发中非常经典的错误。核心问题是:由于 m_c 局部变量通过隐式类型转换构造 MyClass 临时对象的时机不确定,因此代码是不安全的。

  • (1) 隐式类型转换的细节:

    • func() 期望第二个参数是 const MyClass &,但传递的是 int,这里会发生隐式类型转换,也就是编译器会用 m_c(200)调用 MyClass(int) 构造函数创建一个临时对象。
  • (2) 代码安全的部分:int & 参数

    • 虽然函数签名是 const int &i,但实际上 std::thread 构造函数会对参数做按值拷贝(除非显式使用 std::ref
    • 即使 detach() 后主线程销毁了 m_i,子线程持有的也是独立副本,访问是安全的
  • (3) 代码不安全的部分:MyClass & 参数

    • m_c(200)会通过隐式类型转换构造一个 MyClass 临时对象
    • 但是 std::thread 并不是立即执行线程函数,它会先拷贝或移动参数到内部存储
    • 当线程函数真正执行时,MyClass 临时对象的构造时机可能已经晚于主线程中 m_c 的销毁
    • 因此 const MyClass &mc 可能绑定到一个未正确构造的 MyClass 对象
    • 这会导致悬空引用,访问 mc 属于未定义行为(通常是程序崩溃、数据错乱或看似正常等)

问题代码(使用引用 + 隐式类型转换)

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
51
52
53
54
#include <iostream>
#include <string>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

int get() const {
return m_i;
}

private:
int m_i;
};

// 定义普通函数(带参数)
void func(const int &i, const MyClass &mc) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << mc.get() << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
int m_i = 100;
int m_c = 200;

// 创建线程,并传递参数(这里的 m_c 会通过隐式类型转换构造一个 MyClass 临时对象)
// 注意:这里的 MyClass 临时对象是在子线程中构造出来的,而且有可能是在主线程结束运行之后才构造,因此是不安全的
std::thread t(func, m_i, m_c);

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}

解决方案

  • 解决方案一:使用引用 + 手动构造栈对象
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
51
52
53
54
#include <iostream>
#include <string>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

int get() const {
return m_i;
}

private:
int m_i;
};

// 定义普通函数(带参数)
void func(const int &i, const MyClass &mc) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << mc.get() << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
int m_i = 100;
MyClass m_c(200); // 栈上的对象(临时对象)

// 创建线程,并传递参数
// 在创建子线程时,会拷贝参数 m_c 到子线程的内部存储中,因此在执行 detach() 后继续访问 m_c 是安全的
std::thread t(func, m_i, m_c);

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}
  • 解决方案二:使用引用 + 手动构造临时对象(最佳实践
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
51
52
53
54
#include <iostream>
#include <string>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

int get() const {
return m_i;
}

private:
int m_i;
};

// 定义普通函数(带参数)
void func(const int &i, const MyClass &mc) {
std::cout << "sub thread start." << std::endl;
std::cout << i << std::endl;
std::cout << mc.get() << std::endl;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
int m_i = 100;
int m_c = 200;

// 创建线程,并传递参数(构造一个 MyClass 临时对象)
// 注意:这里的 MyClass 临时对象是在主线程中构造出来的,而且是在主线程结束运行之前构造出来,因此是安全的
std::thread t(func, m_i, MyClass(m_c));

// 将主线程与子线程分离(即子线程变为守护线程),主线程无需等待子线程执行完成才继续往下执行
t.detach();

std::cout << "main thread end." << std::endl;
return 0;
}
参数传递的最佳实践
  • 建议不要使用 detach(),只使用 join()。这样可以避免因局部变量失效而导致子线程访问非法内存的问题。
  • 如果确实要使用 detach(),应遵守以下规则:
    • 传递基础类型(如 int)给线程时,建议使用值传递,不要使用引用或指针。
    • 传递类对象(如 MyClass)给线程时:
      • (1) 应避免隐式类型转换
      • (2) 建议在创建线程的同一行代码中直接构建临时对象,并在线程函数的参数列表中使用引用来接收该对象,这样做可以避免一次额外的类对象拷贝构造(示例代码如下)
        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
        // 自定义类
        class MyClass {
        public:
        MyClass(int i) : m_i(i) {

        }

        MyClass(const MyClass &mc) : m_i(mc.m_i) {

        }

        int get() const {
        return m_i;
        }

        private:
        int m_i;
        };

        // 基础类型使用值传递,类对象使用引用传递
        void func(const int i, const MyClass &mc) {
        std::cout << i << std::endl;
        std::cout << mc.get() << std::endl;
        }

        int main() {
        int m_i = 100;
        int m_c = 200;

        // 创建线程时,在创建线程的同一行代码中直接构建临时对象
        std::thread t(func, m_i, MyClass(m_c));

        // 设置主线程无需等待子线程执行结束才继续往下执行
        t.detach();

        return 0;
        }

传递类对象、智能指针作为线程参数

下面这段代码是安全的(因为使用 join() 而不是 detach()),但线程函数里面修改的不是主线程中的 m_c 对象。因为在子线程创建时,已经将主线程中的 m_c 对象拷贝到子线程的内部存储中,这里的 MyClass &mc 引用绑定的是这份拷贝,所以修改不会影响主线程中的 m_c 对象。思考问题:如何才能够在子线程中正确修改主线程中的 mc 对象呢?

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
#include <iostream>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

public:
mutable int m_i;
};

// 定义普通函数(带参数)
void func(const MyClass &mc) {
std::cout << "sub thread start." << std::endl;
// 注意:子线程创建时已经将 m_c 对象拷贝到子线程的内部存储中,这里的 mc 引用绑定的是这份拷贝,因此修改不会影响主线程中的 m_c 对象
mc.m_i = 300;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
MyClass m_c(200);

// 在创建子线程时,会拷贝 m_c 对象到子线程的内部存储中
std::thread t(func, m_c);

// 主线程需等待子线程执行完成才继续往下执行
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
7
8
main thread start.
MyClass()
MyClass(const MyClass &)
sub thread start.
sub thread end.
~MyClass()
main thread end.
~MyClass()

解决方案一,使用 std::ref() 强制传递引用作为线程函数参数,避免多拷贝一份类对象

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
#include <iostream>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

public:
int m_i;
};

// 定义普通函数(带参数)
void func(MyClass &mc) {
std::cout << "sub thread start." << std::endl;
// 主线程中使用 std::ref() 强制传递引用后,这里修改的是主线程中的 m_c 对象
mc.m_i = 300;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
MyClass m_c(200);

// 在创建子线程时,使用 std::ref() 强制传递引用作为线程函数参数
std::thread t(func, std::ref(m_c));

// 主线程需等待子线程执行完成才继续往下执行
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
main thread start.
MyClass()
sub thread start.
sub thread end.
main thread end.
~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
#include <iostream>
#include <memory>
#include <thread>

class MyClass {
public:
// 有参构造函数
MyClass(int i) : m_i(i) {
std::cout << "MyClass()" << std::endl;
}

// 拷贝构造函数
MyClass(const MyClass &mc) : m_i(mc.m_i) {
std::cout << "MyClass(const MyClass &)" << std::endl;
}

// 析构函数
~MyClass() {
std::cout << "~MyClass()" << std::endl;
}

public:
int m_i;
};

// 定义普通函数(带参数)
void func(std::unique_ptr<MyClass> up) {
std::cout << "sub thread start." << std::endl;
// 主线程中传递智能指针后,这里修改的是主线程中的 m_c 对象
up->m_i = 300;
std::cout << "sub thread end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

// 局部变量
std::unique_ptr<MyClass> m_c(new MyClass(200));

// 在创建子线程时,传递智能指针作为线程函数参数
std::thread t(func, std::move(m_c));

// 主线程需等待子线程执行完成才继续往下执行
t.join();

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
main thread start.
MyClass()
sub thread start.
sub thread end.
~MyClass()
main thread end.

并发与多线程

创建和等待多个线程

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
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

void func(int num) {
std::cout << "sub thread " << num << " start." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "sub thread " << num << " end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

std::vector<std::thread> threads;

// 创建 10 个线程,同时这 10 个线程会立即执行(多个线程之间的启动顺序是不确定的)
for (int i = 0; i < 10; i++) {
threads.emplace_back(std::thread(func, i));
}

// 主线程等待所有线程执行完毕,然后才继续往下执行
for (std::vector<std::thread>::iterator it = threads.begin(); it != threads.end(); ++it) {
it->join();
}

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
main thread start.
sub thread 0 start.
sub thread 1 start.
sub thread 2 start.
sub thread 3 start.
sub thread 5 start.
sub thread 4 start.
sub thread 6 start.
sub thread 8 start.
sub thread 7 start.
sub thread 9 start.
sub thread 0 end.
sub thread 1 end.
sub thread 2 end.
sub thread 3 end.
sub thread 5 end.
sub thread 4 end.
sub thread 6 end.
sub thread 8 end.
sub thread 7 end.
sub thread 9 end.
main thread end.

数据共享问题的分析

只读的数据

关键点

  • 在多线程环境下,只读的共享数据是不存在线程安全问题的,不需要特殊的处理手段,直接读就可以。
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
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

// 共享数据(只读)
std::vector<int> g_v = {1, 2, 3};

void func(int num) {
std::cout << "sub thread " << num << " start." << std::endl;
std::cout << "sub thread " << num << " read " << g_v[0] << ", " << g_v[1] << ", " << g_v[2] << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::cout << "sub thread " << num << " end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

std::vector<std::thread> threads;

// 创建 5 个线程,同时这 5 个线程会立即执行(多个线程之间的启动顺序是不确定的)
for (int i = 0; i < 5; i++) {
threads.emplace_back(std::thread(func, i));
}

// 等待所有线程执行完毕
for (std::vector<std::thread>::iterator it = threads.begin(); it != threads.end(); ++it) {
it->join();
}

std::cout << "main thread end." << std::endl;
return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
main thread start.
sub thread 0 start.
sub thread 0 read 1, 2, 3
sub thread 1 start.
sub thread 1 read 1, 2, 3
sub thread 2 start.
sub thread 2 read 1, 2, 3
sub thread 3 start.
sub thread 3 read 1, 2, 3
sub thread 4 start.
sub thread 4 read 1, 2, 3
sub thread 2 end.
sub thread 1 end.
sub thread 0 end.
sub thread 3 end.
sub thread 4 end.
main thread end.
有读有写

关键点

  • 在多线程环境下,有读有写的共享数据是存在线程安全问题的(比如 3 个线程同时读数据,2 个线程同时写数据),需要特殊的处理手段。
  • 常见的处理手段:针对共享数据,使用互斥锁(如 std::mutex)或读写锁(std::shared_mutex)来保证读读不互斥、读写和写写互斥。
  • 问题代码的简单分析
    • 以下代码展示了在多线程环境下,多个线程同时读写共享数据 std::vector<int> g_v 而没有使用任何同步机制,从而产生线程安全问题(数据竞争)。
    • 前 2 个线程执行写操作(修改 vector 内容),后 3 个线程执行读操作(读取 vector 内容)。由于读写同时发生,可能会导致未定义行为(比如读取到不一致的值、程序崩溃等)。
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
51
52
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

// 共享数据(有读有写)
std::vector<int> g_v = {1, 2, 3};

void func(int num) {
std::cout << "sub thread " << num << " start." << std::endl;

// 前两个线程:执行写操作
if (num < 2) {
// 写操作:添加一个新元素
g_v.push_back(num);
std::cout << "sub thread " << num << " write value " << num << std::endl;
}
// 后三个线程:执行读操作
else {
// 读操作:读取 vector 的所有元素
std::cout << "sub thread " << num << " reads: ";
for (int val : g_v) {
std::cout << val << " ";
}
std::cout << std::endl;
}

// 模拟一些工作耗时
std::this_thread::sleep_for(std::chrono::milliseconds(50));

std::cout << "sub thread " << num << " end." << std::endl;
}

int main() {
std::cout << "main thread start." << std::endl;

std::vector<std::thread> threads;

// 创建 5 个线程,其中 2 个线程执行写操作,3 个线程执行读操作
// 由于读写同时发生,可能会导致未定义行为(比如读取到不一致的值、程序崩溃等)
for (int i = 0; i < 5; i++) {
threads.emplace_back(func, i);
}

// 等待所有线程执行完毕
for (auto& t : threads) {
t.join();
}

std::cout << "main thread end." << std::endl;
return 0;
}
  • 存在的线程安全问题
    • (1) 数据竞争:写线程修改 g_v(例如 push_back 可能会引发 vector 重新分配内存)时,读线程同时遍历 g_v,会导致读线程访问到无效的内存或未更新的状态。
    • (2) 不一致的读取:读线程可能在写线程修改过程中读取到部分旧数据、部分新数据,甚至因迭代器失效导致程序崩溃。
    • (3) 未定义行为:根据 C++ 标准,并发读写同一非原子对象且无同步机制时,程序存在未定义行为,可能的表现如下:
      • 程序可能正常输出混乱的结果
      • 程序可能抛出异常(如 vector 迭代器失效)
      • 程序可能直接崩溃(段错误等)

共享数据的保护案例

案例背景说明

在网络游戏服务器的设计中,共享数据的保护是一个典型案例:可以创建两个线程,其中一个线程负责收集玩家的命令并将命令数据写入队列,另一个线程则从队列中取出玩家发来的命令,进行解析并执行玩家所需的动作。值得一提的是,在当前业务场景下建议使用生产者消费者模型来实现,并使用 list 容器作为队列。

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <list>
#include <mutex>
#include <thread>

class MyClass {
public:
// 将收到的玩家命令写入队列(生产者)
void inMsgRecvQueue() {
for (int i = 0; i < 1000; i++) {
{
// 获取互斥锁(作用域结束后会自动释放锁)
std::lock_guard<std::mutex> lock(msgRecvQueueMutex);

// 将玩家命令写入队列
msgRecvQueue.push_back(i);
}

// 唤醒等待操作队列的其他线程
queueNotEmptyCondition.notify_one();

// 模拟网络收包间隔
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

// 通知消费者退出
{
std::lock_guard<std::mutex> lock(msgRecvQueueMutex);
stop = true;
}

// 唤醒所有等待线程
queueNotEmptyCondition.notify_all();
}

// 从队列中读取玩家命令(消费者)
void outMsgRecvQueue() {
while (true) {
// 获取互斥锁(作用域结束后会自动释放锁)
std::unique_lock<std::mutex> lock(msgRecvQueueMutex);

// 当前线程等待队列不为空或者收到退出信号
queueNotEmptyCondition.wait(lock, [this]() { return stop || !msgRecvQueue.empty(); });

// 收到退出信号且队列为空
if (stop && msgRecvQueue.empty()) {
break;
}

// 取出队列元素
int command = msgRecvQueue.front();

// 移除队列元素
msgRecvQueue.pop_front();

// 尽早释放锁,提高并发性能
lock.unlock();

// 解析并执行玩家命令
handleCommand(command);
}
}

private:
// 模拟处理玩家命令
void handleCommand(int command) {
std::cout << "已处理玩家命令: " << command << std::endl;
}

private:
std::list<int> msgRecvQueue; // 消息队列(共享数据)
std::mutex msgRecvQueueMutex; // 保护消息队列线程安全的互斥锁
std::condition_variable queueNotEmptyCondition; // 消息队列不为空的条件
bool stop = false; // 线程退出标志
};

int main() {
// 局部变量
MyClass mc;

// 创建并启动写线程(生产者)
std::thread t_write(&MyClass::inMsgRecvQueue, &mc);

// 创建并启动读线程(消费者)
std::thread t_read(&MyClass::outMsgRecvQueue, &mc);

// 等待写线程执行完毕
t_write.join();

// 等待读线程执行完毕
t_read.join();

return 0;
}

程序运行的结果如下:

1
2
3
4
5
6
7
8
9
已处理玩家命令: 0
已处理玩家命令: 1
已处理玩家命令: 2
已处理玩家命令: 3
......
已处理玩家命令: 996
已处理玩家命令: 997
已处理玩家命令: 998
已处理玩家命令: 999

list 容器和 vector 容器的适用场景

容器适用场景(效率高)不适用场景(效率低)
list- 任意位置插入 / 删除(给定迭代器)
- 从头部 / 尾部操作
- 合并两个列表
- 随机访问(不支持 []
- 查找元素
- 遍历(缓存不友好)
vector- 尾部插入 / 删除
- 随机访问(下标访问)
- 遍历(缓存友好)
- 头部 / 中间插入 / 删除
- 插入导致重新分配内存

提示

  • C++ 的 list 容器在任意位置频繁插入和删除数据时效率高(尤其是中间位置),因为它只需调整前后节点的指针,不需要移动其他元素。
  • C++ 的 vector 容器在尾部频繁插入和删除数据时效率高(平均时间复杂度 O(1)),但在头部或中间插入 / 删除数据时效率低,因为需要移动后续所有元素。