Studying-多线程学习Part2 - 互斥量死锁、lock_guard 与 unique_lock、call_once与其使用场景

来源:多线程学习

互斥量死锁

假设有两个线程 T1 和 T2,它们需要对两个互斥量 mtx1 和 mtx2 进行访问,而且需要按照以下顺序获取互斥量的所有权:

  • T1 先获取 mtx1 的所有权,再获取 mtx2 的所有权。
  • T2 先获取 mtx2 的所有权,再获取 mtx1 的所有权。

如果两个线程同时执行,就会出现死锁问题。因为 T1 获取了 mtx1 的所有权,但是无法获取 mtx2 的所有权,而 T2 获取了 mtx2 的所有权,但是无法获取 mtx1 的所有权,两个线程互相等待对方释放互斥量,导致死锁。 

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int share_data = 0;
mutex mtx1;
mutex mtx2;
void fun1() {
	for (int i = 0; i < 100000; ++i) {
		mtx1.lock();
		mtx2.lock();
		mtx1.unlock();
		mtx2.unlock();
	}
}

void fun2() {
	for (int i = 0; i < 100000; ++i) {
		mtx2.lock();
		mtx1.lock();
		mtx2.unlock();
		mtx1.unlock();
	}
}

int main() {
	thread t1(fun1);
	thread t2(fun2);
	t1.join();
	t2.join();
	cout << share_data << endl;
	return 0;
}

解决该问题的方法,可以让两个线程按照相同的顺序获取互斥量的所有权。例如,都先获取 mtx1 的所有权,再获取 mtx2 的所有权,或者都先获取 mtx2 的所有权,再获取 mtx1 的所有权。这样就可以避免死锁问题。

原因在于每个程序在运行的时候,都要先判断mtx1是否被占用,如果被占用就不能继续往下了,也就取不到mtx2了,而如果没有被占用则就可以继续往下。

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int share_data = 0;
mutex mtx1;
mutex mtx2;
void fun1() {
	for (int i = 0; i < 100000; ++i) {
		mtx1.lock();
		mtx2.lock();
		mtx1.unlock();
		mtx2.unlock();
	}
}

void fun2() {
	for (int i = 0; i < 100000; ++i) {
		mtx1.lock();
		mtx2.lock();
		mtx2.unlock();
		mtx1.unlock();
	}
}

int main() {
	thread t1(fun1);
	thread t2(fun2);
	t1.join();
	t2.join();
	cout << share_data << endl;
	return 0;
}

lock_guard 与 unique_lock

lock_guard

lock_guard是 C++ 标准库中的一种互斥量封装类,用于保护共享数据,防止多个线程同时访问同一资源而导致的数据竞争问题。 

lock_guard的特点是:

  • 当构造函数被调用时,该互斥量会被自动锁定。

  • 当析构函数被调用时,该互斥量会被自动解锁。

  • lock_guard对象不能复制或移动,因此它只能在局部作用域中使用。

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int share_data = 0;
mutex mtx;
void fun() {
	for (int i = 0; i < 1000000; ++i) {
		//创建即加锁
		lock_guard<mutex> lg(mtx);
		share_data += 1;
	}
}

int main() {
	thread t1(fun);
	thread t2(fun);
	t1.join();
	t2.join();
	cout << share_data << endl;
	return 0;
}

lock_guard还有第二种构造函数,除了传递mtx以外,还可以传递一个参数adopt_lock,表示之前以及上过锁了。

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int share_data = 0;
mutex mtx;
void fun() {
	for (int i = 0; i < 1000000; ++i) {
		//创建即加锁
		mtx.lock();
		lock_guard<mutex> lg(mtx, adopt_lock);
		share_data += 1;
	}
}

int main() {
	thread t1(fun);
	thread t2(fun);
	t1.join();
	t2.join();
	cout << share_data << endl;
	return 0;
}

unique_lock 

unique_lock 是 C++ 标准库中提供的一个互斥量封装类,用于在多线程程序中对互斥量进行加锁和解锁操作。它的主要特点是可以对互斥量进行更加灵活的管理,包括延迟加锁、条件变量、超时等。 

unique_lock提供了以下几个成员函数:

  • lock():尝试对互斥量进行加锁操作(这是个手动加锁的操作),如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁。
  • try_lock():尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则函数立即返回false,否则返回true,和lock()不同的是,使用try_lock()不会发生堵塞。
  • try_lock_for(const chrono::duration(Rep, Period>& rel_time) :与上一个函数相比,增加了一个时间参数,也是尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被另一个线程解锁,当前线程成功加锁,或者超过了给定的等待时间。
  • try_lock_until(const chrono::time_point<Clock, Duration>& abs_time):和上一个函数效果类似,不同的在于,增加的参数是一个时间点,效果是对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间点。
  • unlock():对互斥量进行解锁操作(手动解锁)
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int share_data = 0;
//由于后续要使用到时间操作,因此这里我们需要使用到时间锁
timed_mutex mtx;
void fun() {
	for (int i = 0; i < 10; ++i) {
		//增加defer_lock参数,表示创建锁,但是不进行枷锁操作
		unique_lock<timed_mutex> lg(mtx, defer_lock); 
		//try_lock_for返回的是一个bool值,成功加锁返回true,否则返回false
		if (lg.try_lock_for(chrono::seconds(1))) {
			share_data += 1;
			//表示当前线程休眠2s
			this_thread::sleep_for(chrono::seconds(2));
		}
	}
}

int main() {
	thread t1(fun);
	thread t2(fun);
	t1.join();
	t2.join();
	cout << share_data << endl;
	return 0;
}

unique_lock提供了以下几个构造函数:

  • unique_lock() noexcept = default:默认构造函数,创建一个未关联任何互斥量的unique_lock对象。
  • explicit unique_lock(mutex_type& m):构造函数,使用给定的互斥量m 进行初始化,并默认对该互斥量进行枷锁操作。(只支持显示构造)
  • unique_lock(mutex_type& m, defer_lock_t) noexcept:构造函数,使用给定的互斥量m 进行初始化,但是不对该互斥量进行加锁操作。
  • unique_lock(mutex_type& m, try_lock_t) noexcept:构造函数,使用给定的互斥量m 进行初始化,并尝试对该互斥量进行加锁,如果加锁失败,则创建的unqiue_lock 对象不与任何互斥量关联。
  • unique_lock(mutex_type& m, adopt_lock_t) noexcept:构造函数,使用给定的互斥量m 进行初始化,并假设互斥量已经被当前线程成功加锁。

总结:相比于lock_guard,unqiue_lock使用上非常灵活方便。


call_once与其使用场景 

该使用场景主要就是单例设计模式。单例设计模式是一种常见的设计模式,用于确保某个类只能创建一个实例。由于单例实例是全局唯一的,因此在多线程环境中使用单例模式时,需要考虑线程安全的问题。

代码实现:

#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

class Log {
public:
	Log() {};
	//单例模式,类只能有一个对象,因此我们需要禁用其拷贝构造和等于号
	Log(const Log& log) = delete;
	Log& operator=(const Log& log) = delete;

	//全局只需要一个,我们可以采用静态构造的方法
	//static Log& GetInstance() {
	//	static Log log; //饿汉单例模式
	//	return log;
	//}

	static Log& GetInstance() {
		static Log* log = nullptr; //懒汉模式

		if (!log) log = new Log;
		return *log;
	}

	void PrintLog(string msg) {
		cout << __TIME__ << " " << msg << endl;
	}
};

int main() {
	Log::GetInstance().PrintLog("这里有问题");
	return 0;
}

上述中,我们创建了一个日志类,并给了懒汉模式和饿汉模式两种静态构造方法。 由于静态局部变量只会被初始化一次,因此该实现可以确保单例实例只会被创建一次。

但是,该实现并不是线程安全的。如果多个线程同时调用 getInstance() 函数,可能会导致多个对象被创建,从而违反了单例模式的要求。

为例解决该问题,我们可以使用call_once来实现一次性初始化,从而确保单例实例只会被创建一次。

call_once函数的原型为:void call_once(std::once_flag& flag, Callable&& func, Args&&... args);

其中`flag` 是一个 `std::once_flag` 类型的对象,用于标记函数是否已经被调用;`func` 是需要被调用的函数或可调用对象;`args` 是函数或可调用对象的参数。

`std::call_once` 的作用是,确保在多个线程中同时调用 `call_once` 时,只有一个线程能够成功执行 `func` 函数,而其他线程则会等待该函数执行完成。

class Singleton {
public:
    static Singleton& getInstance() {
            std::call_once(m_onceFlag, &Singleton::init);
            return *m_instance;
    }    
    void setData(int data) {
        m_data = data;
    }    
    int getData() const {        
    return m_data;
    }
private:
    Singleton() {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;    
    static void init() {
        m_instance.reset(new Singleton);
    }    
    static std::unique_ptr<Singleton> m_instance;    
    static std::once_flag m_onceFlag;    
    int m_data = 0;
};
std::unique_ptr<Singleton> Singleton::m_instance;
std::once_flag Singleton::m_onceFlag;

在这个实现中,我们使用了一个静态成员变量 m_instance 来存储单例实例,使用了一个静态成员变量 m_onceFlag 来标记初始化是否已经完成。在 getInstance() 函数中,我们使用 std::call_once 来调用 init() 函数,确保单例实例只会被创建一次。在 init() 函数中,我们使用了 std::unique_ptr 来创建单例实例。

使用 std::call_once 可以确保单例实例只会被创建一次,从而避免了多个对象被创建的问题。此外,使用 std::unique_ptr 可以确保单例实例被正确地释放,避免了内存泄漏的问题。

上一篇:【笔记】连续、可导、可微的概念解析-5. 偏导数的连续性


下一篇:Windows安全加固详解