在线程间共享数据:互斥与死锁

一、防止恶性条件竞争

1.数据间的共享问题

竞争状态:多线程同时读写共享数据

临界区:读写共享数据的代码片段,对于共享数据同时只能有一个线程访问

防止恶性条件竞争:

  • 上锁                                                                                                                                 采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见
  • 无锁编程

    修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。

  • 将修改数据结构当作事务(transaction)来处理

2.mutex互斥锁

1)mutex互斥锁

mutex mtxt1;
int shared_data = 100;

void use_lock() {
	while (true) {
		mtxt1.lock();
		shared_data++;
		cout << this_thread::get_id() << "  ,  " << shared_data<<endl;
		this_thread::sleep_for(chrono::seconds(1));
		mtxt1.unlock();
		this_thread::sleep_for(chrono::milliseconds(10));  //防止恶性循环抢占资源,睡眠10ms给cpu留出充足时间释放时间片
	}
}

void Text1() {
	thread t1(use_lock);
	t1.detach();
	thread t2([]() {	//隐式转换
		while (true) {
			mtxt1.lock();
			shared_data--;
			cout << this_thread::get_id() << "  ,  " << shared_data << endl;
			this_thread::sleep_for(chrono::seconds(1));
			mtxt1.unlock();
			this_thread::sleep_for(chrono::milliseconds(10));
		}
		});
	t2.detach();
}

int main() {
	Text1();
	this_thread::sleep_for(chrono::seconds(10));
	return 0;
}
//结果可以看到两个线程对于共享数据的访问是独占的,谁先把shared_data锁上,谁就访问,单位时间片只有一个线程访问并输出日志。

  1)坑(死锁)示例

mutex mux;
void Fun(int i) {

	for (;;) {
		mux.lock();
		cout << this_thread::get_id() << " 进入 ";
		cout << i << endl;
		this_thread::sleep_for(chrono::seconds(1)); //休眠目的是给主线程让出一点时间,继续创建其他线程,
		//但此时这样会有一个坑,有可能主线程创建的子线程创建出来后抢占不到资源
		mux.unlock();
	}
}

int main() {
	for (int i = 1; i <= 3; i++) {
		thread th(Fun,i);
		th.detach();
		this_thread::sleep_for(chrono::seconds(1)); //防止主线程退出过快
	}
	return 0;
}

//可能的结果是某个线程一直抢占资源,不退出
//原因:拿第一个线程举例,1在临界区后,2、3可能现在都在mux.lock()处阻塞,但当1执行完
//mux.unlock()时,(可能操作系统立即释放临界区资源与无限循环的时间先后)因为是for循环,1自己又申请了lock(),其他线程就还是在阻塞中
在unlock后面睡眠1ms,给操作系统多留出一点时间释放临界区资源,可以尽量避免死锁

        ②避免长时间死锁

        1.try_lock()

        try_lock 是一种非阻塞的锁获取方式。如果锁当前不可用(即已被其他线程持有),它将立即返回,而不会等待

        2.timed_mutex.try_lock_for() 超时锁

        也是一种非阻塞的锁获取方式,如果当前锁不可用,等待一个指定的时间段后,再返回,如果在这个时间段内锁可用,则获取锁;若超时仍未获取到锁,返回 false

mutex mux;
void Fun(int i) {

	for (;;) {
		mux.lock();
		cout << this_thread::get_id() << " 进入 ";
		cout << i << endl;
		this_thread::sleep_for(chrono::seconds(1)); 
		mux.unlock();  
		this_thread::sleep_for(1ms);  //留出1ms,给操作系统释放临界资源时间,
	}
}

timed_mutex tmu;
void Text_tmu(int i) {
	for (;;) {
		if (!tmu.try_lock_for(chrono::milliseconds(500))) {  //
			cout << i << "超时" << endl;
			continue;
		}
		cout << i << "抢占到资源" << endl;
		this_thread::sleep_for(chrono::seconds(1));  //睡眠一段时间,缓慢向屏幕输出
		tmu.unlock();
		this_thread::sleep_for(1ms);
	}
}

int main() {

	for (int i = 1; i <= 3; i++) {
		thread th(Text_tmu, i);
		th.detach();
	}
	
	this_thread::sleep_for(chrono::seconds(1));

	getchar();

	for (int i = 1; i <= 3; i++) {
		cout << "。。。。。。。。。。。。。。。。。" << endl;
		thread th(Fun,i);
		th.detach();
		
	}
	this_thread::sleep_for(chrono::seconds(5)); 
	return 0;
}

        3.递归锁(可重入)recursive_mutex和递归超时锁recursive_timed_mutex用于业务组合

 递归锁 (recursive_mutex)

定义与特性

  • recursive_mutex 是一种特殊类型的互斥锁,允许同一线程多次获取该锁而不导致死锁。
  • 每当一个线程成功地获得锁时,内部计数器会增加,当它释放锁时,计数器会减少。只有当计数器降到零时,锁才被完全释放。

递归超时锁则是在递归锁基础上加上了时间段

3.lock_guard的使用

锁的管控(守卫),lock_guard在作用域结束时自动调用其析构函数解锁,这么做的一个好处是简化了一些特殊情况从函数中返回的写法,比如异常或者条件不满足时,函数内部直接return,锁也会自动解开。

void Text1() {
	thread t1(use_lock);
	t1.detach();
	thread t2([]() {	
		while (true) {
			{
				lock_guard<mutex>lock(mtxt1);  //创建时就自动加锁了
				shared_data--;
				cout << this_thread::get_id() << "  ,  " << shared_data << endl;
				this_thread::sleep_for(chrono::seconds(1));
			}  //出了这个局部作用域后就自动解锁

			this_thread::sleep_for(chrono::milliseconds(10));
		}
		});
	t2.detach();
}

4.如何保证数据安全

1)常见错误1

        意外地向外传递引用,指向受保护共享数据

class some_data
{
    int a;
    std::string b;
public:
    void do_something();
};

class data_wrapper
{
private:
    some_data data;
    std::mutex m;
public:
    template<typename Function>
    void process_data(Function func)  
    {
        std::lock_guard<std::mutex> l(m);
        func(data);    ⇽---  ①向使用者提供的函数传递受保护共享数据
    }
};

some_data* unprotected;  //指向了受保护的变量

void malicious_function(some_data& protected_data)
{
    unprotected=&protected_data;
}

data_wrapper x;

void foo()
{
    x.process_data(malicious_function);    ⇽---  ②传入恶意函数
    unprotected->do_something();    ⇽---  ③以无保护方式访问本应受保护的共享数据
}

2)常见错误2

当我们对一份共享数据进行操作时,会为其上锁,在锁内操作是安全的,但是返回值抛给外边使用,就存在不安全性,因为此时已经出了作用域。以stack为例,虽然empty和 pop 都在各自的作用域内加锁,但在两个线程之间相互交错执行时,栈的状态可能会发生瞬间变化,导致判空不准确,尤其是栈中只有一个元素这种情况,会引发崩溃

template<typename T>
class threadsafe_stack1 {
public:
	threadsafe_stack1(){}
	threadsafe_stack1(const threadsafe_stack1& other) {
		std::lock_guard<mutex> lock(other.m); //对要copy过来的stack上锁
		data = other.data;
	}
	threadsafe_stack1& operator=(const threadsafe_stack1& other) = delete;//不允许拷贝赋值,因为拷贝赋值会将锁copy过来

	void push(T new_value) {
		std::lock_guard<mutex>lock(m);
		data.push(std::move(new_value));  //move减少一次拷贝构造
	}

	//problem
	T pop() {
		std::lock_guard<mutex>lock(m);
		auto element = data.top();  //取出栈顶元素
		data.pop();
		return element;
	}

	//危险
	bool empty() const {
		std::lock_guard<mutex> lock(m);
		return data.empty();
	}

private:
	std::stack<T> data;
	mutable std::mutex m; //可变参数,在对于栈的判空读操作函数中,我们可以用const函数,但在多线程中需要对这部分数据读进行加锁,加锁则是改变const成员函数
	//所以需要对于锁这个实例是可变的,这样才能在常量成员函数中加锁
};

void text_threadsafe_stack1() {
	threadsafe_stack1<int> safe_stack;
	safe_stack.push(1);
	std::thread t1([&safe_stack]() {
		if (!safe_stack.empty()) {
			std::cout << "t1线程判断stack不为空" << std::endl;
			this_thread::sleep_for(chrono::seconds(1));  //模拟处理业务逻辑
			safe_stack.pop();
			std::cout << "t1出栈" << std::endl;
		}
		
		});
	std::thread t2([&safe_stack]() {
		if (!safe_stack.empty()) {
			std::cout << "t2线程判断stack不为空" << std::endl;
			this_thread::sleep_for(chrono::seconds(1));
			safe_stack.pop();
			std::cout << "t2出栈" << std::endl;
		}
		
		});

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

int main() {
	//Text1();
	text_threadsafe_stack1();
	return 0;
}

两个线程可能同时判断不为空,同时pop最后一个元素,此时就会引发崩溃。归根结底就是判空时返回值使用时机造成的问题,因为返回值在没有锁的情况下,可能会延迟使用,所以会造成一些问题。

解决这个问题我们可以抛异常,比如定义一个空栈的异常

        自定义异常

struct empty_stack :std::exception {
	const char* what() const noexcept override {
		return "empty stack";
	}
};

        修改后的pop

T pop() {
	std::lock_guard<mutex>lock(m);
	if (data.empty()) {
		throw empty_stack();
	}
	auto element = data.top();
	data.pop();
	return element;
}

        text程序

void text_threadsafe_stack() {
	threadsafe_stack<int> safe_stack;
	safe_stack.push(1);
	std::thread t1([&safe_stack]() {
		try {
			if (!safe_stack.empty()) {
				std::cout << "t1线程判断stack不为空" << std::endl;
				this_thread::sleep_for(chrono::seconds(1));
				safe_stack.pop();
				std::cout << "t1出栈" << std::endl;
			}
		}
		catch (const std::exception& e) {
			std::cout <<"t1抛出异常 "<< e.what();
			std::cout << "\n";
		}

		});
	std::thread t2([&safe_stack]() {
		try {
			if (!safe_stack.empty()) {
				std::cout << "t2线程判断stack不为空" << std::endl;
				this_thread::sleep_for(chrono::seconds(1));
				safe_stack.pop();
				std::cout << "t2出栈" << std::endl;
			}
		}
		catch (const std::exception& e) {
			std::cout <<"t2抛出异常: " << e.what();
			std::cout << "\n";
		}

		});

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

测试运行结果:

这里只改了一个问题隐患,现在这个pop函数仍存在问题,如果T是一个很大的数据类型,在返回时会做一个拷贝,因为数据过大,可能内存不够用溢出,造成拷贝时无效,但数据已经被pop掉了,这就会造成想要返回的数据丢失,可能会造成程序崩溃。

  • 一个函数返回一个对象时,可能会以拷贝构造传递这个对象:                                                返回一个对象会导致调用拷贝构造函数来创建返回值的新副本。例如,你的 pop() 函数返回 T 类型的对象,在返回过程中,可能会导致拷贝构造的调用。

  • T pop() {

    std::lock_guard<std::mutex> lock(m);

    if (data.empty())

    { throw empty_stack(); }

    T element = data.top();     // 这里可能会触发拷贝构造

    data.pop();

    return element;       // 这一步也可能会触发拷贝构造

    }

如何避免拷贝
为了避免这种情况,可以考虑以下几种方法:

1.使用移动语义:采用 std::move 来显式地移动对象,而不是拷贝它,
这是 C++11 引入的一种机制。这样就能有效地避免拷贝。

T pop() {
    std::lock_guard<std::mutex> lock(m);
    if (data.empty()) {
        throw empty_stack();
    }
    
    T element = std::move(data.top()); // 使用移动,避免拷贝
    data.pop();
    return element; // 返回移动对象
}

2.返回 std::shared_ptr<T> 或 std::unique_ptr<T>:这样可以避免直接拷贝对象,
同时确保对象的生命周期管理更安全。

std::shared_ptr<T> pop() {
    std::lock_guard<std::mutex> lock(m);
    if (data.empty()) {
        throw empty_stack();
    }
    
    auto element = std::make_shared<T>(std::move(data.top())); // 返回指向对象的智能指针
    data.pop();
    return element;
}

比如T是一个vector<int>类型,那么在pop函数内部element就是vector<int>类型,开始element存储了一些int值,程序没问题,函数执行pop操作, 假设此时程序内存暴增,导致当程序使用的内存足够大时,可用的有效空间不够, 函数返回element时,当我们尝试为它的副本分配内存时,系统可能没有足够的可用内存,这会导致 std::bad_alloc 异常。就会就会存在vector做拷贝赋值时造成失败。即使我们捕获异常,释放部分空间但也会导致栈元素已经出栈,数据丢失了。这其实是内存管理不当造成的,但是C++ 并发编程一书中给出了优化方案。

第一种方案 引用

void pop(T& value)
    {
        std::lock_guard<std::mutex> lock(m);
        if (data.empty()) throw empty_stack();
        value = data.top();
        data.pop();
    }

第二种方案 使用智能指针

//智能指针
std::shared_ptr<T> pop() {
	std::lock_guard<mutex>lock(m);
	if (data.empty()) {
		throw empty_stack();
	}
	std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
	data.pop();
	return res;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值