C++11 多线程编程基础(上)

参考的学习视频:

【视频 - C++11 多线程编程 - 小白零基础到手撕线程池】

【视频 - C++11 多线程并发 基础入门教程 1.1 创建线程(thread)】

课程学习目录:

multi_thread

一、std::thread线程库

01.基础知识

进程:正在运行中的程序(是动态的)

线程:可以让OS在同一时刻运行多个函数(进程中的进程)

OS可以有多个进程,一个进程也可以有多个线程

程序可利用的线程最大数量,取决于你环境的CPU核数

为什么要使用线程?

单线程是串行执行,效率是比较低的

利用好多线程可提高程序的运行效率


02.常见用法

#include

std::thread t(function_name, args…);

std::thread th(func, ...);

这里创建了一个名为th的线程,入口函数是func; 并且可以给入口函数传参;

th.join();

让整个程序在这个函数卡住,阻塞在这;

让主线程等待,直到子线程执行完毕;

th.detach();

让主线程与子线程分离,此时主线程是可以结束,子线程是挂在后台的;

注意:我们需要确保线程不会在主线程结束前退出,否则可能会导致未定义行为错误

一般是配合多进程使用;

th.joinable();

返回bool值,用于判断线程是否可以使用join()detach()方法;

其他用法:【视频 C++ 多线程并发 基础入门教程 1.1 创建线程(thread)】

参考:

【文档 std::thread - cppreference】

【文档 thead线程库的基本使用】


03.程序示例

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

void printT(std::string msg) {
std::cout << msg << std::endl;
for(int i = 0; i < 10; i++) {
std::cout << i << std::endl;
}
}

int main()
{
// 1.创建线程, 并尝试给入口函数传参
std::thread thread_1(printT, "Hello Thread!");

// 2.join()让主线程等待
// thread_1.join(); // 会阻塞卡在这,主线程等待子线程结束后,才会继续运行

// 3.detach()让线程分离
// thread_1.detach(); // 分离线程,主线程和子线程分离,一般配合多进程使用

// 4.joinable()判断线程能否使用
bool isJoin = thread_1.joinable();
if(isJoin) {
thread_1.join();
}
std::cout << "over" << std::endl;

return 0;
}

Output:
Hello Thread!
0
1
2
3
4
5
6
7
8
9
over

04.常见错误

参考:【文档 thead线程库的基本使用】

在使用C++11线程库时,有一些常见的错误需要注意。例如:

  • 忘记等待线程完成或分离线程:

    如果我们创建了一个线程,但没有等待它完成或分离它,那么在主线程结束时,可能会导致未定义行为。

  • 访问共享数据时没有同步:

    如果我们在多个线程中访问共享数据,但没有使用同步机制,那么可能会导致数据竞争、死锁等问题。

  • 异常传递问题:

    如果在线程中发生了异常,但没有处理它,那么可能会导致程序崩溃。

    因此,我们应该在线程中使用try-catch块来捕获异常,并在适当的地方处理它。

上面提到的错误,后文会给出对应的解决方案。


二、线程函数中的数据未定义错误

主要有以下4种情况,

01.传递临时变量的问题

(1).问题描述

当给某个线程函数 std::thread th(foo, 1);

传入了一个临时值1或者单独一个临时变量,但入口函数要求的函数类型是引用类型,

这样会导致在线程函数执行时,会发生未定义错误;

(2).问题解决

我们需要定义一个变量来保存值,将该变量的引用传递给线程

在给线程的入口函数传递的参数是引用类型时,需要用std::ref()来修饰

来确保传递的参数类型是一致的;

(3).程序示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <thread>

void foo(int &x){
x += 1;
}

// 1. 传递临时变量的问题
int main()
{
int a = 1;
// 函数的参数是引用类型,需要用std::ref()来修饰
std::thread th(foo, std::ref(a)); // 需要保证传递类型一致
th.join();
std::cout << a << std::endl; // a = 2
return 0;
}

02.传递指针或引用指向局部变量(指向已经释放的内存)的问题

参考:线程函数中的数据未定义错误

(1).问题描述

传递指针或引用指向局部变量的问题

当线程函数调用的入口函数的参数是引用类型的或指针类型(传入的是地址)时,

你将一个局部的变量传递给了线程函数,后续使用这个线程函数时,

由于局部变量离开作用域,找不到这个参数,发生未定义错误;

传递指针或引用指向已释放内存的问题

在线程开始执行之前,立即删除了ptr指向的内存,这是不安全的操作

线程可能在主线程中删除ptr后才开始执行,并且它仍然会尝试访问已经释放的内存,这会导致未定义的行为;

这个问题跟前面的描述类似,当线程函数执行期间,传递了一个已经被释放的内存,导致发生未定义错误;

(2).问题解决

传递指针或引用指向局部变量的问题

将传入的参数改为全局变量,即可解决;

传递指针或引用指向已释放内存的问题

不要在创建线程后立即释放内存,确保在线程完成之前内存仍然有效。

更好的办法是,

将指针或引用指向堆上的变量(new操作 + 适时delete) 或是 智能指针,

使用new操作可以通过在线程函数中负责释放内存,或者等待线程完成后再释放内存来实现;

使用std::share_ptr来管理对象的生命周期;

(3).程序示例

传入一个局部变量,会出现问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <thread>

std::thread t; // 全局的线程函数

int a = 1; // 改为传递全局变量可解决

void foo(int &x){ // 传入的是地址
x += 1;
}

void test() {
// int a = 1; // 局部变量在栈上
t = std::thread(foo, std::ref(a));
} // a已经被释放,后面使用t.join()会有空指针问题(win环境)

// 2. 传递指针或引用指向局部变量的问题
int main()
{
test();
t.join();
std::cout << a << 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
#include <iostream>
#include <thread>

void foo(int* x){
*x += 1;
std::cout << *x << std::endl;
}

// 2-1.传递指针或引用指向已释放的内存的问题
int main()
{
int* ptr = new int(1);
std::thread t(foo, ptr);
delete ptr; // 创建线程后,这里提前手动释放了(后续使用时,我们并没有注意到这个问题)

// 传递了已经被释放的内存,线程函数继续执行时已经出现问题
t.join(); // 我们期待x的指针值应该为2,而实际输出了1(这里就出了问题)

// 同上面出现的问题
// 解决方式是将指针或引用指向堆上的变量(new操作), new一个对象或使用share_ptr来管理对象的生命周期
// new后记得在合适时期,进行内存释放操作

// 正确的做法是,在线程完成后释放掉内存
// delete ptr;

return 0;
}

智能指针写法, 可避免上面的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <thread>
#include <memory>

void foo(std::unique_ptr<int> ptr) {
std::cout << *ptr << std::endl;
// 内存会在函数结束时自动释放,无需手动delete
}

int main() {
std::unique_ptr<int> ptr = std::make_unique<int>(1); // 创建一个int型且值为1的独占指针
std::thread t(foo, std::move(ptr)); // 创建线程,传入参数
t.join();
return 0;
}

Output:
1 // 是期望的值

03.类的成员函数作为入口函数,类对象被提前释放

(1).问题描述

创建线程时,传入了一个类的成员函数func()作为该线程的入口函数,&a作为函数的参数

问题在于,如果在线程执行之前,main函数中的a对象已经被销毁,那么在线程执行时尝试访问a对象的成员函数func()将导致未定义的行为。

(2).问题解决

t.join()移动到了线程创建之后,

确保线程执行完毕后才继续执行主线程的操作。这样就能够避免在func中访问无效的对象引用。

或者使用智能指针,可以保证代码的安全性,并且不会出现对象被提前释放的问题。

使用std::shared_ptr来管理对象的生命周期是一种有效的方法,以确保在多线程环境中避免悬垂指针或无效引用的问题。

(3).程序示例

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

class A {
public:
void func() {
// std::this_thread::get_id()用于获取当前线程的唯一标识符
std::cout << "Thread " << std::this_thread::get_id() << " started" << std::endl;
std::cout << "Hello" << std::endl;
}
};


// 3.类成员函数作为入口函数,类对象被提前释放
int main()
{
// A a;
// std::thread t(&A::func, &a);
// 在创建线程之后,某些操作导致a对象立即被销毁了
// 这会导致在线程执行时无法访问类对象,可能会导致程序崩溃或者产生未定义的行为
// t.join();

std::shared_ptr<A> a1 = std::make_shared<A>();
// 传入的是一个共享指针a1, 会保持对象的生命周期,直到线程结束
std::thread t1(&A::func, a1); // 参数是指针类型
t1.join();

return 0;
}

Output:
Thread 139623788812032 started
Hello

04.线程的入口函数使用了类的私有成员函数

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

class MyClass{

private:
friend void thread_func(MyClass* obj);
void printT() {
std::cout << "void printT()..." << std::endl;
std::cout << "Thread " << std::this_thread::get_id() << std::endl;
}
};

void thread_func(MyClass* obj) {
obj->printT(); // 调用了MyClass的私有方法
}

int main()
{
MyClass obj;
// 传递的函数需要访问类的私有方法,需要声明为友元类
std::thread thread_1(thread_func, &obj); // 第二个参数要求传的是地址
thread_1.join();
return 0;
}

Output:
void printT()...
Thread 140095929181952

线程的入口函数是直接使用类的私有方法

将创建线程过程包含进友元函数中,

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

class A {
friend void test();
private:
void func() {
std::cout << "Thread " << std::this_thread::get_id() << " started" << std::endl;
std::cout << "Hello" << std::endl;
}
};

void test() { // 这个方法使用了类的私有方法
std::shared_ptr<A> a1 = std::make_shared<A>();
std::thread t1(&A::func, a1); // func是类的私有成员,需要声明为友元方法来访问
t1.join();
}

// 入口函数为类的私有成员函数
int main()
{
test();
return 0;
}

Output:
Thread 139892073989888 started
Hello

三、互斥量解决多线程数据共享问题

参考:互斥量解决多线程数据共享问题

01.基础知识

线程安全概念:

如果多线程程序的每一次运行结果和单线程运行结果始终是一致的,那么你的线程就是安全的。

02.问题描述

如果多个线程同时访问同一个变量,并且其中至少有一个线程对该变量进行了写操作,

那么就会出现数据竞争问题(理解为同时访问时,只操作了一次,导致和你预期结果不一致)

而且数据竞争可能会导致程序崩溃、产生未定义的结果,或者得到错误的结果。

03.解决措施

数据竞争的解决措施,比如常见的加锁操作;

使用互斥量std::mutex的最终目的就是保证线程的安全性,以避免数据之间发生竞争问题

为了避免数据竞争问题,需要使用同步机制来确保多个线程之间对共享数据的访问是安全的。

常见的同步机制包括互斥量、条件变量、原子操作等。

04.程序示例

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>
#include <mutex>

int a = 0;
std::mutex mtx;

void func()
{
// 在写共享数据之前加锁 mtx.lock()
// 写操作结束之后记得解锁 mtx.unlock()
for(int i = 0; i < 10000; i++) {
mtx.lock(); // 加锁保证线程安全
a += 1;
mtx.unlock();
}
}

int main()
{
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();

std::cout << "a: " << a << std::endl; // a: 20000
return 0;
}

四、std::atomic原子操作解决多线程数据共享问题

01.什么是原子操作

std::atmoic是C++11标准库中的一个模板类,用于实现多线程环境下的原子操作;

原子操作可以提供一种线程安全的方式来访问和修改共享变量,可以避免多线程环境中的数据竞争问题。

主要是用来多线程下解决数据共享的问题。

02.常见的API操作

  1. load():将std::atomic变量的值加载到当前线程的本地缓存中,并返回这个值。

  2. store(val):将val的值存储到 std::atomic 变量中,并保证这个操作是原子性的。

    load()就是输出值、**store(val)就是赋值** 可以保证操作是原子性的,是线程安全的。

  3. exchange(val):将val的值存储到std::atomic变量中,并返回原先的值。

  4. compare_exchange_weak(expected, val)compare_exchange_strong(expected, val)

    比较std::atomic变量的值和expected的值是否相同,

    如果相同,则将val的值存储到std::atomic变量中,并返回true

    否则,将std::atomic变量的值存储到expected中,并返回false

03.程序示例

原始版本的代码,会出现数据竞争的问题,我们期望的结果是20000但结果却不是;

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>

int a = 0;
void func()
{
for(int i = 0; i < 10000; i++) {
a += 1;
}
}

int main()
{
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();

std::cout << "a: " << a << std::endl; // a: 20000
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
#include <iostream>
#include <thread>
#include <atomic>
#include <mutex>

std::atomic<int> a; // 共享数据, 可以保证线程安全,而且效率较高

// int a = 0;
// std::mutex mtx;

void func()
{
for(int i = 0; i < 10000; i++) {
// mtx.lock();
a++;
// mtx.unlock();
}
}

int main()
{
// 计算两个线程执行所花费的时间
auto start = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); // 获取当前的时间(微秒)

std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();

std::cout << "a: " << a << std::endl; // a: 20000

auto end = std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); // 获取当前的时间(微秒)

std::cout << "cost time (us) : " << end - start << std::endl; // 具体时间跟电脑性能有差异

// 经过测试,使用原子操作要比加锁方式快2~3倍,执行效率会更高 !!!
return 0;
}

五、互斥量死锁问题

参考:互斥量死锁

01.死锁现象

多个进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,

此时称系统处于死锁状态,这些永远在互相等待的进程称为死锁进程。【文档 什么是死锁? 是什么,为什么用,怎么用】

产生死锁的原因,主要包括:

  • 系统资源不足;
  • 程序执行的顺序有问题;
  • 资源分配不当等。
image-20230903165112386

02.发生死锁的原因

image-20230903165755770

03.避免死锁的措施

上面的四个条件是死锁的发生必要条件,所以只要让上述条件之一不满足,就不会发生死锁现象。

例如,简单的解决措施是,可以改变线程获取锁的顺序,保证某一时刻共享资源只能被一个线程占有。

04.程序示例

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
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1, mtx2; // 使用互斥量是要避免死锁情况

void func1() {
for(int i = 0; i < 50; i++) {
mtx1.lock(); // 新获取m1,加锁 (互相等待释放资源时,会导致死锁现象)
mtx2.lock();
mtx1.unlock();
mtx2.unlock();
} // 让func1先获m1,m2取所有权操作完后,再让func2操作,可以避免死锁
}

void func2() {
for(int i = 0; i < 50; i++) {
mtx1.lock(); // 第一个线程操作完成后,func2可以完全获取m1,m2进行操作
mtx2.lock();
mtx1.unlock();
mtx2.unlock();
}
}

int main() {
std::thread t1(func1);
std::thread t2(func2);
t1.join();
t2.join();
std::cout << "over" << std::endl; // "over"正常输出了,没有发生死锁现象

return 0;
}