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++并发与多线程-补充知识、线程池浅谈、数量谈与总结
文章目录
11.Windows临界区与其他各种mutex互斥量
11.1 Windows临界区
之前学习到的mutex的lock和unlock。一个线程向队列中插入数据,另一个线程从队列中取得数据。实际上mutex的用法和Windows平台编程里面“临界区”的用法几乎完全相同,用途也几乎完全相同。为了引入一些新知识,这里笔者把当时代码改成Windows临界区的写法,请仔细看如何修改代码。
class A
{
public:
A()
{
#ifdef __WINDOWSLJQ__
InitializeCriticalSection(&my_winsec); //初始化临界区
#endif
}
virtual ~A()
{
#ifdef __WINDOWSLJQ__
DeleteCriticalSection(&my_winsec); //释放临界区
#endif
}
//把收到的消息入到队列的线程
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
#ifdef __WINDOWSLJQ__
EnterCriticalSection(&my_winsec); //进入临界区
msgRecvQueue.push_back(i);
LeaveCriticalSection(&my_winsec); //离开临界区
#else
my_mutex.lock();
msgRecvQueue.push_back(i);
my_mutex.unlock();
#endif
}
}
bool outMsgLULProc(int& command)
{
#ifdef __WINDOWSLJQ__
EnterCriticalSection(&my_winsec);
if (!msgRecvQueue.empty())
{
int command = msgRecvQueue.front();//返回第一个元素但不检查元素存在与否
msgRecvQueue.pop_front();
LeaveCriticalSection(&my_winsec);
return true;
}
LeaveCriticalSection(&my_winsec);
#else
my_mutex.lock();
if (!msgRecvQueue.empty())
{
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
my_mutex.unlock();
return true;
}
my_mutex.unlock();
#endif
return false;
}
void outMsgRecvQueue()
{
int command = 0;
for (int i = 0; i < 100000; i++)
{
bool result = outMsgLULProc(command);
if (result == true)
{
cout << "outMsgRecvQueue()执行了,从容器中取出一个元素" << command << endl;
//这里可以考虑处理数据
//......
}
else
{
cout << "outMsgRecvQueue()执行了,但目前收消息队列中是空元素" << i << endl;
}
}
cout << "end" << endl;
}
private:
std::list<int> msgRecvQueue; //容器(收消息队列),专门用于代表玩家给咱们发送过来的命令
std::mutex my_mutex; //创建互斥量
#ifdef __WINDOWSLJQ__
//windows下叫临界区(类似于互斥量mutex)
CRITICAL_SECTION my_winsec;
#endif
};
int main()
{
A myobja;
std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja);
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
myInMsgObj.join();
myOutnMsgObj.join();
cout << "main主函数执行结束!" << endl;
return 0;
}
现在因为定义了宏__WINDOWSLJQ__,所以实际上是新加入的代码在执行。执行起来,一切正常。
所以,这里针对多线程编程,笔者用Windows平台下的临界区编程代码实现了与C++新标准中的互斥量编程代码完全相同的功能。
11.2 多次进入临界区试验
所谓临界区,也就是那些需要在多线程编程中进行保护的共享数据相关的代码行(区域)。这几行代码相信读者在学习互斥量的过程中都已经完全熟悉了,只不过在Windows平台下称其为临界区。
在进入临界区的时候,EnterCriticalSection(&my_winsec);代码行用于获取到锁(进入临界区)。操作完共享数据后,LeaveCriticalSection(&my_winsec);代码行释放锁(离开临界区)。所以这两行代码其实与my_mutex.lock();与my_mutex.unlock();含义相同(等价)。
现在来做一个小测试,进入临界区两次,直接修改inMsgRecvQueue中的代码。修改如下:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
#ifdef __WINDOWSLJQ__
EnterCriticalSection(&my_winsec); //进入临界区
EnterCriticalSection(&my_winsec); //调用两次
msgRecvQueue.push_back(i);
LeaveCriticalSection(&my_winsec); //离开临界区
LeaveCriticalSection(&my_winsec); //调用两次
#else
my_mutex.lock();
msgRecvQueue.push_back(i);
my_mutex.unlock();
#endif
}
}
执行起来,一切正常。经过上面的演示,可以得到结论:
在同一个线程(若是不同的线程,一个线程进入临界区没有离开时另外一个线程就会卡在进入临界区那行代码上)中,Windows中的同一个临界区变量(my_winsec)代表的临界区的进入(EnterCriticalSection)可以被多次调用,但是调用几次EnterCriticalSection,就要调用几次LeaveCriticalSection,这两者在数量上必须完全相同。
C++11的mutex变量(不同的mutex变量没问题)的lock不能连续调用两次,不然会报异常。笔者认为这是C++11的lock设计的不好的地方。
11.3 自动析构技术
为了防止lock后忘记unlock的问题,改用std::lock_guard<std::mutex>帮助程序员lock和unlock互斥量。改造一下inMsgRecvQueue的代码,把lock_guard用起来。执行起来,一切正常。当然,如果连续两次使用std::lock_guard,也会报异常;
读者可能会问,Windows下是否有和lock_guard功能类似的线程调用接口,笔者还真不知道类似的接口,但是完全可以自己实现一个和lock_guard类似的功能,试一下看。引入一个新类CWinLock,用来实现lock_guard类似的功能:
//本类用于自动释放Windows下的临界区,防止忘记LeaveCriticalSection的情况发生,类似于C++11中的std::lock_guard<std::mutex>功能
class CWinLock
{
public:
CWinLock(CRITICAL_SECTION* pCritSect) //构造函数
{
m_pCritical = pCritSect;
EnterCriticalSection(m_pCritical);
}
~CWinLock() //析构函数
{
LeaveCriticalSection(m_pCritical);
}
private:
CRITICAL_SECTION* m_pCritical;
};
//把收到的消息入到队列的线程
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
#ifdef __WINDOWSLJQ__
//EnterCriticalSection(&my_winsec); //进入临界区
//EnterCriticalSection(&my_winsec); //调用两次
CWinLock wlock(&my_winsec);
CWinLock wlock2(&my_winsec); //调用多次也没问题
msgRecvQueue.push_back(i);
//LeaveCriticalSection(&my_winsec); //离开临界区
//LeaveCriticalSection(&my_winsec); //调用两次
#else
my_mutex.lock();
msgRecvQueue.push_back(i);
my_mutex.unlock();
#endif
}
}
CWinLock类中做的事情比较清晰。构造函数中进入临界区,析构函数中离开临界区,仅此而已,非常简单。
有人把CWinLock类相关的对象如上面的wlock、wlock2叫作RAII对象,CWinLock类也叫RAII类,RAII(Resource Acquisition Is Initialization)翻译成中文是“资源获取即初始化”,这种技术的关键就是在构造函数中初始化资源,在析构函数中释放资源(防止程序员忘记释放资源)。典型的如智能指针、容器等都用到了这种技术。
11.4 recursive_mutex递归的独占互斥量
现在把#define WINDOWSLJQ代码行注释掉,聚焦在C++11的多线程编程上。
#define __WINDOWSLJQ__ //宏定义
有了上面的知识做铺垫之后,再谈回上面的话题。在同一个线程中,如果用C++11多线程编程接口,连续调用两次相同互斥量的lock成员函数就会导致程序报异常而崩溃,这是非常让人遗憾的事。
有的读者可能会有疑问:为啥要连续两次调用相同的互斥量呢?当然肯定不会故意写两条挨在一起的lock语句:
my_mutex.lock();
my_mutex.lock();
这种代码毫无意义,但是设想这样一种场景,例如实际项目中A类可能有一个成员函数如testfunc1做一些事情,代码如下:
void testfunc1()
{
std::lock_guard<std::mutex> sbguard(my_mutex);
//.......做一些事
}
void testfunc2()
{
std::lock_guard<std::mutex> sbguard(my_mutex);
//.......做另外一些事
}
在正常的使用中,如果要么调用testfunc1,要么调用testfunc2,这都没问题,但是随着代码的不断增加,也许有一天,testfunc1里面需要调用到testfunc2里面的代码:
void testfunc1()
{
std::lock_guard<std::mutex> sbguard(my_mutex);
//.......做一些事
testfunc2();
}
void testfunc2()
{
std::lock_guard<std::mutex> sbguard(my_mutex);
//.......做另外一些事
}
在inMsgRecvQueue函数中,在std::lock_guard代码行之后试着调用一次testfunc1成员函数:
std::lock_guard<std::mutex> sbguard(my_mutex);
testfunc1();
执行起来,程序报异常。为什么会这样呢?问题的根本还是因为连续调用同一个mutex的两次lock成员函数所致。所以,因为有Windows程序设计的前车之鉴,那么mutex这种设计就显得不够人性化了。怎么办?
引入现在要讲解的recursive_mutex,这叫作“递归的独占互斥量”。
现在各位读者已经掌握的是std::mutex,称为“独占互斥量”,是很好理解的一个概念——当前线程lock的时候其他线程lock不了,要等当前线程unlock,这就叫独占互斥量。
那么,recursive_mutex递归的独占互斥量又是什么意思呢?显然,它肯定能解决多次调用lock成员函数导致报异常的问题。但是它的叫法中的“递归”二字还是容易让人产生误解的。但笔者相信,经过前面知识的铺垫,理解“递归”的意思也不难,就是解决多次调用同一个mutex的lock成员函数报异常的问题。也就是说,它允许同一个线程多次调用同一个mutex的lock成员函数。
修改代码,将下面独占互斥量my_mutex定义的代码行:
//std::lock_guard<std::mutex> sbguard(my_mutex);
std::lock_guard<std::recursive_mutex> sbguard(my_mutex);
执行起来,一切正常。虽然程序现在功能正常了,但不禁还有些思考:如果使用这种递归锁,或者说如果需要两次调用lock,是否是程序写的不够简化、不够精练?是否程序代码能够进行一定的优化呢?
一般来说,这种递归的互斥量比独占的互斥量肯定消耗更多,效率上要差一些。
据说这种递归(锁多次)也不是无限次,可能次数太多也一样报异常(并不确定),笔者没有亲测过到底递归多少次才能产生异常,但一般来讲,正常使用是绝对够用的。如果读者有兴趣,可以自行测试。
11.5 带超时的互斥量std::timed_mutex和std::recursive_timed_mutex
std::timed_mutex是带超时功能的独占互斥量。
std::recursive_timed_mutex是带超时功能的递归的独占互斥量。
不难发现,多了一个超时的概念,以往获取锁的时候,如果拿不到,就卡那里卡着,现在获取锁的时候增加了超时等待的功能,这样就算拿不到锁头,也不会一直卡那里卡着。
std::timed_mutex有两个独有的接口专门用来应对超时问题,一个是try_lock_for,一个是try_lock_until。
try_lock_for是等待一段时间,如果拿到锁或者等待的时间到了没拿到锁,流程都走下来。
试试这个功能,修改类A的成员变量my_mutex的类型为std::timed_mutex类型:
std::timed_mutex my_mutex;
testfunc1和testfunc2成员函数不使用,所以都注释掉。修改inMsgRecvQueue成员函数:
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
std::chrono::milliseconds timeout(100);
if (my_mutex.try_lock_for(timeout)) //尝试获取锁,这里只等100毫秒
{
//在这100毫秒之内拿到了锁
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i);
//用完了,还要解锁
my_mutex.unlock();
}
else
{
//这次没拿到锁就休息一下等待下次拿吧
std::chrono::milliseconds sleeptime(100);
std::this_thread::sleep_for(sleeptime);
}
}
}
运行起来,通过观察结果可以看到,每次inMsgRecvQueue执行时,都可以成功地拿到锁,那么如何演示拿不到锁的情况呢?可以修改outMsgLULProc,在其中的my_mutex.lock();语句行下面,休息相当长的一段时间不放开锁:
std::chrono::milliseconds sleeptime(1000000000);
std::this_thread::sleep_for(sleeptime);
这样就可以很容易地观察到inMsgRecvQueue中拿不到锁时执行的代码段,读者可以自行试试。
其实,timed_mutex也有lock成员函数,其功能与mutex中lock成员函数的功能是一样的。
另外,timed_mutex还有一个try_lock_until接口。刚刚讲解try_lock_for时可以看到,try_lock_for是尝试获取锁,等待一段时间,时间到达后,无论获取到或者获取不到锁,程序流程都走下来,不会在try_lock_for行卡着。
而try_lock_until的参数是一个时间点,是代表一个未来的时间,在这个未来的时间没到的这段时间内卡在那里等待拿锁,如果拿到了或者没拿到但是到达了这个未来的时间,程序流程都走下来。
尝试一下把刚才用try_lock_for写的代码改成用try_lock_until来写。
只需要把下面这一行:
//if (my_mutex.try_lock_for(timeout)) //尝试获取锁,这里只等100毫秒
if (my_mutex.try_lock_until(chrono::steady_clock::now() + timeout)) //now:当前时间
执行起来,一切正常。
std::timed_mutex的功能就介绍上面这些。
std::recursive_timed_mutex是带超时功能的递归的独占互斥量,也就是允许同一个线程多次获取(多次lock)这个互斥量。
std::timed_mutex和std::recursive_timed_mutex两者的关系与上面讲解的std::mutex和std::recursive_mutex关系一样,非常简单,笔者就不做过多的解释了。代码中如果把如下的定义:
//std::timed_mutex my_mutex;
std::recursive_timed_mutex my_mutex;
执行起来,一切正常。