C++ 并发与多线程学习笔记(四) 线程数据共享 互斥锁 死锁

数据共享

线程与数据的交互有多种方式。

只读数据:所有线程只能读取这些数据,所以是安全稳定的。

#include 
#include 

using namespace std;
static int share[1000];

void threadEntry(int threadCount)
{
	cout << "线程入口函数执行,线程编号:" << threadCount << endl;
	cout << "访问共享数据,结果:" << share[threadCount] << endl;
	cout << "线程入口函数执行结束,线程编号:" << threadCount << endl;
}

int main()
{
	vector<thread> threads;
	for (int i = 0; i < 1000; i++)
	{
		share[i] = i;
	}
	
	for (int i = 0; i < 1000; i++)
	{
		threads.push_back(thread(threadEntry,i));
	}
	for (auto iter = threads.begin(); iter != threads.end(); iter++)
	{
		iter->join();
	}
	cout << "主线程结束" << endl;
	return 0;
}

运行结果:
C++ 并发与多线程学习笔记(四) 线程数据共享 互斥锁 死锁_第1张图片
可以看到虽然杂乱无序,但确实正确运行了,前前后后共有1000个线程,也没有影响正确执行。所以只读数据是安全的。

有读有写:有些线程要修改数据,有的线程要读取这些数据。
由于计算机操作系统的调度机制,可能会发生各种诡异的事情,如前面我们遇到的同一行执行输出,结果却在不同行出现的现象。

消息队列:

对于同一个变量的多个操作,要保证每个操作能正常执行,不能发生数据已经被取出,却还不断的读取的现象。因此我们把所有对这些数据的操作请求扔到一个队列中,由队列来整理,让他们有序依次的访问数据。

#include 
#include 

using namespace std;

//队列遵循先入先出原则
class MsgQueue
{
public:
	//插入消息队列
	void InMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			msgQueue.push_back(i);
			cout << "新的请求入队,请求码:" << i << endl;
		}
	}
	//从队列输出
	void OutMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			if (!msgQueue.empty())
			{
				cout << "请求出队,请求码:" << msgQueue.front() << endl;
				msgQueue.pop_front();
			}
			else
			{
				//消息队列为空
				cout << "无请求" << endl;
			}
		}
	}
private:
	//使用链表,头尾增删效率更高
	list<int> msgQueue;
	//这里的int就是消息的类型
};

int main()
{
	MsgQueue queue;
	thread InQueueObj(&MsgQueue::InMsgQueue, &queue);
	thread OutQueueObj(&MsgQueue::OutMsgQueue, &queue);
	InQueueObj.join();
	OutQueueObj.join();
	
	cout << "主线程结束" << endl;
	return 0;
}

运行之后我们会发现异常,因为按照代码中的直接写变量是不安全的,当一个线程正在取出一个数据,而另一个线程又正在访问这个数据的时候,就会引发异常。这个时候,我们就需要保护读写数据的操作,使之能安全的读写。(有时候不报异常,可能是偶然,要知道STL是不支持线程安全的,有胜于无,涉及异步读写的操作一定要加锁,安全第一,否则出现问题很难排查)

扩充:消息队列的应用除了线程之间,还在进程间、系统间有广泛的应用,消息可以很简单,也可以很复杂,面对庞杂的数据业务时,消息队列是常用的思想,市面上有很多第三方软件封装好了消息队列供企业使用。

互斥锁

概念:

多个线程执行同一函数的时候,将函数中的某部分规定为:同一时刻,只允许一个线程执行这部分代码。
用来保证数据的安全性和稳定性。

思考:通过知道C++的内存模型我们知道同一个程序中代码只有一份,所有的线程都需要访问代码段来执行,那是不是执行到lock()的时候就会在这里触发某种判断机制?那lock和unlock以及PV操作的原理是不是基于此呢?

lock()和unlock()

用法:

mutex my_mutex;
my_mutex.lock();
//读写操作
my_mutex.unlock();

要注意,lock和unlock必须成对使用,非对称的调用lock和unlock会导致异常,修改后类的代码如下:

class MsgQueue
{
public:
	//插入消息队列
	void InMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			my_mutex.lock();
			msgQueue.push_back(i);
			my_mutex.unlock();
			cout << "新的请求入队,请求码:" << i << endl;
		}
	}
	void OutMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			if (!msgQueue.empty())
			{
				my_mutex.lock();
				cout << "请求出队,请求码:" << msgQueue.front() << endl;
				msgQueue.pop_front();
				my_mutex.unlock();
			}
			else
			{
				//消息队列为空
				cout << "无请求" << endl;
			}
		}
	}
private:
	list<int> msgQueue;//使用链表,头尾增删效率更高
	mutex my_mutex; //互斥量对象
};

所有线程执行到lock()的时候,会互斥的访问接下来的代码,在这段代码执行到unlock()之前,其他线程执行到lock()会进入阻塞直到unlock()后,进入就绪,等待操作系统调用。
需要注意的是,lock和unlock都会导致较大的计算机资源开销,尽量使需要互斥的代码简短快速。

lock_guard类

由于lock之后必须unlock,这就带来和指针一样的弊端,忘记unlock,并且在实际中如果有忘记unlock的情况很难排除这个问题。C++提供了像智能指针一样的解决方法,使用lock_guard这个类,它的构造函数提供了lock的功能,它的析构函数提供了unlock()的功能,这样在lock_guard的作用域内,它又能起到互斥锁的作用,又能避免忘记unlock的弊端。将原函数改造如下:

	void InMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			lock_guard<mutex> my_guard(my_mutex);
			msgQueue.push_back(i);
			cout << "新的请求入队,请求码:" << i << endl;
		}
	}

输出函数以此类推。但要注意,使用lock_guard后,这段区域内就不能使用lock 和unlock了。

死锁

死锁的概念和进程间死锁的问题操作系统中有了详细的讲解,网上也有大量的理论和文章,这里主要讲C++中多线程的情况,但本质上是一样的,资源分配和互斥的时机的不合理等导致。

假设存在两个互斥锁,
mutex1,mutex2,
两个线程,
A和B

A需要先mutex1,后mutex2
B需要先mutex2,后mutex1

  1. 线程A执行,它先锁mutex1成功,它现在去锁mutex2
  2. 处理机调度,上下文切换
  3. 线程B执行,它先锁mutex2 成功 ,它现在去锁mutex1
  4. 处理机调度,上下文切换
  5. 线程A无法得到mutex2中的东西,阻塞在mutex2.lock()
  6. 处理机调度,上下文切换
  7. 线程B无法得到mutex1中的东西,阻塞在mutex1.lock()

此时 死锁就产生了
除了互斥锁,系统资源(硬件、网卡、显卡、内存)也是导致死锁的重要原因,但在实际中少见,因为操作系统提供对设备管理的优化

死锁的解决方案:

死锁发生的时候,很难完美的解决问题。
预防和避免死锁的发生是解决死锁的主要方式

保证互斥锁的顺序正确
发生死锁的函数:

	void InMsgQueue()
	{
		for (int i = 0; i < 10000; i++)
		{
			my_mutex1.lock();
			my_mutex2.lock();
			msgQueue.push_back(i);
			my_mutex2.unlock();
			my_mutex1.lock();
			cout << "新的请求入队,请求码:" << i << endl;
		}
	}
void OutMsgQueue()
	{
		for (int i = 0; i < 100000; i++)
		{	
			//顺序不同,此处发生死锁
			my_mutex2.lock();
			my_mutex1.lock();		
			if (!msgQueue.empty())
			{
				
				cout << "请求出队,请求码:" << msgQueue.front() << endl;
				msgQueue.pop_front();		
				my_mutex1.unlock();
				my_mutex2.unlock();
			}
			else
			{
				//消息队列为空
				cout << "无请求" << endl;
				my_mutex1.unlock();
				my_mutex2.unlock();
			}
		}
	}

执行不到几步就会停止运行。上面的例子很简单,在这几行内出现互斥锁顺序出错的情况概率不大,但在面对庞杂的业务和合作开发的时候,很有可能疏忽。

有一些项目对执行的完整性要求极高,不可随意重启的条件下,发生死锁的时候,可以采用执行回退的方法,这个需要特殊的硬件支持,多见军工和科研项目。

std::lock()函数模板

作用:一次锁住两个或者两个以上的互斥量(一般不会超过两个)
它不存在因为锁的顺序问题导致的死锁的风险。
如果一个没锁住,就会陷入阻塞,但不会占有能锁的锁,直到所有互斥锁都能同时锁住,才继续执行,保证要么两个互斥量都锁住,要么都没锁住。
用法:

std::lock(my_mutex1,my_mutex2);

std::adopt_lock

adopt_lock是一个lock_guard的参数。
我们还可以将std::lock_guard和std::lock()一起使用。来避免使用std::lock时,unlock的疏忽。
std::adopt是一个结构体对象,起一个标记作用,用来表示这个互斥量已经被lock()了,不需要在构造函数中再lock()。(但必须要已经lock了才能使用)
使用方法:

using namespace std;
void InMsgQueue()
{
	for (int i = 0; i < 10000; i++)
	{
		lock(my_mutex1,my_mutex2);
		lock_guard<mutex> my_guard1(my_mutex1,adopt_lock);
		lock_guard<mutex> my_guard2(my_mutex2,adopt_lock);
		msgQueue.push_back(i);
		
		cout << "新的请求入队,请求码:" << i << endl;
	}
}

adopt_lock是适用于unique_lock的

unique_lock取代lock_guard

unique_lock是一个类模板,工作中一般使用lock_guard已经足够了,unique_lock比lock_guard要灵活很多,有更多的功能,但效率上要差一点,内存也要占多一点

由上面的学习,我们知道lock_guard可以用第二个参数来标记已经上锁的mutex。
unique_lock 也可以带一样的标记adopt_lock,作用相同。

std::try_to_lock

try_to_lock是一个unique_lock的参数。
正如其名,尝试去锁,锁定失败会立即返回,不会阻塞在此处。用try_to_lock的前提是不能先去lock。
用法:

unique_lock<std::mutex> my_unique(my_muteax,std::try_to_lock);

问题的来源是,程序中互斥访问的时候,没有进入临界区的线程就相当于闲了下来,如果互斥的时间很长,当有需要的时候,总要让它干点别的,不然会浪费CPU的资源,try_to_lock就提供了解决方案。

void InMsgQueue()
{
	for (int i = 0; i < 10000; i++)
	{
		unique_lock<mutex> my_unique(my_mutex, try_to_lock);
		if (my_unique.owns_lock())
		{
			msgQueue.push_back(i);
			cout << "新的请求入队,请求码:" << i << endl;
		}
		else
		{
			doSomethingelse();
		}
	}
}

defer_lock

defer_lock是一个unique_lock的参数
它的作用是初始化一个没有加锁的mutex,有时候你不希望他锁着,只是想把mutex和unique_lock绑在一起。

unique_lock的成员函数

unique_lock的成员函数有四个:
lock(),unlock(),try_lock(),release()
使用defer_lock后,unique_lock和mutex绑定在了一起,只需要调用成员函数就能实现和前面几种相同的功能,可能有人会说这不墨迹吗,都是一样的功能,还麻烦。确实是,但这种用法更能体现面向对象的思想,操作起来也更安全,灵活,会有很少的额外资源开销。这里只讲一下release()

release():返回它所管理的mutex对象指针,释放对它的绑定,使之不再和mutex有关系
注意:严格区分unlock和release的区别。释放没有unlock的mutex必须单独unlock。

release的返回值证明了unique_lock本质上一个指向mutex的指针,这也暗示unique_lock和mutex彼此之间是一一对应的。

unique_lock的成员函数使我们能更灵活的运用锁,不用再让锁的范围依赖于对象的作用域,能够提前解锁,这里提一个概念,就是锁的粒度。

粒度

前面提到过,使用锁会有较大的系统开销,并且锁住的代码越多,就意味着其他线程等待互斥的时间越长,程序的效率越低,那么这段被锁住的代码的规模,就称为锁的粒度。unique_lock让我们可以提前使用unlock来降低代码的粒度,对于粒度的取舍,是高级程序员能力的重要体现。

并不是粒度越粗效率就一定越低,粒度越细效率就越高,关于锁的粒度,这里有更详细的解释:https://www.ibm.com/support/knowledgecenter/zh/ssw_aix_71/performance/lock_granularity.html

unique_lock的所有权传递

std::unique_lock<std::mutex> my_unique1(my_mutex);
//else...
//...
std::unique_lock<std::mutex> my_unique2(std::move(my_unique1));

移动语义,本质上和智能指针(unique_ptr)的转移是一样的。和智能指针一样,可以用成员函数返回这个对象。

std::unique_lock<std::mutex> rtn_unique()
{
	std::unique_lock<std::mutex> temp(my_mutex);
	return temp;
}

你可能感兴趣的:(C++多线程学习笔记,多线程,c++)