C++ 多线程学习笔记:互斥量概念和用法死锁演示及解决

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ 多线程学习笔记:互斥量概念和用法死锁演示及解决相关的知识,希望对你有一定的参考价值。


文章目录

  • ​​1. 互斥锁(mutex)基本概念​​
  • ​​2. 互斥量的用法​​
  • ​​(1)lock(),unlock()​​
  • ​​(2)用lock和unlock改写上一节最后的代码​​
  • ​​(3)std::lock_guard类模板​​
  • ​​3. 死锁​​
  • ​​(1)死锁演示​​
  • ​​(2)死锁的一般解决方案​​
  • ​​(3)`std::lock()`函数模板​​
  • ​​(4)`std::lock_guard`的`std::adopt_lock`函数​​
  • ​​4. 效率问题​​

1. 互斥锁(mutex)基本概念

  1. 保护共享数据
  • 操作时,某个线程用代码把共享数据锁住,自己操作数据
  • 其他线程只能等这个线程处理完,解锁后才能操作共享数据
  1. 互斥量(互斥锁)(mutex)
  • 互斥量是一个类对象,可以看成一把 “锁”
  • 多个线程尝试用 lock() 成员函数来给这个锁 “加锁”,只有一个线程能锁定成功
  • 成功的标志是 lock() 有返回值;如果不成功,这个线程就卡在lock() 位置,不能向下执行
  1. 互斥量使用要小心
  • 对于每一个线程,找到要保护的代码
  • 找少了,没达到保护效果
  • 找多了,影响效率
  1. 在保护区域前加一行上锁,保护区域后加一行解锁
  1. 互斥锁的特点
  • 互斥锁只有两种状态,即上锁( lock )和解锁( unlock )
  • 原子性:把一个互斥量锁定为一个原子操作,这意味着如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
  • 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
  • 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。避免了忙等
  1. ​std::mutex​​ 的成员函数
  • 构造函数,std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
  • ​lock()​​,调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:
  • 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。
  • 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(不会忙等)
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
  • ​unlock()​​, 解锁,释放对互斥量的所有权。
  • ​try_lock()​​,尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况,
  • 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。
  • 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。

2. 互斥量的用法

(1)lock(),unlock()

  1. 头文件
  • ​#inlclude <mutex>​
  1. 创建一个互斥量
  • ​std::mutex my_mutex;​
  1. 保护一个线程(​lock​unlock​要成对使用
  • 先 ​​lock()​
  • 操作共享数据
  • ​unlock()​

(2)用lock和unlock改写上一节最后的代码

  • 原问题在这篇文章最后:​​C++ 多线程学习笔记(4):多个线程数据共享问题分析​​
  • 这个程序中共享数据​​msgRecvQueue​​ 同一时刻只允许一个进程使用,是临界资源要对访问临界资源的代码加锁
// test1.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//

#include "pch.h"
#include <iostream>
#include <thread>
#include <vector>
#include <list>
#include <mutex>
using namespace std;

class A
public:

//把受到的数据存入一个队列的线程
void inMsgRecvQueue()

for (int i = 0; i < 100000; i++)

cout << "inMsgRecvQueue()执行,插入元素" << i << endl;

my_mutex.lock(); //访问临界资源的代码加锁
msgRecvQueue.push_back(i); //假设数字i就是受到的命令,直接放入消息队列
my_mutex.unlock();



//取指令线程利用这个函数访问临界资源,提出来方便加锁
bool popCommand(int &command)

my_mutex.lock();
if (!msgRecvQueue.empty())

//取出指令
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
my_mutex.unlock();
return true;

my_mutex.unlock();
return false;


//把数据从消息队列取出的线程
void outMsgRecvQueue()

for (int i = 0; i < 100000; i++)

int command;
if(popCommand(command))

//进行command处理
//...
cout << "outMsgRecvQueue()执行,指令为:" << command <<" "<< i << endl;

else
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;



private:
list <int> msgRecvQueue; //用来存玩家命令的队列
std::mutex my_mutex; //设置一个互斥量
;


int main()

//用类的成员函数作子线程入口的形式,实现两个线程
A myobj;
std::thread myOutMsgObj(&A::outMsgRecvQueue, &myobj); //这里一定要用传引用,否则子线程是建立在myobj的副本上的
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobj);

myOutMsgObj.join();
myInMsgObj.join();

return 0;
  • 两个线程中对临界资源 ​​msgRecvQueue​​​ 的访问都被加锁保护,保证了两个线程不会同对 ​​msgRecvQueue​​​ 进行操作,保证了对 ​​msgRecvQueue​​ 操作的原子性,从而避免了程序崩溃

(3)std::lock_guard类模板

  1. ​lock()​​​和​​unlock()​​必须成对出现,如果加锁后不解锁,程序就会卡死。很容易出的一个问题是:某段代码有多个分支出口,在这些分支分叉前有一个lock(),那么每个分支都要加一个 ​unlock()​ ,为了防止忘写​​unlock​​,C++11提供了​​std::lock_guard​​类模板
  2. ​std::lock_guard​​类模板可以直接取代​lock()​​和​​unlock()​​。
  3. 同一个线程中,std::lock_guard​lock​/​unlock()​不能混用,如果用了​​lock_guard​​,就禁止使用​​lock()​​和​​unlock()​
  4. 用​​std::lock_guard​​改写上述代码
  5. 在​​lock()​​的地方换成定义一个 ​​lock_guard​​ 对象就行了
/*-----------------------原写法-------------------------*/
//取指令线程利用这个函数访问临界资源,提出来方便加锁
bool popCommand(int &command)

my_mutex.lock();
if (!msgRecvQueue.empty())

//取出指令
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
my_mutex.unlock();
return true;

my_mutex.unlock();
return false;


/*------------------改用lock_guard----------------------*/
bool popCommand(int &command)

//定义一个lock_guard对象
std::lock_guard<std::mutex> threadGuard(my_mutex);
//my_mutex.lock();
if (!msgRecvQueue.empty())

//取出指令
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
//my_mutex.unlock();
return true;

//my_mutex.unlock();
return false;
  1. ​std::lock_guard​​的原理
  • 创建 ​​lock_guard​​ 对象时,传入了我们使用的互斥量
  • 在定义对象的位置,​​lock_guard​​ 对象帮我们对互斥量进行一个​​lock()​
  • 在对象析构的位置(通常是​​return​​),​​lock_guard​​ 对象帮我们对互斥量进行一个​​unlock()​
  1. ​std::lock_guard​​的缺点
  • 不够灵活,不好控制​​unlock()​​时刻了
  • 如果一定要控制,可以用加的方法,把​lock_guard​对象放在额外大括号中,使其在出括号时就提前析构,从而实现​​unlock()​​位置的控制
  • 又包装一层,降低效率

3. 死锁

(1)死锁演示

  • 现实例子
  • 张三在北京等李四,李四不来就不动;李四在深圳等张三,张三不来就不动
  • C++例子
  • 死锁要求至少两把锁(两个互斥量)才会发生
  • 现在有两个线程A/B,两个锁a/b。A先锁a再锁b;B先锁b再锁a
  • A执行时,先锁a, ​​lock()​​ 成功。准备锁b时,发生上下文切换
  • B执行时,先锁了b, ​​lock()​​ 成功。准备锁a时,发现a没有解锁
  • 此时,死锁就发生了,两个线程都差一个锁,不能往下执行,也不能释放手里的锁
  • 这就是一个两个人情况的哲学家吃饭问题

(2)死锁的一般解决方案

  1. 只要保证两个线性上锁的次序一样,就不会死锁
  2. 要求每个线程必须一次锁上所有的锁(两个或两个以上),相当于用信号量集解决
  3. 增加一些额外的限制条件

(3)std::lock()函数模板

  1. 用于需要处理多个互斥量的场合
  2. 能力:一次锁住两个或两个以上的互斥量(至少两个,多了不限,1个不行)
  3. 使用​​std::lock()​可以解锁上面那种由于加锁顺序问题造成的死锁
  4. 工作过程
  • 从第一个互斥量开始尝试上锁,如果​​lock()​​成功,就继续尝试下一个互斥量;一旦有一个互斥量锁不上,立即释放已经锁住的所有互斥量,从第一个互斥量开始重新尝试
  • 要么所有互斥量都锁住,要么一个也不锁(尝试得快,释放得快)
  1. 使用方法
  • std::lock(锁1,锁2,锁3...); //参数顺序无所谓
  • 一行std::lock(),要配多行​unlock()​,​​unlock()​​顺序无所谓
  1. 注意:往往较少出现多个互斥量连着上锁的情况,谨慎使用这个
  • 还是建议一个一个锁
  1. 使用​​std::lock​​解决双互斥量死锁问题
#include "pch.h"
#include <iostream>
#include <thread>
#include <vector>
#include <list>
#include <mutex>
using namespace std;

class A
public:

//把受到的数据存入一个队列的线程
void inMsgRecvQueue()

for (int i = 0; i < 100000; i++)

cout << "inMsgRecvQueue()执行,插入元素" << i << endl;

std::lock(my_mutex1, my_mutex2); //同时锁俩
msgRecvQueue.push_back(i);

my_mutex1.unlock(); //两个unlock顺序随意
my_mutex2.unlock();



//取指令线程利用这个函数访问临界资源,提出来方便加锁
bool popCommand(int &command)


std::lock(my_mutex1, my_mutex2); //同时锁俩
if (!msgRecvQueue.empty())

//取出指令
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
my_mutex1.unlock(); //两个unlock顺序随意
my_mutex2.unlock();
return true;

my_mutex1.unlock(); //两个unlock顺序随意
my_mutex2.unlock();
return false;


//把数据从消息队列取出的线程
void outMsgRecvQueue()

for (int i = 0; i < 100000; i++)

int command;
if (popCommand(command))

//进行command处理
//...
cout << "outMsgRecvQueue()执行,指令为:" << command << " " << i << endl;

else
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;



private:
list <int> msgRecvQueue; //用来存玩家命令的队列
std::mutex my_mutex1; //设置俩互斥量
std::mutex my_mutex2;
;


int main()

//用类的成员函数作子线程入口的形式,实现两个线程
A myobj;
std::thread myOutMsgObj(&A::outMsgRecvQueue, &myobj); //这里一定要用传引用,否则子线程是建立在myobj的副本上的
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobj);

myOutMsgObj.join();
myInMsgObj.join();

return 0;
  1. ​std::lock​​​还是需要手动​​unlock()​​​,容易忘记,能不能用前面的​​lock_guard​​​帮忙​​unlock()​​呢,看下面

(4)std::lock_guard的std::adopt_lock函数

  • ​std::adopt_lock​​​是一个结构体,它是​​std::lock_guard​​类的构造函数的一个可选参数
  • 意义:在构造 ​​lock_guard​​​ 对象的时候,告诉它其互斥量参数已经​​lock()​​​过了,不需要 ​​lock_guard​​​对象再帮他 ​​lock​​ 了
  • 使用方法:​​std::lock_guard<std::mutex> threadGuard(my_mutex, std::adopt_lock);​
  • 使用​​std::lock()​​ 和带​​std::adopt_lock​​的​​std::lock_guard​​改写上面的程序
  • ​std::lock()​​​负责一次锁定多个​​lock()​​信号量,解决死锁问题
  • ​std::lock_guard​​​负责​​unlock()​​​信号量,避免忘记​​unlock()​
  • ​std::adopt_lock​​​用来避免重复​​lock()​
#include "pch.h"
#include <iostream>
#include <thread>
#include <vector>
#include <list>
#include <mutex>
using namespace std;

class A
public:

//把受到的数据存入一个队列的线程
void inMsgRecvQueue()

for (int i = 0; i < 100000; i++)

cout << "inMsgRecvQueue()执行,插入元素" << i << endl;

//用lock类同时锁俩
std::lock(my_mutex1, my_mutex2);
//用lock_guard对象来unlock,adopt_lock用来避免重复lock
std::lock_guard<std::mutex> threadGuard1(my_mutex1, std::adopt_lock);
std::lock_guard<std::mutex> threadGuard2(my_mutex2, std::adopt_lock);

msgRecvQueue.push_back(i);

//my_mutex1.unlock(); //两个unlock顺序随意
//my_mutex2.unlock();



//取指令线程利用这个函数访问临界资源,提出来方便加锁
bool popCommand(int &command)


//用lock类同时锁俩
std::lock(my_mutex1, my_mutex2);
//用lock_guard对象来unlock,adopt_lock用来避免重复lock
std::lock_guard<std::mutex> threadGuard1(my_mutex1, std::adopt_lock);
std::lock_guard<std::mutex> threadGuard2(my_mutex2, std::adopt_lock);

if (!msgRecvQueue.empty())

//取出指令
command = msgRecvQueue.front();
msgRecvQueue.pop_front();
//my_mutex1.unlock(); //两个unlock顺序随意
//my_mutex2.unlock();
return true;

//my_mutex1.unlock(); //两个unlock顺序随意
//my_mutex2.unlock();
return false;


//把数据从消息队列取出的线程
void outMsgRecvQueue()

for (int i = 0; i < 100000; i++)

int command;
if (popCommand(command))

//进行command处理
//...
cout << "outMsgRecvQueue()执行,指令为:" << command << " " << i << endl;

else
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;



private:
list <int> msgRecvQueue; //用来存玩家命令的队列
std::mutex my_mutex1; //设置俩互斥量
std::mutex my_mutex2;
;


int main()

//用类的成员函数作子线程入口的形式,实现两个线程
A myobj;
std::thread myOutMsgObj(&A::outMsgRecvQueue, &myobj); //这里一定要用传引用,否则子线程是建立在myobj的副本上的
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobj);

myOutMsgObj.join();
myInMsgObj.join();

return 0;

4. 效率问题

  • 互斥锁 ​​std::mutex​​ 可以实现了线程间对临界资源的互斥访问,而且解决了忙等问题,但这不一定高效
  • 互斥锁在避免忙等的时候,把线程加入阻塞队列以及唤醒被阻塞线程都是自动的,但考虑下面的情况
  • 生产者每秒占用一次临界区,放入一个数据
  • 消费者循环检查临界区,取走放入的数据
  • 这个情况下,生产者每秒只有很短的时间参与临界资源的争用,大部分时间,消费者都会访问到临界区(lock-访问-unlock),判断有没有新数据
  • 这就会导致很大的资源浪费,较好的方法是:让消费者进入休眠,生产者放入数据后,在再唤醒消费者去取数据
  • 可以用​​std::condition_variable​​​ 提供的​​wait()​​​和​​notify_one()​​解决此问题,参考:(​​C++ 线程安全下Lock 类的两种使用方式​​)


以上是关于C++ 多线程学习笔记:互斥量概念和用法死锁演示及解决的主要内容,如果未能解决你的问题,请参考以下文章

五互斥量概念用法死锁

LINXU多线程(进程与线程区别,互斥同步)

Linux多线程——互斥和同步

线程同步与互斥详解

C++多线程1.2-线程安全的保证——互斥量mutex(锁)和原子变量atomic

C++多线程1.2-线程安全的保证——互斥量mutex(锁)和原子变量atomic