八、多线程
8.1 多线程概念
-
**程序:**程序就是指令和数据的结合,是一个静态的概念,如果不运行的话,没有实际意义。
-
进程:进程就是运行中的程序,此时它是动态的,它占用了操作系统的各种资源。一个程序可以包括多个进程。
-
线程:线程是进程中的实际运行单元,是操作系统进行运算调度的最小单位。每个进程至少需要一个线程,当然也可以有多个,那就是多线程。
-
多线程的理解:
- 软件层面的多线程并不意味着所有的线程在同时执行,要实现真正的同时执行的多线程,需要硬件的支持,也就是说CPU有多个计算核心,
- 或者有多个CPU,否则多线程也是在CPU中逐个去执行的,它的效率并不一定比单线程高。CPU的计算分成很多个小单元,叫做时间片,给线程使用。
- 由于时间片很短,所以即使多个线程依此执行,看起来也像在同时执行。
8.2 thread类
-
在C++11之前,没有多线程类库,所以如果想实现多线程编程,只能调用操作系统本身的多线接口,但是这种方式很麻烦,因为不同的操作系统接口不一样,
而且不能跨平台。 -
C++11之后提供了thread类库,让我们方便地进行多线程编程,而且可以实现跨平台。需要引入头文件
#include <thread>
thread的构造函数:
-
1)thread t();默认的无参构造,构造一个空的线程对象,它没有关联任何可执行的函数,所以不会启动任何线程。
-
2)thread t(func);这个线程对象关联了一个函数func,但它不会立刻执行,而是需要我们手动让它执行,可以选择两种执行方式:阻塞join和分离detach。
- **阻塞执行:join()函数来完成阻塞执行,**阻塞就是在某处停下来,指的是当前的线程必须执行完毕,才能往下进行。比如说在main函数中我们构造一个线程t,
main函数有自己的线程,它是父线程,而我们自己构造的线程t是子线程,子线程的控制权属于父线程,父线程可以杀掉它。如果父线程在子线程之前就
提取结束了,这个时候不管子线程有没有结束,父线程都会杀掉它。如果子线程不想被杀掉,想一直执行到结束,那就可以使用join()函数阻塞父线程,
这种情况下,父线程就会等待子线程结束后才会继续运行。 - **分离执行:detach()函数实现,**即main函数的线程不会取得对子线程t的控制权,也就无法杀死它。它们各自独立执行。此时t进程可以称之为守护线程。
- **阻塞执行:join()函数来完成阻塞执行,**阻塞就是在某处停下来,指的是当前的线程必须执行完毕,才能往下进行。比如说在main函数中我们构造一个线程t,
-
3)**thread t(func,args1,args2…);这个线程关联了func函数,并且给func函数提供了参数args1,args2…**这种情况下,线程会马上开始执行。也需要让线程t
选择阻塞还是分离的方式来执行。同上。 -
4)移动构造函数:移动构造即在构造对象的时候,去掉拷贝的动作,选择直接移动,这样效率更高。对于线程对象来说,很适合做移动构造,因为线程是禁止拷贝的,不能用拷贝构造。
**thread t(move(other));//将线程对象other的所有权转交给或者叫移动给t对象,之后other就消失了,**并且通过查看可以发现,other和t的线程id是一样的。//准备线程函数,给线程调用 void fun1(int n) { for (int i = 0; i < 5; i++) { cout << "fun1执行,参数是:" << n << endl; //休眠一下,当前线程休息500毫秒ms this_thread::sleep_for(chrono::milliseconds(500)); } } void fun2(int& n) { for (int i = 0; i < 5; i++) { cout << "fun2执行,参数是:" << n << endl; n++; //休眠一下,当前线程休息500毫秒ms this_thread::sleep_for(chrono::milliseconds(300)); } }
void test01() { //调试线程,使用线程的构造函数和成员函数 int n = 0; thread t1;//无参构造,没有绑定函数也不会执行 cout << "t1=" << t1.get_id() << endl;//此时t1没有执行,所以没有id,此时输出的id=0 thread t2(fun1, n);//t2线程绑定了函数,并且给函数传了参数,马上开始执行 cout << "t2=" << t2.get_id() << endl;//t2开始运行了,所以有id thread t3(fun2, ref(n));//由于fun2是引用传参,所以需要使用ref函数来传递 cout << "t3=" << t3.get_id() << endl;//t3开始运行了,所以有id thread t4(move(t3));//移动构造,用到move函数,将t3转移给t4,t3就消失了,t4拥有了t3的id cout << "t3=" << t3.get_id() << endl;//t3消失了,id变为0 cout << "t4=" << t4.get_id() << endl;//t4接管了t3,所以有t3的id //为了保证子线程能执行完毕,需要选择阻塞或者分离的方式让它们去执行 t2.join(); t4.join(); //t2.detach();//换成分离执行后,观察输出的效果有什么不同 //t4.detach(); cout << "运行完毕后,n=" << n << endl; }
-
thread的其他常用函数:
- get_id();获取线程id,每个线程都有一个独一无二的id号
- join();阻塞执行,调用后会阻塞主线程,当该线程结束后,主线程才会继续执行。
- detach();分离执行,主线程和子线程不汇合,各自执行,主线程没有对子线程的控制权。
- joinable();判断一种状态,返回bool值,如果一个线程已经join或者detach执行了,那么返回的结果就是false,否则是true。
-
休眠函数:
即让程序停止执行一段时间。
C++11之前没有专门的休眠函数,也是需要调用操作系统的休眠函数,比如window的休眠函数的时间单位是毫秒,Linux的时间单位是秒。
C++11之后提供了专门的休眠函数,需要引入头文件#include
this_thread::sleep_for(休眠时间); 让当前线程休眠一段时间,如:this_thread::sleep_for(chrono::milliseconds(500));休眠500毫秒
//演示阻塞join的用法
void test02()
{
thread t5(fun3);//t5不会立刻执行,没有给fun3传参,需要手动执行
t5.join();
cout << "主线程等子线程执行完毕后,再继续执行" << endl;
for (int i = 10; i < 15; i++)
{
cout << "这是主线程打印的:" << i << endl;
}
}
//演示分离detech的用法
void test03()
{
thread t6(fun3);//t6不会执行
t6.detach();//分离的方式执行,观察输出效果,主线程不会等待子线程执行完毕,而是各自分开执行,主线程结束后,输出窗口就不可输出了,但子线程仍然执行到完毕
cout << "主线程正常执行" << endl;
for (int i = 10; i < 15; i++)
{
cout << "这是主线程打印的:" << i << endl;
}
}
//joinable的用法
void test04()
{
thread t7(fun4);
cout << "joinable=" << t7.joinable() << endl;//返回1,true
t7.join();
cout << "joinable=" << t7.joinable() << endl;//返回0,false
//thread对象析构的时候,会判断joinable的状态,如果当前为true,就会调用一个terminate()函数结束线程。
//所以当一个线程对象绑定了函数执行后,既没有使用join也没有使用detech的方式,那么joinable就为true,就会被异常结束掉。
}
8.3多线程应用
- 简单总结:
1)先创建的子线程不一定跑的最快(多线程运行有偶然性)
2)线程绑定的函数返回后,线程也将终止,id变为0
3)如果主线程先退出,那么它旗下所有的子线程也将被全部终止,除非我们使用阻塞或者分离的方式执行。
8.4线程互斥
-
在多线程环境中运行的代码段,有些代码是存在竞争关系的,我们叫做竞态条件。比如说两个线程都去对一个资源(例如一个变量)进行操作,这时候就存在竞争关系,我们无法保证这个资源的安全有效。这样代码段不是线程安全的,不应该运行在多线程环境下。这样的代码段称为关键代码段或者临界区代码段。
-
为了解决上面的线程安全问题,我们应该禁止多个线程同时操作同一个资源,而是保证某个线程代码段执行前,独占这个资源,等它执行完毕后,再释放资源,
给别的线程代码段执行。这样的操作就是线程互斥。 -
线程互斥通过锁机制来实现,需要引入头文件**#include **
- 上锁:lock(),使用前先上锁,保证自己独占资源
- 解锁:unlock(),使用后解锁,资源可以再给别人使用
//线程互斥 void fun6()//加上互斥锁,保证每个线程都会独享num变量,这样就是线程安全的 { //用num变量之前,先加锁,保证只有自己在用 num_lock.lock(); for (int i = 0; i < 5; i++) { cout << "线程" << this_thread::get_id() << "给num+1,num=" << ++num << endl; } num_lock.unlock();//用完之后,解锁,让别的线程去用 } void test06()//加上互斥锁 { thread t8(fun6); thread t9(fun6); t8.join(); t9.join(); //可见,加了锁之后,两个线程都可以保证独立使用num变量,完成自己的全部5次循环操作。 }
-
除了上面的锁机制完成线程互斥,保证线程安全之外,还有一种更加轻量级(效率更高)的方式也可以实现线程安全,通过CAS原子操作类来实现,
-
需要引入头文件**#include **,这个类库提供了很多基于原子操作的数据类型,可以从根本上保证数据的线程安全。
原子操作就是指不可再分的操作,一个操作要完整的执行完毕,不能被分隔或打断。
//在多线程大量操作下,int类型是不安全的,可能会出错。但是原子整型没问题,可以保证安全可靠。
void fun_int()//普通类型,多线程下,不安全
{
for (int i = 0; i < 10000; i++)
{
num++;
}
}
void fun_atomic()//原子类型,多线程下,安全可靠
{
for (int i = 0; i < 10000; i++)
{
sum++;
}
}
void test07()//使用int,多线程大量操作,可能出错
{
//模拟一个大量运算的场景,启动多个线程
//创建100个线程,写到循环中,放到容器中,逐个启动,共运行100万次加法
vector<thread> vec;
for (int i = 0; i < 100; i++)
{
vec.push_back(thread(fun_int));
}
for (int i = 0; i < 100; i++)
{
vec[i].join();
}
cout << "num=" << num << endl;//结果应该是一百万,但有可能出错
}
void test08()//使用原子整型,多线程大量操作,安全可靠
{
//模拟一个大量运算的场景,启动多个线程
//创建100个线程,写到循环中,放到容器中,逐个启动,共运行100万次加法
vector<thread> vec;
for (int i = 0; i < 100; i++)
{
vec.push_back(thread(fun_atomic));
}
for (int i = 0; i < 100; i++)
{
vec[i].join();
}
cout << "sum=" << sum << endl;//结果应该是一百万,不会出错
}
- lock_guard 与 unique_lock、
std::lock_guard`是 C++ 标准库中的一种互斥量封装类,用于保护共享数据,防止多个线程同时访问同一资源而导致的数据竞争问题。
std::lock_guard
的特点如下:
- 当构造函数被调用时,该互斥量会被自动锁定。
- 当析构函数被调用时,该互斥量会被自动解锁。
std::lock_guard
对象不能复制或移动,因此它只能在局部作用域中使用。
std::unique_lock 是 C++ 标准库中提供的一个互斥量封装类,用于在多线程程序中对互斥量进行加锁和解锁操作。它的主要特点是可以对互斥量进行更加灵活的管理,包括延迟加锁、条件变量、超时等。
std::unique_lock 提供了以下几个成员函数:
-
**lock():**尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁。
-
**try_lock():**尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则函数立即返回 false,否则返回 true。
-
t**ry_lock_for(const std::chrono::duration<Rep, Period>& rel_time):**尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间。
-
**try_lock_until(const std::chrono::time_point<Clock, Duration>& abs_time):**尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间点。
-
**unlock():**对互斥量进行解锁操作。
8.5线程同步
-
多个线程在运行时,都随着操作系统的调度来运行,它们之间没有顺序和规律可言。但是在某些情况下,我们需要线程之间打配合,
比如说一个线程需要等待另外一个线程的执行结果,才能继续执行,这就是线程之间的同步通信机制。 -
线程同步需要用到条件变量,作为线程之间传递消息的中介。
-
条件变量需要引入类库**#include <condition_variable>**,同时要结合互斥锁一起使用。
std::condition_variable
的步骤如下:-
创建一个
std::condition_variable
对象。 -
创建一个互斥锁
std::mutex
对象,用来保护共享资源的访问。 -
在需要等待条件变量的地方
使用
std::unique_lock<std::mutex>
对象锁定互斥锁并调用
std::condition_variable::wait()
、std::condition_variable::wait_for()
或std::condition_variable::wait_until()
函数等待条件变量。 -
在其他线程中需要通知等待的线程时,调用
std::condition_variable::notify_one()
或std::condition_variable::notify_all()
函数通知等待的线程。
-
线程同步案例:
写一个经典的线程同步案例:生产者-消费者模型,见代码(如下)
//生产者线程调用的函数
void producter()
{
//一共生产10个产品
for (int i = 0; i < 10; i++)
{
//生产者每生产一个就通知消费者去消费一个,消费者每消费一个就通知生产者去生产一个。
//用到互斥锁的更加封装的用法,用到了unique_lock<mutex>类
unique_lock<mutex> lock(g_mtx);//完成了上锁,待会还会自动解锁
//作为生产者,首先要判断容器中有没有产品,没有产品才需要生产,有产品就不需要生产,而是通知消费者去消费
while (!g_vector.empty())//容器不为空,不用生产,通知消费者去消费
{
//通知需要用到条件变量
g_convar.wait(lock);//这个wait做了几件事:1.释放锁。2.等待消费者发来的信号。3.阻塞在这里
}
//如果容器为空,就需要生产
g_vector.push_back(i);
cout << "生产者生产了产品:" << i << endl;
//接下来通知消费者去消费
g_convar.notify_all();//通知所有等待的消费者可以消费了
this_thread::sleep_for(chrono::milliseconds(300));//休眠100ms
}
}
//消费者线程调用的函数
void consumer()
{
//一共消费10个产品
for (int i = 0; i < 10; i++)
{
//消费者每消费一个就通知生产者去生产一个。
//用到互斥锁的更加封装的用法,用到了unique_lock<mutex>类
unique_lock<mutex> lock(g_mtx);//完成了上锁,待会还会自动解锁
//作为消费者,首先要判断容器中有没有产品,没有产品就需要等待,有产品就去消费,消费完了别忘了通知生产者继续生产
while (g_vector.empty())//容器为空,不能消费,需要释放锁,阻塞,等待生产者发来通知
{
//通知需要用到条件变量
g_convar.wait(lock);//这个wait做了几件事:1.释放锁。2.等待消费者发来的信号。3.阻塞在这里
}
//如果容器不为空,就可以消费了
int p_num = g_vector.back();//先拿到这个产品,一会用于输出
g_vector.pop_back();//消费了一个产品,从容器中删除
cout << "消费者者消费产品:" << p_num << endl;
//接下来通知生产者去生产
g_convar.notify_all();//通知所有等待的生产者
this_thread::sleep_for(chrono::milliseconds(300));//休眠100ms
}
}
void test09()//生产者消费者模型,线程同步
{
thread t_p(producter);//生产者线程
thread t_c(consumer);//消费者线程
t_p.join();
t_c.join();
}
**建议:**多线程能不使用的情况下就不要使用,用不好容易出问题,而且多线程不一定比单线程效率高。