C++并发与多线程(9) | 线程安全访问控制

转载自:C++ - 线程安全访问控制 - MyRedstone

一、加锁的原则

1.1 规则1:多线程/进程,并行访问共享资源时一定要加锁保护

 共享资源,包括全局变量,静态变量,共享内存,共享文件等。

#include 
#include
#include
#include
#include

using namespace std;

class A
{
public:
	//收集数据的函数
	void inMsgRecvQueue()
	{
		for (int i = 0; i < 100000; ++i)
		{
			cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
			std::lock_guard guard_lock(my_mutex);
			//my_mutex.lock(); //给共享数据msgRecvQueue上锁(防止多个线程同时访问共享数据msgRecvQueue)

			msgRecvQueue.push_back(i);

			//my_mutex.unlock();	//给共享数据msgRecvQueue解锁,其他线程才可以访问这个共享数据

		}
	}


	bool outMsgLULPro(int& command)
	{
		std::lock_guard sbguard(my_mutex);
		//my_mutex.lock(); //因为这里又要访问共享数据msgRecvQueue,所以要在这里赶紧加把锁
		if (!msgRecvQueue.empty()) //如果队列不为空的时
		{
			//取数据 & 去除这个数据
			int commond = msgRecvQueue.front();
			msgRecvQueue.pop_front();
			//my_mutex.unlock();
			return true;

		}

		//my_mutex.unlock(); //else的,如果队列为空
		return false;



	}


	//取出数据的线程
	void outMsgRescQueue()
	{

		int command = 0;

		for (int i = 0; i < 100000; ++i)
		{
			bool result = outMsgLULPro(command); //这个command是传引用的方式,所以也会跟着变


			if (result == true) //若完成了从list中取出数据并从原来的list中剔除这个数据的动作
			{
				cout << "outMsgRescQueue()执行,取出一个元素" << command << endl;

			}

			else
			{
				cout << "outMsgRescQueue()执行,但目前消息队列为空" << i << endl;

			}

		}

		cout << "end" << endl;


	}

private:
	std::listmsgRecvQueue; //接收命令的list

	std::mutex my_mutex; //私有互斥量

};




int main()
{
	A myobja;
	std::thread myOutMsgobj(&A::outMsgRescQueue, &myobja);

	std::thread myInMsgobj(&A::inMsgRecvQueue, &myobja);

	myOutMsgobj.join();
	myInMsgobj.join();

	cout << "main over" << endl;

	return 0;
}

比如在这个代码中(我们加锁完毕,是线程安全的了),

msgRecvQueue就是在类中的全局可见的,属于类内的共享内存,因此本身是线程不安全的,可以被多个线程同时访问。

但是:

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

这句代码是线程不安全的,其有两端代码组成:

cout << "inMsgRecvQueue()执行,插入一个元素" << i

以及cout <

这个<<输出运算是全局的,因此也是本身线程不安全的,可以被多个线程访问,因此我们常常发现用cout打印输出,控制台的输出顺序是混乱的。

当然这个解决办法要么对cout加锁(之前的博客有),要么用printf函数替代!




|建议封装像智能指针一样的对象来对锁进行管理,比如我们就封装了一个auto_lock,在构造时申请锁,析构中释放锁,保证不会忘记解锁。

1.2 规则2:锁的职责单一

 每个锁只锁一个唯一共享资源,这样才能保证锁应用的单一,也能确保加锁的范围尽量小。对于共享全局资源,应该根据实际需要,每类或者每个资源有一把锁。这样,这把锁只锁对当前这个资源进行访问的代码,通常这样的代码都会是比较简单的资源操作代码,不会是复杂的函数调用等。相反,如 果我们对几类或几个资源共用一把锁。这把锁的责任范围就大了,使用复杂,很难理清锁之间的关系(有 没有释放锁,或者锁之间的嵌套加锁等),容易导致死锁问题。

1.3 规则3:锁范围尽量小 只锁对应资源操作代码

使用锁时,尽量减少锁的使用范围。我们使用锁,为了方便会大范围的加锁如:直接锁几个函数调用。这种使用,一方面会导致多线程执行效率的低下,容易变成串行执行(将几个函数的调用变成串行了,不过话说回来,我们的真实需求是有时候就是有串行的需求的,比如推理和后处理,肯定这个推理跟后处理是串行的,不然后处理不报错了么?);另一方面,容易出现锁未释放,或者锁的代码中再加锁的场景,最后导致死锁。 所以,对锁操作的最好办法,就是只锁简单资源操作代码。对应资源访问完后,马上释放锁尽量在 函数内部靠近资源操作的地方加锁而不是靠近线程、函数外部加锁

1.4 规则4:避免嵌套加锁 如果必须加锁 务必保证不同地方的加锁顺序是一样的

加上一把锁后,在释放之前,不能再加锁。

典型的锁中加锁的场景:

代码中对几个容器的同时遍历,每个容器一把锁,就导致需要加多把锁。

这种场景的解决方法:先加一把锁,对一个容器遍历,选择出合乎要求的数据,并保存在临时变量中; 再加另一把锁,使用临时变量,再对其他容器遍历。

锁中加锁。必须保证加锁的顺序是一样的,比如先加的锁后解锁, Lock1 Lock2 Unlock2 Unlock1 ,则其他地方的加锁顺序,必须与这里的顺序一样,避免死锁,不允许出现: Lock2 Lock1 Unlock2 Unlock1 。

以下是一个示例代码,演示了如何创建一个会导致死锁的情况,其中两个线程分别尝试遍历两个容器,但以不一致的锁顺序来锁定互斥锁:

#include 
#include 
#include 
#include 

std::vector container1;
std::vector container2;
std::mutex mutex1;
std::mutex mutex2;

void thread1()
{
    std::lock_guard lock1(mutex1);
    for (const auto& item : container1) {
        // 处理 container1 中的数据
        std::cout << "Thread 1 processing container1: " << item << std::endl;
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(1));  // 模拟一些工作

    std::lock_guard lock2(mutex2);
    for (const auto& item : container2) {
        // 处理 container2 中的数据
        std::cout << "Thread 1 processing container2: " << item << std::endl;
    }
    std::cout << "Thread 1 completed." << std::endl;
}

void thread2()
{
    std::lock_guard lock2(mutex2);
    for (const auto& item : container2) {
        // 处理 container2 中的数据
        std::cout << "Thread 2 processing container1: " << item << std::endl;
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(1));  // 模拟一些工作

    std::lock_guard lock1(mutex1);
    for (const auto& item : container1) {
        // 处理 container1 中的数据
        std::cout << "Thread 2 processing container2: " << item << std::endl;
    }
    std::cout << "Thread 2 completed." << std::endl;
}

int main()
{
    container1.push_back(1);
    container1.push_back(2);
    container2.push_back(3);
    container2.push_back(4);

    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    return 0;
}

运行结果如下:

C++并发与多线程(9) | 线程安全访问控制_第1张图片

可以看到控制台并未结束,一直停留着!

好的,那么分析一下这个死锁是如何发生的:

首先我们的容器container1有两个元素:1和2。容器container2有两个元素3和4。

然后开启线程,进入线程函数,这个进入thread1和thread2线程函数的顺序是与主函数中的定义顺序无关的,这只与系统调度有关系。根据上面控制台截图来看,程序先进入线程函数thread2中:

那我们先看线程函数thread2中:

std::lock_guard lock2(mutex2);

这句话直接的作用范围就是下面的所有语句,明显是对下面的:

  std::cout << "Thread 2 processing container1: " << item << std::endl;中的cout<

以及container2两个共享资源进行加锁了

当然对于后面的container1和std::cout << "Thread 2 processing container2: " << item << std::endl;中的cout<

那么从thread2函数的上到下来看,迭代访问取值container2的元素,因此控制台先输出了:

"Thread 2 processing container2: 3"

这个时候注意了,线程函数thread1可没闲着,这个时候我们进入线程函数thread1看看:

std::lock_guard lock1(mutex1);
for (const auto& item : container1) {
    // 处理 container1 中的数据
    std::cout << "Thread 1 processing container1: " << item << std::endl;
}

明显是对整个函数的共享资源进行了加锁,这个时候由于线程函数thread2还有个睡眠1s,所以这个时候线程函数thread1拿到了mutex1的锁头进行了加锁,这个时候因此就打印出了:

"Thread 1 processing container1: 1"

由于两个线程函数是同时的,因此这个时候在线程函数thread2中,由于mutex1已经在线程函数thread1中被锁定了,所以在线程函数thread2中拿不到mutex1的锁头了,因此这个时候是被在

阻塞,程序无法执行下去:

C++并发与多线程(9) | 线程安全访问控制_第2张图片

同理:

这个时候在线程函数thread1中,由于mutex2已经在线程函数thread2中被锁定了,所以在线程函数thread1中拿不到mutex2的锁头了,因此这个时候是被在

阻塞,程序无法执行下去:

C++并发与多线程(9) | 线程安全访问控制_第3张图片

当然每个线程函数,thread1()会因为里面的循环进行打印出:

"Thread 1 processing container1: 1"
"Thread 1 processing container1: 2"

thread1()会因为里面的循环进行打印出:

"Thread 2 processing container2: 3"
"Thread 2 processing container2: 4"

这样就出现了两个线程函数,都有堵塞,相互等待,程序运行不下去!

那么怎么修改呢,摆脱这个死锁呢?下面给出代码:

#include 
#include 
#include 
#include 

std::vector container1;
std::vector container2;
std::mutex mutex1;
std::mutex mutex2;

void thread1()
{
    std::lock_guard lock1(mutex1);
    for (const auto& item : container1) {
        // 处理 container1 中的数据
        std::cout << "Thread 1 processing container1: " << item << std::endl;
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(1));  // 模拟一些工作

    std::lock_guard lock2(mutex2);
    for (const auto& item : container2) {
        // 处理 container2 中的数据
        std::cout << "Thread 1 processing container2: " << item << std::endl;
    }
    std::cout << "Thread 1 completed." << std::endl;
}

void thread2()
{
    std::lock_guard lock1(mutex1);
    for (const auto& item : container1) {
        // 处理 container1 中的数据
        std::cout << "Thread 2 processing container1: " << item << std::endl;
    }

    std::this_thread::sleep_for(std::chrono::milliseconds(1));  // 模拟一些工作

    std::lock_guard lock2(mutex2);
    for (const auto& item : container2) {
        // 处理 container2 中的数据
        std::cout << "Thread 2 processing container2: " << item << std::endl;
    }
    std::cout << "Thread 2 completed." << std::endl;
}

int main()
{
    container1.push_back(1);
    container1.push_back(2);
    container2.push_back(3);
    container2.push_back(4);

    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    return 0;
}

C++并发与多线程(9) | 线程安全访问控制_第4张图片

这个代码分析如下:

同时启动线程函数thread1和thread2,这时按照上面控制台的输出。恰好线程2先运行快一点,然后拿到了mutex1互斥锁的锁头,这个时候由于mutex1已经在线程函数thread2中被锁定了,所以在线程函数thread1中拿不到mutex1的锁头了,因此这个时候是被阻塞,程序无法执行下去:

C++并发与多线程(9) | 线程安全访问控制_第5张图片

因此我们在线程函数2就可以看到全部的输出(这个时候线程函数1一直被阻塞在第一句):

"Thread 2 processing container1: 1"
"Thread 2 processing container1: 2"

"Thread 2 processing container2: 3"
"Thread 2 processing container2: 4"

"Thread 2 completed."

然后全部线程函数2运行完毕了,这个时候跳出了mutex1和mutex2两个锁的作用范围了,因此这个时候这两把锁也就是被释放了,那么线程函数1的阻塞就被解除了,进而就完成了对mutex1和mutex2的加锁,因此线程函数1就得到了全部输出:

"Thread 1 processing container1: 1"
"Thread 1 processing container1: 2"

"Thread 1 processing container2: 3"
"Thread 1 processing container2: 4"

"Thread 1 completed."

关于死锁的部分,我们下一篇博文再专门介绍一下!!!!

二、加锁的建议

2.1 进程间通讯 使用自己保证互斥的系统

       由于文件在不同进程间访问,无法保证互斥。当然,可以在进程间加进程锁,但只受限于我们能加锁的进程,对于第三方进程等无法保证。这样,当多个进程同时对文件进行写操作时,将会导致文件数据破坏,或文件写失败等问题。

       当数据库系统本身的访问接口带有互斥机制时,当多个进程同时访问时,可以保证数据库数据的完整。

        共享内存,只限制于使用共享内存的几个进程,需要我们对这些访问共享内存的进程加锁。但由于共享内存,第三方进程等无法访问,这也能比较好的保护数据,避免文件系统存在的问题。

       Socket 消息机制,由操作系统 Socket 通讯机制保证互斥,在多个进程间,通过消息来保证数据的互斥。 进程的消息都是操作系统转发而来的独立数据,属于进程私有数据,不存在进程间并行访问的问题。

2.2 可重入函数尽量只使用局部变量和函数参数 少用全局变量 静态变量

支持多线程并行访问的函数称之为可重入函数。设计可重入函数时,尽量使用局部变量和函数参数来传递数据,在多线程并行访问时,互相之间不会受影响。相反,如果使用全局变量、静态变量,就需要同步。一些库函数也是非线程安全,调用时可能会出现多线程并发访问问题。

可重入函数和不可重入函数,需要阅读下面的博文:

浅谈可重入函数与不可重入函数-CSDN博客

2.3 锁中避免调用函数 如果必须调用函数 务必保证不会造成死锁

这条规则是对加锁范围尽量小(只锁对应资源操作代码)规则的补充。不能把调用函数也加到加锁范围中。因为被调用函数的内部到底做了什么事情,是如何做的,调用者可能不是很清楚。尤其是 当被调用函数内部又加锁的情况,就容易导致两个锁互锁,导致死锁。而且这种死锁 情况还是比较难分析。因为我们调用函数,很多时候只关注函数实现的功能 ,而忽略函数内部的具体实现。其次,锁中调用函数,也会把对资源操作的代码扩大化,不利于并行效率。更主要的是,这种操作, 由于加锁的范围变大,引起死锁的可能就增大。

2.4 锁中避免使用跳转语句

跳转语句包含 return、break、continue、goto 等。如果锁中有宏调用的代码,要特别注意,分析宏中是否存在隐含的跳转语句。 在函数返回时忘记把锁释放,特别是存在很多分支都可能返回的时候,可能一些分支会忘记释放锁。

你可能感兴趣的:(C++,C++)