C++11中的锁

数据共享

race condition

A race condition is an undesirable situation that occurs when a device or system attempts to perform two or more operations at the same time , but because of the nature of the device or system, the operations must be done in the proper sequence to be done correctly.

并发编程中,两个线程对一个对象执行各自的操作,它们会争相让线程执行各自的操作,结果会取决于实际执行的次序,这种情况称为条件竞争。

条件竞争的产生必须满足以下条件:

  1. 并发程序;
  2. 存在线程间共享的对象;
  3. 存在某个线程尝试改变对象;

引用百度百科的例子:

假设两个进程P1和P2共享了变量a。在某一执行时刻,P1更新a为1,在另一时刻,P2更新a为2。因此两个任务竞争地写变量a。在这个例子中,竞争的“失败者”(最后更新的进程)决定了变量a的最终值。

避免产生恶性的条件竞争,通常有两种方法:

  1. 采取保护措施包装数据结构,确保中间状态只对执行改动的线程可见,也就是常见的锁
  2. 使用一连串不可拆分的改动完成数据表更,也就是无锁编程;

互斥锁

互斥锁(mutual exclusion,mutex)一旦有线程锁住了某个互斥锁,其他线程再尝试加锁时,会阻塞这个线程。等待持有互斥锁的线程释放锁之后,其他线程重新去争抢这个锁。在C++中,使用std::mutex创建互斥锁,使用成员函数lock去加锁,unlock解锁,代码如下所示:

int t = 0;
std::mutex mtx;

void func_read() {
	while (1) {
		mtx.lock();
		std::cout << t << std::endl;
		mtx.unlock();
	}
}

void func_write() {
	while (1) {
		mtx.lock();
		t = 1;
		t = 0;
		mtx.unlock();
	}
}

执行这段代码,可以保证打印的都是0。但是在实际的编程中,并不推荐直接使用lock和unlock,假如线程在临界区抛出了异常,导致没有执行unlock,那么其他线程也一直阻塞,C++模板库提供了基于RAII手法的std::lock_guard类,在构造lock_guard时加锁,析构时解锁,一个简单的示例如下:

void func_read() {
	while (1) {
		std::lock_guard<std::mutex> lk(mtx);
		std::cout << t << std::endl;
	}
}

void func_write() {
	while (1) {
		std::lock_guard<std::mutex> lk(mtx);
		t = 1;
		t = 0;
	}
}

容器的线程安全性

考虑下面一串代码:

stack<int> s;
if(!s.empty()){
	int const value = s.top();
	s.pop();
	do_something(value);
}

对一个空栈调用top接口会报错:back() called on a empty queue;于是在前面加了一个判断s不为空,这段代码在单线程的情况下是安全的。但是在多线程下,假如在if语句和top调用之间发生了线程调度,另一个线程调用了pop使得stack为空,此时就会产生问题。

另一种可能性是,假如有两个线程运行上述代码,可能会出现如下的情况:

if(!s.empty()){//thread 1
if(!s.empty()){//thread 2
	int const value = s.top();//thread 1
	int const value = s.top();//thread 2
	s.pop();//thread 1
	s.pop();//thread 2
}

这样的执行流会导致栈顶元素被读取两次,而第二个元素没有读取就被弹出了。

一种方案是将top和pop设计成如下接口:

int value = s.pop();

但是,存在一个问题,如果是形如stack<vector < int >>这样的数据结构,在复制vector时,拷贝构造函数抛出异常(例如内存不足),而此时栈上的元素已经被移除,可能导致数据丢失。

C++ Conccurency in action提供的方法是返回指针指向弹出元素:既然在返回vector时,存在复制失败的问题,那么可以返回一个指针,指向对象的内存,不管多么复杂数据结构的指针都只是一个值而已。但是不管怎么样,从一个未知的接口去返回指针都不是一个很好的选择,一旦涉及指针,我们就要考虑内存的管理。因此作者的建议是,返回std::shared_ptr类型的指针;

struct empty_stack :std::exception {
	const char* what() const throw();
};

template<typename T>
class threadsafe_stack {
private:
	std::stack<T> data;
	mutable std::mutex m;
public:
	threadsafe_stack(){}
	threadsafe_stack(const threadsafe_stack& other) {
		std::lock_guard<std::mutex> lock(other.m);
		data == other.data;
	}
	threadsafe_stack& operator=(const threadsafe_stack&) = delete;
	void push(T new_value) {
		std::lock_guard<std::mutex> lock(m);
		data.push(std::move(new_value));
	}
	std::shared_ptr<T> pop() {
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();
		//返回一个shared_ptr指针
		std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
		data.pop();
		return res;
	}
	void pop(T& value) {
		std::lock_guard<std::mutex> lock(m);
		if (data.empty()) throw empty_stack();
		value = data.top();
		data.pop();
	}
	bool empty() const
	{
		std::lock_guard<std::mutex> lock(m);
		return data.empty();
	}
};

死锁

死锁是面试中常见的问题之一,一个经典的情形是;

A.面试官对面试者说:你告诉我什么是死锁,我就让你面试通过;

B.面试者对面试官说:你让我面试通过,我就告诉你什么是死锁;

可想而知,面试者永远不会通过面试,而面试官也不知道死锁是什么了。

死锁的四个条件是:

1.互斥 2.请求和保持 3.不可剥夺 4.环路等待

最为常用的预防死锁的方法是按一个固定的顺序加锁,在对锁A加锁成功之前,永远不对锁B尝试加锁,但是,考虑下面的情况:

class some_big_object;
void swap(some_big_object& lhs,some big object& rhs);
class X{
private:
	some_big_object some_detail;
	std::mutex m;
public:
	X(some_big_object const& sd):some_detail(sd){}//构造函数
	friend void swap(X& lhs,X& rhs){
		//非递归锁不可以重复加锁,先判断是否是自身
		if(&lhs==&rhs)
			return;
		std::lock_guard<std::mutex> lock_a(lhs.m);
		std::lock_guard<std::mutex> lock_b(rhs.m);
		swap(lhs.some_detail,rhs.some_detail);
	}
}

上面的代码中,首先对lhs.m加锁,保护lhs中的数据,随后,对rhs.m加锁,保护rhs中的数据。这样看起来是符合上面的准则,但是,假如对于两个对象A,B,线程1调用swap(A,B),线程2调用swap(B,A),那么还是会形成环路,这种错误在实际编程的过程是很容易出现的。

std::lock

解决的办法是引入std::lock:

		std::lock(lhs.m,rhs.m);
		std::lock_guard<std::mutex> lock_a(lhs.m, std::adopt_lock);
		std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);

std::lock会对两个互斥量进行加锁,其内置避免死锁的算法。随后,将锁的所有权移交给lock_guard。adopt_lock参数表明这个互斥量已经加锁。

std::scoped_lock

C++17的scoped_lock相当于上述的std::lock和std::lock_guard的组合:

std::scoped_lock guard(lhs.m,rhs.m);

std::unique_lock

std::unique_lock相对于lock_guard更为灵活,它可以传入defer_lock参数,使得锁在完成构造时处于未加锁状态,等后面有需要时,才调用lock进行加锁。

std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock);
std::unique_lock<std::mutex> lock_b(rhs.m.std::defer_lock);
std::lock(lock_a,lock_b);

std::unique_lock具有以下几个成员函数:

  • lock:尝试对互斥量加锁
  • unlock:如果互斥量处于加锁状态,解锁;
  • owns_lock:查看目前是否拥有锁;
  • try_lock:尝试加锁,但不阻塞;

避免死锁的一些准则

  1. 不使用嵌套锁:这将导致一个线程只能持有一个锁,实际情况下可以使用std::lock来获取全部锁;
  2. 持有锁之后,不要调用用户提供的程序接口:因为你不知道用户会在里面干什么;
  3. 以一定的顺序加锁;
  4. 按层级加锁。

线程安全的单例模式

所谓懒汉模式,在需要获取对象时才调用构造函数:

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo(){
	std::unique_lock<std::mutex> lk(resource_mutex);
	if(!resource_ptr){
		resource_ptr.reset(new some_resource);
	}
	lk.unlock();
	resource_ptr->do_something();
}

通过unique_lock保证创建的过程是线程安全的,否则如果有两个线程执行了if(!resouce_ptr),那么就可能创建两次对象。

这段代码的问题在于每次都要调用都要去尝试加锁,然后运行if判断,如果调用这个函数的线程比较多,都被迫顺序运行,程序性能就会收到影响;

使用std::once_flag和std::call_once

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource(){
	resource_ptr.reset(new some_resource);
}
void foo(){
	std::call_once(resource_flag,init_resource);
	resource_ptr->do_something();
}

使用静态成员变量

C++11的静态变量初始化是线程安全的。

class my_class;
my_class& get_my_class_instance(){
	static my_class instance;
	return instance;
}

读写锁

假设有一个DNS缓存表,里面存放数以万计的DNS缓存,查询DNS缓存是十分常见的工作,但加入DNS缓存条目则是十分少见的操作,对每个读操作加互斥锁似乎是矛盾的事情。假设另一个读线程尝试获取锁,那么这个锁是不必要的;如果是写线程尝试获取锁,这个锁又成了必要的,但是,写的次数远远少于读的次数。

读写锁应用于读操作远远大于写操作的情形下,C++14/17提供了std::share_mutex和std::share_timed_mutex两个类来实现读写锁:

typedef std::shared_lock<std::shared_mutex> ReadLock;
typedef std::lock_guard<std::shared_mutex> WriteLock;

读写锁遵守以下规则:

  1. 一个线程获取了读锁,则其他获取读锁的线程可以访问临界区,但写锁不能;
  2. 一个线程获得了写锁,则所有其他线程不能访问临界区;

递归锁

递归锁允许对同一个线程多次加锁。但实践中不推荐使用递归锁。

小结

多线程编程中,race condition是一个必须面对的问题。避免race condition的主要方法就是加互斥锁,可以基于std::mutex.lock加锁,但更为常用的是RAII的std::lock_guard。

stl的容器本身是不具备线程安全特性的,同时,设计一些合并的接口时可能遇到一些复杂情况,例如将stack的top和pop合在一起时,可能由于异常导致数据丢失。此时,使用shared_ptr是个不错的办法,shared_ptr在返回指针的同时避免了手动管理内存带来的风险。

死锁是另一个重要的问题。C++11提供了std::lock和std::coped_lock进行死锁避免工具。

要实现一个线程安全的单例模式,可以使用std::once_flag和std::call_once。

读写锁在读操作远远大于写操作的情况下比互斥锁高效,C++17提供了std::shared_mutex实现读写锁。

不要使用递归锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值