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)基本概念
- 保护共享数据
- 操作时,某个线程用代码把共享数据锁住,自己操作数据
- 其他线程只能等这个线程处理完,解锁后才能操作共享数据
- 互斥量(互斥锁)(mutex)
- 互斥量是一个类对象,可以看成一把 “锁”
- 多个线程尝试用
lock()
成员函数来给这个锁 “加锁”,只有一个线程能锁定成功 - 成功的标志是
lock()
有返回值;如果不成功,这个线程就卡在lock()
位置,不能向下执行
- 互斥量使用要小心
- 对于每一个线程,找到要保护的代码
- 找少了,没达到保护效果
- 找多了,影响效率
- 在保护区域前加一行上锁,保护区域后加一行解锁
- 互斥锁的特点
- 互斥锁只有两种状态,即上锁( lock )和解锁( unlock )
- 原子性:把一个互斥量锁定为一个原子操作,这意味着如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
- 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
- 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。避免了忙等
-
std::mutex
的成员函数
- 构造函数,std::mutex不允许拷贝构造,也不允许 move 拷贝,最初产生的 mutex 对象是处于 unlocked 状态的。
-
lock()
,调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:
- 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock之前,该线程一直拥有该锁。
- 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住。(不会忙等)
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
-
unlock()
, 解锁,释放对互斥量的所有权。 -
try_lock()
,尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况,
- 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量。
- 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉。
- 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)。
2. 互斥量的用法
(1)lock(),unlock()
- 头文件
-
#inlclude <mutex>
- 创建一个互斥量
-
std::mutex my_mutex;
- 保护一个线程(
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类模板
-
lock()
和unlock()
必须成对出现,如果加锁后不解锁,程序就会卡死。很容易出的一个问题是:某段代码有多个分支出口,在这些分支分叉前有一个lock()
,那么每个分支都要加一个 unlock()
,为了防止忘写unlock
,C++11提供了std::lock_guard
类模板 -
std::lock_guard
类模板可以直接取代lock()
和unlock()
。 - 同一个线程中,
std::lock_guard
和lock
/unlock()
不能混用,如果用了lock_guard
,就禁止使用lock()
和unlock()
- 用
std::lock_guard
改写上述代码 - 在
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;
-
std::lock_guard
的原理
- 创建
lock_guard
对象时,传入了我们使用的互斥量 - 在定义对象的位置,
lock_guard
对象帮我们对互斥量进行一个lock()
- 在对象析构的位置(通常是
return
),lock_guard
对象帮我们对互斥量进行一个unlock()
-
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)死锁的一般解决方案
- 只要保证两个线性上锁的次序一样,就不会死锁
- 要求每个线程必须一次锁上所有的锁(两个或两个以上),相当于用信号量集解决
- 增加一些额外的限制条件
(3)std::lock()函数模板
- 用于需要处理多个互斥量的场合
- 能力:一次锁住两个或两个以上的互斥量(至少两个,多了不限,1个不行)
- 使用
std::lock()
可以解锁上面那种由于加锁顺序问题造成的死锁 - 工作过程
- 从第一个互斥量开始尝试上锁,如果
lock()
成功,就继续尝试下一个互斥量;一旦有一个互斥量锁不上,立即释放已经锁住的所有互斥量,从第一个互斥量开始重新尝试 - 要么所有互斥量都锁住,要么一个也不锁(尝试得快,释放得快)
- 使用方法
-
std::lock(锁1,锁2,锁3...); //参数顺序无所谓
- 一行
std::lock()
,要配多行unlock()
,unlock()
顺序无所谓
- 注意:往往较少出现多个互斥量连着上锁的情况,谨慎使用这个
- 还是建议一个一个锁
- 使用
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;
-
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++ 多线程学习笔记:互斥量概念和用法死锁演示及解决的主要内容,如果未能解决你的问题,请参考以下文章