共享数据带来的问题
条件竞争
条件竞争产生
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,结果是可以接受的。例如有两个线程同时向一个处理队列中添加任务,由于系统提供的不变量保持不变,所以先后顺序并不会造成影响。不变量遭到破坏以后,才会产生条件竞争,这种竞争方式通常表示为恶性条件竞争。
恶性条件竞争通常发生于对多于一个的数据块的修改的时候,操作访问两个独立的数据块,独立的指令将会对数据块进行修改,并且可能一个线程正在进行时,另一个线程就对数据块进行了访问。因为出现的概率太小,条件竞争很难查找,并且也很难进行浮现。当系统负载增加的时候,随着执行数量的增加,执行序列的问题复现的概率也在增加,但是这只会出现在负载比较大的时候。条件竞争通常是时间敏感的,所以程序以调试模式进行运行的时候,他们将会完全消失。
避免恶性条件竞争
- 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏的中间状态。从其他的线程来看,修改不是已经完成的,就是还没有开始。
- 对数据结构和不变量的设计进行修改,修改完成的结构必须可以完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,即所谓的无锁编程。
- 使用事物的方式进行数据结构的更新。当所需的一些数据和读取都存储在事务日志中,然后将之前的操作合并为一步再进行提交。当数据结构被另一个线程修改之后,或者处理已经重启的情况下,提交就会无法进行。这被称作“软件事物内存”
使用互斥量保护数据
使用互斥量保护共享数据
当访问共享数据的前,使用互斥量将相关的数据锁住,当访问结束之后,再将数据进行解锁。当一个线程使用特定互斥量锁住共享数据时候,其他的线程想要访问锁住的数据,都必须等到之前的那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都可以看到共享数据,而不破坏不变量。
C++中使用互斥量
C++中通过实例化std::mutex创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。C++标准库为互斥量提供了一个RAII语法的模板类std::lack_guard,其会在构造的时候提供已经上锁的互斥量,并且在析构的时候进行解锁,从而保证了一个已经上锁的互斥量都会被正确的解锁。
#include<mutex>
#include<list>
#include<algorithm>
using namespace std;
std::list<int> some_list;
std::mutex some_mutex;
void add_to_list(int new_value){
std::lock_guard<std::mutex> guard(some_mutex);
some_list.push_back(new_value);
}
void list_contains(int value_to_find){
std::lock_guard<std::mutex> guard(some_mutex);
return std::find(some_list.begin(),some_list.end(),value_to_find)!=some_list.end();
}
在某些情况下,使用全局变量没有问题,但是在大多数的情况下,互斥量通常会和保护的数据放在同一个类中,而不是定义成全局变量。
将其放在同一个类中,就可以将其联系在一起,也可以对类的功能进行封装,并且进行数据的保护。在这种情况下,函数add_to_list和list_contains都可以作为这个类的成员函数。 互斥量和要保护的数据,在类中都要定义为private成员。当所有成员函数都会在调用的时候对数据进行上锁,结束的时候对数据进行解锁,就可以保证数据访问的时候,不变量不会被破坏。
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
class some_data{
int a;
string b;
public:
some_data():a(10),b("This is a test"){};
void do_something(){
cout<<b<<"\n";
cout<<a<<"\n";
};
void reset_a(int x){
a=x;
}
void reset_b(string s){
b=s;
}
};
//构造一个data_wrapper 类对data数据结构加以保护
//同时可以使用参数传递的方式 传入处理函数
//这里只使用了一个互斥量 保护的是data整体
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);
}
};
void malicious_function(some_data& protected_data){
protected_data.do_something();
protected_data.reset_a(10);
protected_data.reset_b("another test");
protected_data.do_something();
}
int main(){
data_wrapper x;
x.process_data(malicious_function);
return 0;
}
当其中的一个成员函数返回的是保护数据的指针或者引用的时候,会破坏对数据的保护。
精心组织代码来保护共享数据
使用互斥量保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard对象那么简单。一个迷失的指针或者引用,会将这种保护形同虚设。只要没有成员函数通过返回值或者是输出参数的形式向其调用者返回指向受保护数据的指针或者引用,数据就是安全的。
在确保成员函数不会传出指针或者引用的同时,检查成员函数是否通过指针或者引用的方式来调用也是重要的。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就是危险的。更为危险的是,将保护数据作为一个运行时参数。
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;
class some_data{
int a;
string b;
public:
some_data():a(10),b("This is a test"){};
void do_something(){
cout<<b<<"\n";
cout<<a<<"\n";
};
void reset_a(int x){
a=x;
}
void reset_b(string s){
b=s;
}
};
//构造一个data_wrapper 类对data数据结构加以保护
//同时可以使用参数传递的方式 传入处理函数
//这里只使用了一个互斥量 保护的是data整体
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_data;
void malicious_function(some_data& protected_data){
protected_data.do_something();
protected_data.reset_a(10);
protected_data.reset_b("another test");
protected_data.do_something();
unprotected_data=&protected_data;
}
int main(){
data_wrapper x;
x.process_data(malicious_function);
unprotected_data.do_something();
return 0;
}
使用接口内在的竞争条件
在使用了互斥量或者是其他机制保护共享数据的时候,也需要避免接口内部的条件竞争。
以双向链表为例,为了可以线程安全的删除一个节点,需要确保防止对三个节点(待删除节点以及前后相邻的节点)的并发访问。如果只是对指向每个节点的指针进行了访问保护,就和没有使用互斥量是一样的,条件竞争仍然是存在的。除了指针,整个数据结构和整个删除操作都是需要进行保护的。这种情况下最简单的解决方案就是使用互斥量保护整个链表。
构建一个类似于std::stack结构的栈,除了构造函数和swap()操作之外,还需要提供push(),pop(),top()等操作。