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

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

  1. 保护共享数据

    • 操作时,某个线程用代码把共享数据锁住,自己操作数据
    • 其他线程只能等这个线程处理完,解锁后才能操作共享数据
  2. 互斥量(互斥锁)(mutex)

    • 互斥量是一个类对象,可以看成一把 “锁”
    • 多个线程尝试用 lock() 成员函数来给这个锁 “加锁”,只有一个线程能锁定成功
    • 成功的标志是 lock() 有返回值;如果不成功,这个线程就卡在lock() 位置,不能向下执行
  3. 互斥量使用要小心

    • 对于每一个线程,找到要保护的代码
      • 找少了,没达到保护效果
      • 找多了,影响效率
    1. 在保护区域前加一行上锁,保护区域后加一行解锁
  4. 互斥锁的特点

    • 互斥锁只有两种状态,即上锁( lock )和解锁( unlock )

    • 原子性:把一个互斥量锁定为一个原子操作,这意味着如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;

    • 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;

    • 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。避免了忙等

  5. 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>
  2. 创建一个互斥量

    • std::mutex my_mutex;
  3. 保护一个线程(lockunlock要成对使用

    • lock()
    • 操作共享数据
    • unlock()

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

   // 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_guardlock/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()

  2. 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()成功,就继续尝试下一个互斥量;一旦有一个互斥量锁不上,立即释放已经锁住的所有互斥量,从第一个互斥量开始重新尝试

    • 要么所有互斥量都锁住,要么一个也不锁(尝试得快,释放得快)

  5. 使用方法

    • std::lock(锁1,锁2,锁3...); //参数顺序无所谓
    • 一行std::lock(),要配多行unlock()unlock()顺序无所谓
  6. 注意:往往较少出现多个互斥量连着上锁的情况,谨慎使用这个

    • 还是建议一个一个锁
  7. 使用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_guardstd::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_lockstd::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 类的两种使用方式)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云端FFF

所有博文免费阅读,求打赏鼓励~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值