17.1 C++并发与多线程-基础概念与实现
17.2 C++并发与多线程-线程启动、结束与创建线程写法
17.3 C++并发与多线程-线程传参详解、detach坑与成员函数作为线程函数
17.4 C++并发与多线程-创建多个线程、数据共享问题分析与案例代码
17.5 C++并发与多线程-互斥量的概念、用法、死锁演示与解决详解
17.6 C++并发与多线程-unique_lock详解
17.7 C++并发与多线程-单例设计模式共享数据分析、解决与call_once
17.8 C++并发与多线程-condition_variable、wait、notify_one与notify_all
17.9 C++并发与多线程-async、future、packaged_task与promise
17.10 C++并发与多线程-future其他成员函数、shared_future与atomic
17.11 C++并发与多线程-Windows临界区与其他各种mutex互斥量
17.12 C++并发与多线程-补充知识、线程池浅谈、数量谈与总结
文章目录
12.补充知识、线程池浅谈、数量谈与总结
12.1 知识点补充
(1)虚假唤醒
在17.8节比较详细地讲述了条件变量condition_variable、wait、notify_one与notify_all的用法,请读者认真学习和思考,充分理解wait、notify_one、notify_all的工作细节,因为它们可能在日后的C++11多线程编程中被频繁使用。回顾一下17.8节中的代码:
class A
{
public:
//把收到的消息(玩家命令)入到一个队列的线程
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
std::unique_lock<std::mutex> sbguard1(my_mutex);
msgRecvQueue.push_back(i);//假设这个数字就是我收到的命令,我直接放到消息队列里来
my_cond.notify_one(); //尝试把卡(堵塞)在wait()的线程唤醒,但光唤醒了还不够,这里必须把互斥量解锁,另外一个线程的wait()才会继续正常工作
}
return;
}
//把数据从消息队列中取出的线程
void outMsgRecvQueue()
{
int command = 0;
while (true)
{
std::unique_lock<std::mutex> sbguard1(my_mutex); //临界进去
my_cond.wait(sbguard1, [this] {
if (!msgRecvQueue.empty())
return true;//该lambda表达式返回true,则wait就返回,流程走下来,互斥锁被本线程拿到
return false; //解锁并休眠,卡在wait等待被再次唤醒
});
//现在互斥量是锁着的,流程走下来意味着msgRecvQueue队列里必然有数据
command = msgRecvQueue.front(); //返回第一个元素,但不检查元素是否存在
msgRecvQueue.pop_front(); //移除第一个元素,但不返回
sbguard1.unlock(); //因为unique_lock的灵活性,我们可以随时unlock解锁,以免锁住太长时间
cout << "outMsgRecvQueue()执行,取出一个元素" << command << " threadid = " << std::this_thread::get_id() << endl;
} //end while
}
private:
std::list<int> msgRecvQueue; //容器(消息队列),专门用于代表玩家给咱们发送过来的命令
std::mutex my_mutex; //创建了一个互斥量 (一把锁头)
std::condition_variable my_cond; //生成一个条件对象
};
int main()
{
A myobja;
std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja); //第二个参数是引用(地址),才能保证线程里用的是同一个对象
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
myInMsgObj.join();
myOutnMsgObj.join();
}
执行起来,一切正常。
在这里提及一个概念,叫作“虚假唤醒”。虚假唤醒,就是wait代码行被唤醒了,但是不排除msgRecvQueue(消息队列)里面没有数据的情形。醒来是为了处理数据,但是实际没有可供处理的数据,这就叫虚假唤醒。
虚假唤醒产生的情况很多,例如push_back一条数据,调用多次notify_one,或者是有多个outMsgRecvQueue线程取数据,但是inMsgRecvQueue线程里只push_back了一条数据,然后用notify_all把所有的outMsgRecvQueue线程都通知到了,就总有某个outMsgRecvQueue线程被唤醒,但是队列中并没有它要处理的数据。
现在读者看到的代码已经把虚假唤醒处理得很好,笔者在这里只是介绍“虚假唤醒”的概念而已,防止日后听到这个概念感觉陌生。那代码是怎样处理虚假唤醒的呢?就是下面这段代码(if语句所在行):
my_cond.wait(sbguard1, [this] {
if (!msgRecvQueue.empty())
return true;//该lambda表达式返回true,则wait就返回,流程走下来,互斥锁被本线程拿到
return false; //解锁并休眠,卡在wait等待被再次唤醒
});
所以请注意,wait的第二个参数(lambda表达式)特别重要,通过里面的if判断语句来应付虚假唤醒。因为wait被唤醒后,是要先拿锁,拿到锁后才会执行这个lambda表达式中的判断语句,所以此时这个lambda表达式里面的判断是安全的。
另外已经知道,对于wait,如果一直不notify或者notify的时机不对,可能唤醒不了wait,这就会导致一直卡在wait行,所以在书写使用condition_variable、wait、notify_one、notify_all的代码时,要透彻理解,小心测试,以免不小心写出错误代码,而且一旦出现错误,比较难排查。
(2)atomic的进一步理解
atomic表示原子操作,在17.10.4节中已经有过详细的介绍
#include <atomic>
atomic<int> atm;
public:
A()
{
atm=0;
}
现在把inMsgRecvQueue和outMsgRecvQueue这两个类A的成员函数(也是线程入口函数)原有内容全部注释掉,写入新内容。写入新内容后的两个成员函数如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 1000000; ++i)
{
atm = atm + 1; //非原子操作
}
return;
}
void outMsgRecvQueue()
{
while (true)
{
cout << atm << endl;
}
}
执行起来,一切正常,最终的输出结果始终是1000000(虽然程序写得很差,还用到了死循环,但执行结果并没有什么问题)。
现在以inMsgRecvQueue为线程入口函数,再创建一个新的线程,这只需要修改main主函数即可做到。修改后的main主函数代码如下:
int main()
{
A myobja;
std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja);
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
std::thread myInMsgObj2(&A::inMsgRecvQueue, &myobja);
myInMsgObj.join();
myInMsgObj2.join();
myOutnMsgObj.join();
}
执行起来,一切正常,最终的输出结果始终是2000000。
现在修改inMsgRecvQueue线程入口函数。修改之后的代码如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 1000000; ++i)
{
atm = atm + 1; //非原子操作
}
return;
}
执行起来,最终的输出结果就会小于2000000。因为有两个inMsgRecvQueue线程来同时改写atm的值,但“atm=atm+1;”这行代码却不是原子操作。所以,导致最终的结果是错的。
上面这行代码出现了atm,表示要读atm的值,读该值是原子操作,但是这可是整个一行语句,这整个一行语句却不是原子操作。
cout << atm << endl;
“<<”是把atm的值往屏幕上输出,可能输出的同时,其他inMsgRecvQueue线程又已经改变了atm的值。换句话说,此时此刻屏幕上输出的值应该是一个atm的曾经值。当然,最后当atm不再继续增加的时候,在屏幕上输出的会是atm的最终值,此后atm的输出结果就会一直保持不变。
随便找个位置试一下下面这行代码,例如在类A的构造函数中写入:
auto atm2 = atm; //不允许 编译时语法报错
编译的时候系统会报错,提示的错误诸如“std::atomic::atomic(const std::atomic&)”:尝试引用已删除的函数。
分析一下,上面这行代码会调用atomic的拷贝构造函数,这里提到的已删除的函数应该指的就是拷贝构造函数(用14.4.5节中讲过的=delete;方式就可以把拷贝构造函数删除)。
为什么编译器不让其进行复制构造呢?因为这里如果允许这样给值,那auto推断也会推断成atomic类型,因为atomic对象是原子的,上面这种“定义时初始化的语句”肯定很难弄成原子操作,所以系统处理的方式很简单直接,干脆不让用拷贝构造函数来构造新的atomic对象。
同理,复制赋值运算符也不可以使用。下面的代码也不合法:
atomic<int> atm3;
atm3 = atm; //不允许 编译时报语法错
既然复制构造不可以,复制赋值也不行,那如何实现类似的功能呢?atomic提供了一些成员函数能够做类似的事情。
load——以原子方式读atomic对象的值
store——以原子的方式写入内容:
atomic<int>atm5(atm.load()); //这时可以
atm5.store(12);
那么,“atm5=12;”这种代码是否是原子操作呢?通过一定的调试观察,感觉这种赋值内部调用的也是store成员函数。所以atm5=12;笔者认为也是原子操作,与“atm5.store(12);”似乎没有什么本质差异。如果读者有什么不同的观点,欢迎与笔者联系探讨。
如果说到store、load的性能问题,不好说,当然,毕竟是atomic对象,和非atomic对象比,性能上肯定是差一些。如果读者有条件或者大量使用store、load的话,可以考虑专门测试一下它们的效率问题。
12.2 浅谈线程池
(1)场景设想
设想这样一个场景:开发一个服务器程序,等待客户端连接进来。每进来一个客户端连接,这个服务器程序就创建一个新的工作线程,专门给这个客户端提供服务,客户离开或者断线后,这个工作线程就执行结束。
这种服务器实现方式可能写起代码来比较简单,但是也有明显的缺陷。例如,如果客户端只有10个20个的数量,每个客户创建1个线程当然没问题,也就是说负荷最高的时候这个服务器程序中同时运行的线程数量也不过是20个,这种资源消耗可以说任何计算机硬件都能应付。但有两个问题必须思考:
(1)如果是一个网络游戏,玩家特别多,如果这一个服务程序上同时有2万个玩家客户呢?那不可能创建出2万个线程来为每个玩家服务,系统资源肯定会枯竭,程序崩溃。所以这种情况下,不可能每一个客户进来就创建一个线程,换句话说,在这种工作场景下,现在这种程序写法是行不通的。
(2)程序运行稳定性问题。不知道读者是否有一种感觉,写一个程序,如果这个程序中偶尔就有创建线程的代码出现,可以说这种程序的写法是有点让人不安的,创建线程这种代码相对于常规程序代码,对内存等硬件资源会有更多的消耗,线程的运行也需要CPU进行上下文切换,上下文切换必然要进行各种调度(如保存和恢复程序的现场数据),所以这些消耗不能忽视。
创建线程既然有各种对系统资源的消耗问题,所以不排除,如果系统可用资源过低等一些不太常见的情况发生时,创建线程可能会失败,一旦创建线程失败,那该程序会不会因此而产生执行异常甚至崩溃?所以说,程序中偶尔在某种条件达成时就创建出来一个线程,这种程序写法是让人不安的,或者换句话说,就是写出来这个程序也总让人觉得心里没底,感觉不够稳定,尽管这种程序绝大部分时间工作起来都表现正常。
基于上面这些原因,也可能还存在一些这里没谈到的其他原因,开发者提出了“线程池”的概念。“池”这个字表示把一堆线程放到一起,进行统一的管理调度。发挥一下想象力,就是把多个线程放到一个池子里,用的时候随手抓一个线程拿来用,用完了再把这个线程扔回到池子里,供下次使用,也就是说循环再利用。这种统一的管理调度线程的方式,被形象地比喻为线程池。
(2)实现方式
一般来讲,最简单的线程池实现方式,就是在程序启动的时候,一次性地创建好一定数量的线程,如少则可能10个8个,多则可能几十上百个(后续还会谈一些对线程创建数量的建议)。
当有一个任务请求(任务)到来的时候,就从线程池中拿出来一个预先创建好的但还没有分配任务的线程来处理任务请求,处理完任务请求后,线程不会销毁,会继续等待下次请求任务的到来。
那么请想一想,这种线程池的编码方式是不是更让人放心:程序开始执行的时候,就把线程预先创建好了,不会在程序执行过程中进行线程的创建和销毁工作,这样就不会因为动态创建线程导致瞬间占用更多系统资源,同时也提高系统运行效率(创建线程的开销比较大,对系统效率影响比较大)。此外,作为程序开发者,也能感觉到这种程序设计方式设计出的程序更健壮、稳定,更让人放心。
这里笔者并不打算演示如何实现一个线程池,因为线程池的实现不管从哪个角度来说,都具有一定的复杂性,可能需要比较大的篇幅来讲。而本书的讲解主要定位在C++语言层面。笔者会在《C++新经典:LinuxC++通信架构实战》书籍中详细讲解线程池。因为实战主要是讲述项目以及项目的实现手段。结合具体的项目,讲解线程池才更容易理解和有深刻的印象,才能学以致用。
如果读者迫不及待地想研究线程池实现技术,也可以通过搜索引擎搜索来学习。
12.3 线程创建数量谈
(1)线程创建的数量极限问题
很多人可能对一个程序(进程)里面到底能创建多少个线程感到好奇,其实这与很多因素有关,因为创建线程要消耗资源,不但是消耗内存,还有很多与操作系统相关的其他资源,这些资源的叫法可能对读者也不算太熟,笔者在这里就不提了。根据相关人士的测试,一般开2000个左右线程就是极限,再创建就会导致资源枯竭甚至程序崩溃。
(2)线程创建数量建议
这个问题比较重要,笔者分两个方面谈:
(1)当程序员采用一些比较独特的开发技术来开发程序时,如采用IOCP完成端口技术开发网络通信程序,往往会收到开发接口提供商提出的建议,如建议创建的通信线程数量等于CPU数量、等于CPU的数量2、等于CPU的数量2+2等诸如此类。建议遵从这些建议,因为这些建议是专业的,经过大量测试的,有权威性。
(2)但如果某些线程是用来实现业务需求的,那么就要换个角度看问题。读者都知道,一个线程就等于一条执行通路,可以做个设想,例如这个系统要同时服务1000个客户,预计在最坏的情况下,可能会有100个用户同时充值,假如这个充值业务是给第三方充值服务器发起充值请求,并等待第三方充值服务器返回或者等待一个超时时间到来,这段时间可能短则几秒,长则几分钟,这个执行通路是堵着的,这意味着这个线程没有办法给其他用户提供服务,其他所有用户都要等待。
那请试想,如果这个系统中开启了110个线程,那么哪怕真有100个用户同时充值,堵在那里,还剩余10个线程可以为其他用户提供非充值业务的其他服务,所以这个时候,创建出来110个线程就显得非常必要。
有些读者可能认为:何必创建110个线程,系统不是最大允许创建2000个线程吗?那直接创建1800个线程,留200个供将来扩展,1800个执行通路,可以同时应付1800个用户充值,这是不是更好?这当然不是更好。
其一:要知道,线程多的话,CPU在各个线程之间切换就要大量地保存数据和恢复数据,因为线程切换回来的时候要把线程中用到的如局部变量等数据也要恢复回来。显然,大量的保存和恢复数据是很占用CPU时间的,CPU都把时间花在保存和恢复数据上,它还有时间干正事吗?所以,当创建的线程数量过多时会发现,每个线程的执行都变得特别慢,整个系统的执行效率不升反降。
其二:现在的操作系统都是多任务操作系统,虽然系统会把一个应用程序虚拟成一个独立的个体,看起来所有硬件都归这个独立的个体所用,但是系统的硬件资源必定是有限的,一个程序占用的多了,另外一个程序必然就占用的少了,当程序运行所需的资源超出了整个计算机硬件的负荷,该计算机的运行效率就直线下降,程序执行将变得异常缓慢。
笔者给的建议是,一个进程中所包含线程的数量尽量不要超过500个,以200个以内为比较好,就算是根据业务需要,一般来讲,也很少会用到超过200个线程的。如果业务太过庞大,单台计算机处理不了,那么就要考虑集群的解决方案,拼命榨取单计算机的硬件资源终究会有尽头。
到底创建多少个线程合适,笔者认为实践是检验真理的最好标准。要根据不同的业务类型,找到创建工作线程的最佳数量。
12.4 C++11多线程总结
传统上开发多线程程序的时候,不同的平台如Windows平台有自己的线程库开发接口(可以调用函数),Linux平台也有自己的线程库开发接口,这些接口发展多年,成熟稳定,但是,因为它们不具备跨平台的特性,所以使用上多多少少会受到制约。
C++11中引入了多线程开发接口,从而使程序员脱离了以往在不同的操作系统平台下要用不同的线程库开发接口来实现多线程程序开发的尴尬境地,实现了可以通过C++语言本身提供的接口实现跨平台统一开发多线程程序的心愿,降低了学习成本,提高了程序的可移植性。
当然,当下C++11支撑的线程功能可能还不算太强大和成熟,但已经足够应付绝大部分开发需求了。同时,C++标准也在不断进化,所以有理由相信,C++对多线程的支持会越来越好,功能会越来越强大。
当然,也许开发中会遇到C++11标准线程库中的功能和具体操作系统平台相关线程开发接口结合使用的情形,这是很正常的,具体情况具体分析,结合使用可以优势互补,写出更好的多线程程序。