c++中的多线程:概念、基本用法、锁以及条件变量和优先级调度策略
1.基本概念
首先要对并发,进程,线程有基本的概念。
1.1什么是并发
意思就是两个任务同时执行。
对于单核CPU:在不考虑Intel超线程技术的情况下,由于只有一个CPU,某一时刻只能执行一个任务,因此只能软件并发,多任务并发情景下需要进行任务切换,因此这个并不是帧并发,而是假并发。
对于多核CPU:因为有多个CPU,所以可以同时执行多个任务;因此可以硬件并发,真正同时执行多个任务。
1.2什么是进程
进程是系统资源分配的最小单位,是应用程序运行的环境。打开Windows的进程管理器,你会发现Chrome浏览器、Word等应用都是以进程的形式被管理的。
1.3什么是线程
线程是任务执行的最小单位,一般是执行某个function。
1.4进程与线程的关系
线程属于进程,一个进程可以拥有多个线程。每个进程都有一个主线程。
一个进程中的所有线程,是共享资源的,比如全局的变量、对象、指针、引用等等。
举个例子,在word这个应用进程中,有负责UI的线程,有负责字数统计的线程,所有的线程的功能加起来,就是word这个应用进程的全部功能。当某个线程执行时需要资源时,就从word进程的资源池里取。
其实单线程也可以跑word,只不过多线程可以最大化的发挥出多核CPU的性能优势。
1.5多进程并发和多线程并发的区别
一个进程中创建了多个线程,一个进程中的线程共享地址空间,全局变量、指针、引用,都可以在线程间传递,使用多线程的开销远远小于多进程。
多进程并发,这些进程之间没有共享地址空间,无法直接传递变量、指针、引用等资源,因此数据同步带来的开发难度与效率会弱于多线程。
2.C++中多线程的创建、启动与应用
c++ 11 之后有了标准的线程库:std::thread
基本作用:开启另一个线程并行执行函数/任务。
2.0最基本用法,什么都不用(join和detach都不用)
效果是起一个线程,执行线程中的函数,直到结束,函数结束时,子线程会自动被销毁。
线程创建之后就开始执行了,只不过在一般的示例中,main函数中起了线程之后,如果不用join或者detach会报错,这是因为main已经结束了,而子线程还没结束,但是资源已经被释放掉了,就会报错。
而一般实际项目中,main会一直跑,比如某个service跑在循环里,这个时候子线程什么都不用,也可以一直跑着,并不会报错。
2.1join等待thread执行完毕:
#include<iostream> #include<thread> using namespace std; void func_thread() { // sleep 3s sleep(3000); cout << "in func_thread, sleeped 3s.." << endl; } int main() { cout << "main begin ....." << endl; std::thread t1(func_thread); // 阻塞当前main主线程,等待子线程执行完毕后,恢复主线程执行 t1.join(); cout << "main continue ....." << endl; }
常用参数:
get_id() 取得目前的线程 id, 回传一个 std::thread::id 类型 joinable() 检查是否可 join join() // 阻塞当前线程,等待子线程执行完毕 detach() // 与该线程分离,一旦该线程执行完后它所分配的资源就会被释放 native_handle() 取得平台原生的 native handle. sleep_for() // 停止目前线程一段指定的时间 yield() // 暂时放弃CPU一段时间,让给其他线程
2.2不等待thread执行结束,主线程与子线程分离:detach
void thread_func() { while(1){ cout << " thread_func begin...." << endl; sleep(1); cout << " thread_func end...." << endl; } } int main() { std::thread t1(thread_func); cout << "created thread t1....." << endl;; t1.detach(); cout << "detach thread t1....."<< endl; while(1){ cout << "main running" << endl; Sleep(2); } return 0; }
参数传入引用:
void myFunc(int n) { std::cout << "myFunc n = " << n << endl; n += 10; } void myFunc_ref(int& n) { std::cout << "myFunc reference n = " << n << endl; n += 10; } int main() { int n1 = 5; thread t1(myFunc, n1); t1.join(); cout << "main n1 = " << n1 << "\n"; int n2 = 10; thread t2(myFunc_ref, std::ref(n2)); t2.join(); cout << "main n2 = " << n2 << "\n"; cout << "main thread run over" << endl; return 0; }
2.3 reference to non-static member function must be called
代表std::thread t1(func,...)传入的func是对象的非静态成员函数。有可能是因为不确定线程还在工作时,该对象是否会被free,因此报错。
2个解决办法:
- 方法一:把该函数改为static函数,这样该函数属于类,而不是对象,所以就没有上述担忧了
- 方法二:参考上述class 类成员函数创建线程,如下所示
class 类成员函数创建线程
C++ std::thread 的构建可以传入class类别中的成员函数,如下范例所示:AA::start 分别建立t1, t2 两个线程,而 t1传入 AA::a1 类别函数。
notice :
第一个参数:AA::a1 前面需要添加 &
第二个参数:代表的是那个类对象
后面参数: 按照要求传入即可
class AA { public: void a1() { cout << "a1\n"; } void a2(int n) { cout << "a2 : " << n << "\n"; } void start() { thread t1(&AA::a1, this); thread t2(&AA::a2, this,10); t1.join(); t2.join(); } private: };
3.C++创建多个线程以及数据共享
3.1多线程创建
创建多个线程的步骤与创建第一个线程时一样,需要注意的时线程被创建后,立刻就开始运行了,因此在不使用join的情况下,很难去规定不同线程的执行顺序。
使用join()会让整个流程更稳定。
3.2数据共享
只读数据在进行线程间的操作时是安全稳定的,不需要特别的处理手段,直接读就可以;
有的线程写有的线程读,不进行特殊处理,那么程序肯定会崩溃;
因此解决办法是: 对于读和写操作,在对某个数据进行读写操作时,先对数据进行上锁,其他线程要等待该操作完成后对数据解锁后才可以使用
保护共享数据
3.2.1用互斥量(mutex)解决共享数据的保护问题
互斥量(mutex)是个类对象,理解成一把锁,当多个线程尝试使用lock()成员函数来加锁时,只有一个线程可以锁成功(成功标志是lock()返回);如果没锁成功,那么线程会卡在lock()这里不断进行尝试去加锁。
在执行多个线程之间的共享数据的读写操作时,在每一个读写操作之前进行上锁lock(),然后在操作完之后进行解锁unlock(),这样可以保证同一时刻只有一个线程对数据进行处理。
下面是一个用mutex的例子:
// C++stu_03.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。 //本例程用于学习创建多线程以及数据共享问题 #include <iostream> #include <windows.h> #include <thread> #include <vector> #include <list> #include <mutex> using namespace std; vector<int>g_vaul = { 1,2,3,4 };//共享数据,只读 class A { public: //把玩家命令输入到list中 void inMsgRecvQueue() { for (int i=0;i<1000;++i) { my_mutex.lock(); cout << "inMsgRecvQueue执行插入一个元素" << i << endl; msgRecvQueue.push_back(i); my_mutex.unlock(); } } //从list中取出玩家命令 void outMsgRecvQueue() { for (int i = 0; i < 1000; ++i) { my_mutex.lock(); if (!msgRecvQueue.empty()) { //消息不为空 int command = msgRecvQueue.front();//返回第一个元素 msgRecvQueue.pop_front();//移除第一个元素但不返回 cout << "从消息队列中取出一个数据"<< command << endl; my_mutex.unlock(); } else { my_mutex.unlock();//进行判断时要注意每种情况下都要有对应的unlock() cout << "outMsgRecvQueue执行,但是消息队列为空" << i << endl; } } } private: list<int>msgRecvQueue; mutex my_mutex; }; int main() { std::cout << "Hello World!\n"; A myobja; thread myOutMsgObj(&A::outMsgRecvQueue, &myobja);//要注意第二个参数是引用才能保证线程中使用的是同一个对象 thread myInnMsgObj(&A::inMsgRecvQueue, &myobja); myOutMsgObj.join(); myInnMsgObj.join(); cout << "I love Arsenal!" << endl; return 0; }
4. 死锁以及解决方案
4.1死锁的概念
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
解释起来就是:
两个线程两把锁,其中一个线程先lock mutex1,再lock mutex2,另外一个线程先lock mutex2,再lock mutex1
当第一个线程把mutex1给locked住时,第二个线程locked了mutex2,此时两个线程都要继续上锁,但是第一个线程无法上lock mutex2,第二个线程无法上lock mutex1,那么就会卡在这里,这时就产生了死锁。
或者说多个线程进行环路等待,就会出现死锁:
发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
4.2死锁的解决办法
std::lock
功能:
- 一次锁住两个或两个以上的mutex(至少两个,多了不限,1个不行)
- 使用该函数模板不存在因多个线程上锁顺序问题而导致的死锁情况的发生。
- 如果mutex中有一个没锁住,那么就会一直等待mutex都锁住才会继续向下执行。
特点:
要么多个mutex都锁住,要么多个mutex都没锁住。如果只锁了一个,另外一个没有锁成功,那么它会立即把已经锁住的解锁,避免死锁的发生。
解锁时要按常规方式挨个解锁
例子:
// C++stu_03.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。 //本例程用于学习创建多线程以及数据共享问题 #include <iostream> #include <windows.h> #include <thread> #include <vector> #include <list> #include <mutex> using namespace std; vector<int>g_vaul = { 1,2,3,4 };//共享数据,只读 class A { public: //把玩家命令输入到list中 void inMsgRecvQueue() { for (int i=0;i<1000;++i) { //my_mutex1.lock();//先锁1,再锁2 //my_mutex2.lock(); std::lock(my_mutex1, my_mutex2);//用来代替上面两句 cout << "inMsgRecvQueue执行插入一个元素" << i << endl; msgRecvQueue.push_back(i); my_mutex1.unlock(); my_mutex2.unlock(); } } //从list中取出玩家命令 void outMsgRecvQueue() { for (int i = 0; i < 1000; ++i) { //my_mutex1.lock();//先锁1,再锁2 //my_mutex2.lock(); std:lock(my_mutex2, my_mutex1);//用来代替上面两句 if (!msgRecvQueue.empty()) { //消息不为空 int command = msgRecvQueue.front();//返回第一个元素 msgRecvQueue.pop_front();//移除第一个元素但不返回 cout << "从消息队列中取出一个数据"<< command << endl; my_mutex1.unlock(); my_mutex2.unlock(); } else { my_mutex1.unlock(); my_mutex2.unlock(); cout << "outMsgRecvQueue执行,但是消息队列为空" << i << endl; } } } private: list<int>msgRecvQueue; mutex my_mutex1; mutex my_mutex2; }; int main() { std::cout << "Hello World!\n"; A myobja; thread myOutMsgObj(&A::outMsgRecvQueue, &myobja);//要注意第二个参数是引用才能保证线程中使用的是同一个对象 thread myInnMsgObj(&A::inMsgRecvQueue, &myobja); myOutMsgObj.join(); myInnMsgObj.join(); cout << "I love Arsenal!" << endl; return 0; }
5.unique_lock
5.1什么是unique_lock
lock_guard时用来管理mutex的上锁解锁的模板类,unique_lock也是用来管理mutex上锁解锁的类模板
lock_guard<>可以看出它是一个模板类,它在自身作用域(生命周期)中具有构造时加锁,析构时解锁的功能。
unique_lock是一个类模板,比lock_guard更加灵活,效率上低一点,内存占用大一点。
unique_lock可以直接替换lock_guard,调用unique_lock也不需要手动解锁,在当前作用域结束时会自动解锁。
也就是说lock_guard和unique_lock一样,只需要创建一个mutex对象,然后调用就可以。他们都是在构建函数中加锁,在析构函数中解锁。也就是说只有当前作用域结束时该锁才会解开,这就灵活性比较低,导致锁的时间比较长:
std::mutex m_mutex1; /* 创建互斥量 m_mutex1*/ for (int i = 0; i < 100000; ++i) { cout << "inMsgRecvQueue exec, push an elem " << i << endl; std::unique_lock<std::mutex> m_guard1(m_mutex1); msgRecvQueue.push_back(i); /* 假设数字 i 就是收到的玩家命令 */ ... ... ... /* 在此作用域内,unique_lock调用后一直处于上锁状态 */ }
5.2std::defer_lock
作用是初始化一个没有加锁的mutex(在使用其他参数时会在创建mutex时直接尝试加锁),这样可以自己控制枷锁的粒度
5.3unique_lock的成员函数
5.3.1成员函数lock()
对于没有加锁的unique_lock对象,可以通过成员函数lock()进行上锁。
5.3.2成员函数unlock()
对于上锁的unique_lock对象,可以通过成员函数unlock()进行解锁。这样提前解锁来运行一些不需要共享数据的代码,这使得我们的代码设计更加灵活
粒度一般用粗细来描述
锁住的代码越少,这个锁的粒度就细,执行效率就越高
锁住的代码越多,这个锁的粒度就粗,执行效率就越低
例子:
std::unique_lock<std::mutex>myunique_lock(my_mutex1,std::defer_lock);//创建一个没有加锁的myunique_lock myunique_lock.lock();//对myunique_lock进行加锁操作 //处理一些共享数据代码 myunique_lock.unlock(); //继续处理一些非共享代码 //。。。。。。 //处理完之后可以再次上锁 myunique_lock.lock();//对myunique_lock进行加锁操作
5.3.3成员函数try_lock()
在不阻塞的情况下进行lock,如果加锁成功,那么返回true,如果没有加锁成功,那么返回false。
5.3.4成员函数release()
通过release()会返回它所管理的mutex对象指针,并释放所有权(也就是说,这个unique_lock和mutex不再有关系)
release()之后要负责把上锁的mutex解锁,否则会报错。
std::unique_lock<std::mutex>myunique_lock(my_mutex1,std::defer_lock);//创建一个没有加锁的myunique_lock myunique_lock.lock();//对myunique_lock进行加锁操作 //处理一些共享数据代码 std::mutex *ptr = myunique_lock.release();//释放myunique_lock并返回my_mutex1的指针 //处理共享数据 ptr->unlock();//手动解锁,如果不解锁会卡住导致程序崩溃
6.条件变量condition_variable、wait、notify_one、notify_all
6.1什么是条件变量
条件变量是允许多个线程相互交流的同步原语。它允许一定量的线程等待(可以定时)另一线程的提醒,然后再继续。条件变量始终关联到一个互斥。
std::condition_variable 类是同步原语,能用于阻塞一个线程,或同时阻塞多个线程,直至另一线程修改共享变量(条件)并通知 condition_variable 。
也就是当条件不满足时,相关线程被一直阻塞,直到某种条件出现,这些线程才会被唤醒。
然后通过使用my_cond的成员函数wait、notify_one、notify_all来进行条件相关的操作。
6.2如何使用条件变量
6.2.1使用条件
如果一个线程想要修改变量,必须满足以下条件:
- 获得 std::mutex (典型地通过 std::lock_guard )
- 在保有锁时进行修改
- 在 std::condition_variable 上执行 notify_one 或 notify_all (不需要为通知保有锁)
即使共享变量是原子性的,它也必须在mutex的保护下被修改,这是为了能够将改动正确发布到正在等待的线程。
任意要等待std::condition_variable的线程,必须满足以下条件:
- 获取std::unique_lock<std::mutex>,这个mutex正是用来保护共享变量(即“条件”)的
- 执行wait, wait_for或者wait_until. 这些等待动作原子性地释放mutex,并使得线程的执行暂停
- 当获得条件变量的通知,或者超时,或者一个虚假的唤醒,那么线程就会被唤醒,并且获得mutex. 然后线程应该检查条件是否成立,如果是虚假唤醒,就继续等待。
note: 所谓虚假唤醒,就是因为某种未知的罕见的原因,线程被从等待状态唤醒了,但其实共享变量(即条件)并未变为true。因此此时应继续等待
std::condition_variable 只可与 std::unique_lock<std::mutex> 一同使用;此限制在一些平台上允许最大效率。 std::condition_variable_any 提供可与任何基础可锁 (BasicLockable) 对象,例如 std::shared_lock 一同使用的条件变量。
6.2.2 成员函数wait
condition_variable 容许 wait 、 wait_for 、 wait_until 、 notify_one 及 notify_all 成员函数的同时调用。
示例:
#include <iostream> #include <condition_variable> using namespace std; mutex wait_mutex; condition_variable wait_condition_variable; // 等待线程函数 void wait_thread_func() { unique_lock<mutex> lock(wait_mutex); cout << "等待线程(" << this_thread::get_id() << "): 开始等待通知..." << endl; wait_condition_variable.wait(lock); cout << "等待线程(" << this_thread::get_id() << "): 继续执行代码..." << endl; } int main() { thread wait_thread(wait_thread_func); this_thread::sleep_for(1s); // 等待1秒后进行通知 cout << "通知线程(" << this_thread::get_id() << "): 开始通知等待线程..." << endl; wait_condition_variable.notify_one(); wait_thread.join(); cout << "--- main结束 ---" << endl; }
6.2.3成员函数notify_one和notify_all
notify_one只会唤醒一个线程的wait(),而如果有多个线程需要等待唤醒,那么需要使用notify_all()来唤醒所有线程中的wait()。
7.进程优先级
在多进程/线程模型中,优先级决定了当前进程/线程获取资源(主要是CPU时间片)的比例。
7.1 overview
7.2 nice value 和 scheduling priority
在用户空间,进程优先级有两种含义:nice value和scheduling priority。
对于普通进程而言,进程优先级就是nice value,从-20(优先级最高)~19(优先级最低),通过修改nice value可以改变普通进程获取cpu资源的比例。
对于实时进程而言,进程优先级就是scheduling priority。实时进程的优先级的范围可以通过sched_get_priority_min和sched_get_priority_max,对于linux而言,实时进程的scheduling priority的范围是1(优先级最低)~99(优先级最高)。当然,普通进程也有scheduling priority,被设定为0。
具体参考可以查阅Linux手册:https://man7.org/linux/man-pages/man2/sched_get_priority_min.2.html
7.3 非实时调度策略SCHED_OTHER等与实时调度策略SCHED_FIFO 和 SCHED_RR
7.3.1 调度策略概览
sched_setscheduler()为pid中指定的线程设置调度策略和相关参数。 如果pid等于零,则将设置调用线程的调度策略和参数。 参数param的解释取决于所选策略。 当前,Linux支持以下“常规”(即非实时)调度策略:
- SCHED_OTHER:标准循环分时策策;
- SCHED_BATCH:用于“批处理”样式的进程执行;
- SCHED_IDLE:用于运行优先级较低的后台作业。
还支持以下“实时”策略,用于支持需要严格控制选择可运行线程来执行的方式的特殊时间紧迫的应用程序。
- SCHED_FIFO:先进先出策略;
- SCHED_RR:循环策略。
7.3.2 SCHED_OTHER
它是默认的线程分时调度策略,所有的线程的优先级别都是0,线程的调度是通过分时来完成的。简单地说,如果系统使用这种调度策略,程序将无法设置线程的优先级。请注意,这种调度策略也是抢占式的,当高优先级的线程准备运行的时候,当前线程将被抢占并进入等待队列。这种调度策略仅仅决定线程在可运行线程队列中的具有相同优先级的线程的运行次序。
7.3.3 SCHED_FIFO
它是一种实时的先进先出调用策略,且只能在超级用户下运行。这种调用策略仅仅被使用于优先级大于0的线程。它意味着,使用SCHED_FIFO的可运行线程将一直抢占使用SCHED_OTHER的运行线程J。此外SCHED_FIFO是一个非分时的简单调度策略,当一个线程变成可运行状态,它将被追加到对应优先级队列的尾部((POSIX 1003.1)。当所有高优先级的线程终止或者阻塞时,它将被运行。对于相同优先级别的线程,按照简单的先进先运行的规则运行。我们考虑一种很坏的情况,如果有若干相同优先级的线程等待执行,然而最早执行的线程无终止或者阻塞动作,那么其他线程是无法执行的,除非当前线程调用如pthread_yield之类的函数,所以在使用SCHED_FIFO的时候要小心处理相同级别线程的动作。
7.3.4 SCHED_RR
鉴于SCHED_FIFO调度策略的一些缺点,SCHED_RR对SCHED_FIFO做出了一些增强功能。从实质上看,它还是SCHED_FIFO调用策略。它使用最大运行时间来限制当前进程的运行,当运行时间大于等于最大运行时间的时候,当前线程将被切换并放置于相同优先级队列的最后。这样做的好处是其他具有相同级别的线程能在“自私“线程下执行。要设一个进程为实时进程时,我们一般将其调度策略设为SCHED_RR。
7.4 sched_priority 与实时进程示例
7.4.1 sched_priority
sched_priority就是前面讲的scheduling priority的值,对于实时进程,范围是1(优先级最低)~99(优先级最高)。对于非实时进程,则为0。调度过程中,sched_priority值较高的进程在sched_priority值较低的进程之前进行调度。
也就是说会把所有进程按照sched_priority的值从高到低进程排序,并按照排序顺序进行调度。
7.4.2 实时进程示例
#include <thread> #include <mutex> #include <iostream> #include <chrono> #include <cstring> #include <pthread.h> std::mutex iomutex; void f(int num) { std::this_thread::sleep_for(std::chrono::seconds(1)); sched_param sch; int policy; pthread_getschedparam(pthread_self(), &policy, &sch); std::lock_guard<std::mutex> lk(iomutex); std::cout << "Thread " << num << " is executing at priority " << sch.sched_priority << '\n'; } int main() { std::thread t1(f, 1), t2(f, 2); sched_param sch; int policy; pthread_getschedparam(t1.native_handle(), &policy, &sch); sch.sched_priority = 20; //可以将20设为一个经验值 if (pthread_setschedparam(t1.native_handle(), SCHED_RR, &sch)) { std::cout << "Failed to setschedparam: " << std::strerror(errno) << '\n'; } t1.join(); t2.join(); }
参考链接:
https://www.runoob.com/w3cnote/cpp-std-thread.html
https://blog.csdn.net/u013620306/article/details/128565614
https://blog.csdn.net/milkhoko/article/details/118282922
https://www.cnblogs.com/xiaohaigegede/p/14008121.html
https://blog.csdn.net/qq_39277419/article/details/99544724
https://man7.org/linux/man-pages/man2/sched_get_priority_min.2.html
https://blog.csdn.net/m0_50662680/article/details/129221186
https://zhuanlan.zhihu.com/p/618044514?utm_id=0