我的C++并发编程实战之旅(二)
线程间共享数据
共享数据带来的问题
共享数据如果是只读的,那么不会带来影响。如果多个线程要修改共享数据,那就很麻烦。
双链表删除节点后,需要更新两边。
避免恶性调节竞争
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。而恶性条件竞争通常发生于完成对多于一个的数据块的修改时。
避免恶性条件竞争有三个主要方法。
1、对数据块进行加锁等数据保护机制。同一时间,只有一个线程可对数据进行修改操作。
2、实现无锁化编程。对数据结构进行无锁化设计。
3、利用数据库中事务的思想,采用和事务一样的原理进行数据修改。
使用互斥量保护共享数据
互斥量是c++中最基本数据保护的方式。使用std::mutex来声明互斥量,使用lock()和unlock()函数进行加锁和解锁操作。因为使用lock()后,必须使用unlock()来解锁(包括异常情况),所以操作繁琐。我们可以利用RAII(获取资源即初始化)来完成这一操作。c++库提供了模板类std::lack_guard其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。两者都声明在头文件。
下面就是两种方法,推荐第二种。
#include <mutex>
std::mutex my_mutex;
void foo1(){
my_mutex.lock();
do_something();
my_mutex.unlock();
}
void foo2(){
std::lock_guard<std::mutex> my_guard(my_mutex);
do_something();
}
注意:要避免将保护数据的引用或者指针作为返回值传出或者参数传入,这将导致保护数据在保护域外被使用。
虽然某些情况下,使用全局变量没问题,但在大多数情况下,互斥量通常会与保护的数据放
在同一个类中,而不是定义成全局变量。这是面向对象设计的准则:将其放在一个类中,就
可让他们联系在一起,也可对类的功能进行封装,并进行数据保护。互斥量和要保护的数据,在类中都需
要定义为private成员,这会让访问数据的代码变的清晰,并且容易看出在什么时候对互斥量
上锁。当所有成员函数都会在调用时对数据上锁,结束时对数据解锁,那么就保证了数据访
问时不变量不被破坏。
typedef void (*funType) (int&);
class foo{
private:
int a = 0;
mutex my_mutex;
public:
void f(funType func){
std::lock_guard<mutex> my_guard(my_mutex);
func(a);
}
void show(){
cout << a <<endl;
}
};
int* unprotectd_data;
void func(int & a){
unprotectd_data = &a; //恶意或许a的地址
}
int main(){
foo a;
a.show();
a.f(func);
*unprotectd_data = 1; //a的地址不受保护,在互斥量保护外修改
a.show();
}
foo类的本意是只能在f函数内对变量a进行操作。但是当我们恶意地用指针来接收a变量的引用时,我们就可以在f的保护域外修改a。
互斥量范围大小与接口设计
假设我们有一个获取栈顶的函数pop
stack<int> s;
void mypop(stack<int> s){
if (! s.empty()){ // 1
int value = s.top(); // 2
s.pop(); // 3
do_something(value);
}
}
以上是单线程安全代码:对一个空栈使用top()是未定义行为。对于共享的栈对象,这样的调
用顺序就不再安全了,因为在调用empty()①和调用top()②之间,可能有来自另一个线程的
pop()调用并删除了最后一个元素。这是一个经典的条件竞争,使用互斥量对栈内部数据进行
保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。
所以进行如下的修改:
void mypop(stack<int> s){
mutex my_mutex;
lock_guard<mutex> my_guard(my_mutex);//
if(s.empty())
throw empty_stack();
else{
int value = s.top(); // 2
s.pop(); // 3
do_something(value);
}
}
死锁
死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。
死锁的四个条件:
-
互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
-
请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
-
不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
-
循环等待条件: 若干进程间形成首尾相接循环等待资源的关系
比如下面的例子,当foo1,foo2同时运行时,foo1和foo2会分别对m1,m2加锁。之后他们就会陷入foo1等待m2解锁,而foo2等待m1解锁的状态。这就是死锁状态。当我们要对2个以上的互斥量进行加锁的时候,要避免这种死锁状态
mutex m1;
mutex m2;
void foo1(){
lock_guard<mutex> guard1(m1);
do_something();
lock_guard<mutex> guard2(m2);
}
void foo2(){
lock_guard<mutex> guard2(m2);
do_something();
lock_guard<mutex> guard1(m1);
}
很幸运,C++标准库有办法解决这个问题, std::lock ——可以一次性锁住多个(两个以上)的
互斥量,并且没有副作用(死锁风险)。下面的程序清单中,就来看一下怎么在一个简单的交换
操作中使用 std::lock 。
mutex m1;
mutex m2;
void foo1(){
lock(m1,m2)
lock_guard<mutex> guard1(m1, adopt_lock);
lock_guard<mutex> guard2(m2, adopt_lock);
do_something();
}
void foo2(){
lock(m1,m2)
lock_guard<mutex> guard1(m1, adopt_lock);
lock_guard<mutex> guard2(m2, adopt_lock);
do_something();
}
lock()先对m1上锁,之后对m2上锁,当发现m2已锁时,将会抛出异常,同时对m1解锁。所以lock的作用是,要么2个一起锁,要么都不锁。std::adopt_lock表示对象已经上锁。
使用层次避免死锁
没学完,不学啦,哈哈哈