C++多线程中互斥量的使用

2023-08-05

多线程中互斥信号量(Mutex)的使用

1.0 互斥量的基本概念

1.1 Example

\(\quad\)首先我们要明白,为什么会有互斥信号量的出现,在多线程编程中,不同的线程之间往往要对同一个数据进行操作,如果该数据是只读的,当然不会出现什么问题,但是如果两个线程同时对某个数据进行写操作,则可能出现难以预料的事情。

我们来看一个简单的操作

#include <atomic>
#include <iostream>
#include <thread>
#include <chrono>
#include <pthread.h>
using namespace std; int i = 0;
const int maxCnt = 1000000;
void mythread()
{
for (int j = 0; j < maxCnt; j++)
{
i++; // 线程同时操作变量
}
} int main()
{
auto begin = chrono::high_resolution_clock::now();
thread t1(mythread);
thread t2(mythread);
t1.join();
t2.join();
auto end = chrono::high_resolution_clock::now();
cout << "i=" << i << endl;
cout << "time: "
<< chrono::duration_cast<chrono::microseconds>(end - begin).count() *
1e-6
<< "s" << endl; // 秒计时
}

可以看到在我的电脑上程序的输出为

i=1022418
time: 0.010445s

很明显和我们预想的结果是不一致的,我们使用两个线程同时对该变量进行加法操作,根据运行此书来计算,结果因该为 2000000,但事实上却不是这样的,这就是因为有多个线程在对同一个变量进行写操作的时候会出现难以排查的问题,意想不到的结果。此时mutex就派上用场了,我们对程序进行稍微的改动。

std::mutex var_mutex;

int i = 0;
const int maxCnt = 1000000;
void mythread()
{
for (int j = 0; j < maxCnt; j++)
{
var_mutex.lock();
i++; // 线程同时操作变量
var_mutex.unlock();
}
}

此时再运行程序可以发现结果如下,这是符合我们的预期的。

i=2000000
time: 0.09337s

1.2 互斥量用法解释

\(\quad\)互斥量就是个类对象,可以理解成一把锁,多个线程尝试用lock()成员函数来加锁,从而获得对数据的访问权限,或者说读写权限,其实就是继续执行代码的权限。最终只有一个线程能锁定成功,如果没有锁成功,那么流程将卡在lock()这里不断尝试去锁定。所以我们在使用的时候要注意尽量在lock()和unlock()中间插入较小的代码片段,这样才能提高多线程时程序的执行效率,比如前面的加加操作,只有一行,如果你在锁住某个线程之后后面在unlock之前还让线程睡眠了一会,那你可真是个大聪明。当然也有其他的方法可以跳过等待,后面我们也会说到。

\(\quad\)在使用互斥量的时候要包含头文件 #include<mutex> 然后使用mutex 类即可创建对象。更重要的一点是,在代码中 lock() (上锁)和 unlock() (解锁)必须成对使用,代码中使用互斥量的时绝不允许非对称调用,即 lock()unlock() 一定是成对出现的。步骤如下:

lock() 上锁;
然后操作共享数据;
unlock() 解锁

2.0其他C++11新特性

2.1 std::lock_guard类模板

\(\quad\)我们在代码中上锁后,一定要记得解锁,如果忘记解锁会导致程序运行异常,而且通常很难排查。为了防止开发者忘记解锁,C++11引入了一个叫做 std::lock_guard 的类模板,它在开发者忘记解锁的时候,会替开发者自动解锁。std::lock_guard 可以直接取代 lock()unlock(),也就说使用 std::lock_guard 后,就不能再使用 lock()unlock() 了。

如下所示

std::mutex var_mutex;

int i = 0;
const int maxCnt = 1000000;
void mythread()
{
for (int j = 0; j < maxCnt; j++)
{
lock_guard<mutex> guard(var_mutex);
i++; // 线程同时操作变量
}
}

输出结果为

i=2000000
time: 0.102605s

\(\quad\)std::lock_guard 虽然用起来方便,但是不够灵活,它只能在析构函数中 unlock(),也就是对象被释放的时候,这通常是在函数返回的时候,或者通过添加代码块 { /* 代码块 */ } 限定作用域来指定释放时机。其还有一个特性是在构造的时候可以传入第二个参数为std::adopt_lock,此时在析构的时候就不会unlock了,但是此时就必须我们手动unlock了,这种使用场景也不多。

2.2 死锁

\(\quad\)谈到互斥量,就不得不来说一下死锁。一个简单的例子:

张三在北京说:等李四来了之后,我就去广东。
李四在广东说:等张三来了之后,我就去北京。

\(\quad\)张三李四互相扯皮,两人一直互相等待,就死等()。同理,假设代码中有两把锁,至少有两个互斥量存在才会产生死锁,分别称为锁1、锁2,并且有两个线程分别称为线程A和线程B。只有在某个线程同时获得锁1和锁2时,才能完成某项工作:

线程A执行时,先上锁1------------再上锁2。
线程B执行时,先上锁2------------再上锁1。

\(\quad\)如果在执行线程A的时候,先对1上了锁,这时候出现了上下文切换(并不是说上锁之后该线程不会被其他线程占用,而是说其他线程执行到需要锁1的时候如果发现被锁了,会给出执行权),现在来到了线程B,线程需要对锁2上锁,进行数据操作,此时发现锁2没有被锁,则上锁,继续执行发现,需要上锁1,但是此时的锁1被线程A锁上了,于是只能给出执行权限,此时又回到了线程1,线程1发现我如果想继续执行,那么就又要给2上锁,但是发现锁2又被线程B给上锁了,于是也只好给出执行权限。就这样,两个线程来回扯皮,就形成了死锁。

\(\quad\)用一句话概括以下呢就是:在程序执行线程A的过程中,上好了锁1后,出现了上下文切换,系统调度转去执行线程B,把锁2给上了,那么后续线程A拿不到锁2,线程B拿不到锁1,两条线程都没法往下执行,即出现了死锁。

例如下面的程序

#include <pthread.h>

#include <atomic>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <list>
using namespace std; list<int> msgRecvQueue; // 容器(实际上是双向链表):存放玩家发生命令的队列
mutex m_mutex1; // 创建互斥量1
mutex m_mutex2; // 创建互斥量2 void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
cout << "inMsgRecvQueue exec, push an elem " << i << endl;
m_mutex1.lock(); // 实际代码中,两把锁不一定同时上,它们可能保护不同的数据
m_mutex2.lock();
msgRecvQueue.push_back(i); // 假设数字 i 就是收到的玩家命令
m_mutex2.unlock();
m_mutex1.unlock();
}
}
bool outMsgLULProc(int &command)
{
m_mutex2.lock();
m_mutex1.lock();
if (!msgRecvQueue.empty())
{
command = msgRecvQueue.front(); // 返回第一个元素
msgRecvQueue.pop_front(); // 移除第一个元素
m_mutex1.unlock();
m_mutex2.unlock();
return true;
}
m_mutex1.unlock();
m_mutex2.unlock();
return false;
}
void outMsgRecvQueue()
{
int command = 0;
for (int i = 0; i < 100000; ++i)
{
bool result = outMsgLULProc(command);
if (result)
cout << "outMsgLULProc exec, and pop_front: " << command << endl;
else
cout << "outMsgRecvQueue exec, but queue is empty!" << i << endl;
cout << "outMsgRecvQueue exec end!" << i << endl;
}
} int main()
{
thread myInMsgObj(inMsgRecvQueue);
thread myOutMsgObj(outMsgRecvQueue);
myInMsgObj.join();
myOutMsgObj.join(); cout << "Hello World!" << endl;
return 0;
}

笔者运行的时候发现程序会卡死,无法输出最后的一句话

outMsgLULProc exec, and pop_front: 271
outMsgRecvQueue exec end!289
outMsgLULProc exec, and pop_front: 272
outMsgRecvQueue exec end!290
inMsgRecvQueue exec, push an elem 491

通常来讲,死锁的一般解决方案,只要保证多个互斥量上锁的顺序一致,就不会出现死锁,比如把上面示例代码的两个线程回调函数中的上锁顺序改一下,保持一致就好了(都改为先上锁1,再上锁2)。读者可以自己试一下改动下代码。

线程A执行时,先上锁1------------再上锁2。
线程B执行时,先上锁1------------再上锁2。

这样的顺序之下就形不成死锁了。因为当切换到B的时候B由于没有锁1所以值接让出执行权限。

2.3 死锁的另一种解决方案

std::lock() 函数模板是C++11引入的,它能一次锁住两个或两个以上的互斥量,并且它不存在上述的在多线程中由于上锁顺序问题造成的死锁现象,原因如下:std::lock() 函数模板在锁定两个互斥量时,只有两种情况:

    两个互斥量都没有锁住;
    两个互斥量都被锁住。

如果只锁了一个,另一个没锁成功,则它会立即把已经锁住的互斥量解锁。将上面的接收函数改为如下就可以避免死锁的出现。

void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
cout << "inMsgRecvQueue exec, push an elem " << i << endl;
// m_mutex1.lock(); // 实际代码中,两把锁不一定同时上,它们可能保护不同的数据
// m_mutex2.lock();
std::lock(m_mutex1,m_mutex2);
msgRecvQueue.push_back(i); // 假设数字 i 就是收到的玩家命令
m_mutex2.unlock();
m_mutex1.unlock();
}
}

在使用 std::lock() 函数模板锁上多个互斥量时,也必须得记得把每个互斥量解锁,此时借助 std::lock_guardstd::adopt_lock 参数可以省略解锁的代码。我们再稍微更改一下代码,让他看上去更modern一些。

void inMsgRecvQueue()
{
for (int i = 0; i < 100000; ++i)
{
cout << "inMsgRecvQueue exec, push an elem " << i << endl;
// m_mutex1.lock(); // 实际代码中,两把锁不一定同时上,它们可能保护不同的数据
// m_mutex2.lock();
std::lock(m_mutex1, m_mutex2); // 锁上两个互斥量
std::lock_guard<std::mutex> m_guard1(m_mutex1, std::adopt_lock); // 构造时不上锁,但析构时解锁
std::lock_guard<std::mutex> m_guard2(m_mutex2, std::adopt_lock); // 构造时不上锁,但析构时解锁
msgRecvQueue.push_back(i); // 假设数字 i 就是收到的玩家命令
}
}

Reference

https://blog.csdn.net/weixin_40026797/article/details/123974378
https://blog.csdn.net/qq_24447809/article/details/118179908?spm=1001.2014.3001.5506

C++多线程中互斥量的使用的相关教程结束。