共享数据
不变量,书中总是提到不变量,我个人的理解是他描述的是一种固定的关系,这个关系是数据结构最基本也不会改变的关系。因为在改变数据的时候,比如链表,我们需要暂时的打破他们之间的关系,然后设置新的数据,然后恢复关系。数据结构要始终维持不变量的稳定。
这部分看不太懂,只是大致总结,可能有错误。
条件竞争
有一个临界资源,有两个线程都想要对它进行处理,但是肯定不能让两个线程同时操作,因为一个线程写到一半,另一个线程进来也要写,那么结果就无法保证是正确的,那么就需要让他们保持有序,此时就有了一个问题(竞争条件形成),谁先来(相对顺序)。书中提到只有不变量遭到破坏时,才会产生条件竞争(话说这东西不就是临界资源么)。
所以要提供一些不变量的保护手段。书中主要提到了锁、无锁编程、事务(事务没有涉及,事务在数据库中很常见)。
使用互斥量保护数据
互斥量简单理解加锁,学操作系统的时候的pv原语,被锁住的过程只有在这个线程完成修改,将锁打开后其他线程才可以修改,其实就是利用锁让在同一时间点的并行程序变成在同一时间点的串行程序。但是互斥锁针对指针和引用这种可以修改数据的的方式不起作用,他们可以绕过互斥锁对数据进行修改,所以在使用引用或者指针的时候要注意。
书中例子:
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(); // 在无保护的情况下访问保护数据
}
除了变量的指针和引用,函数的指针和引用,因为如果传进来的函数是以引用的方式使用参数的,那么锁也就没用了。尽量避免将参数的指针通过返回值等各种方式传到函数的外面。
接口内在条件竞争
书中利用栈举了一个例子。
stack<int> s;
if (! s.empty()){ //
int const value = s.top(); //
s.pop(); //
do_something(value);
}
这里如果是单线程,那么没有问题,但如果是多线程,在获取了栈的数量后线程就可以操作栈中的数据,这是这个数量就不一定正确。如果在使用top的时候栈已经被清空了,这是就会发生错误。就算使用了锁对内部数据进行保护,但是该问题还是会存在。也就是说函数内部的接口之间不同步,因为执行顺序的差异可能会引起不同的问题。
书中着重强调了top和pop之间的矛盾以及解决方法。
- 传入一个引用:将用于存储结果的变量的引用作为参数传入pop中用于获取值。
std::vector<int> result;
some_stack.pop(result);
创建临时的变量用于接收值,在时间和资源上都很不划算。同时有些类型不一定可用,书中提到的构造函数参数,可能是需要从程序中获得一定的参数进行初始化才行,还有一些类型不支持赋值操作。
- 要求不引发异常的拷贝和移动构造函数
对于pop来说有一个问题是返回值异常,这个异常可能会使得程序终止或者结果错误(tom指出拷贝构造函数的异常可能会导致问题),许多类型的拷贝和移动构造函数不会返回异常。 - 返回指向出栈栈顶的指针
返回一个一个出栈元素的指针,指针可以随意复制不引发异常,但是对指针的内存进行管理需要很大的开销。
一般会采用组合的方式,书中存在一个线程安全的例子。
首先是线程不安全和线程安全的接口。
template<typename T,typename Container=std::deque<T> >
class stack
{ //线程不安全
public: //一系列构造器
explicit stack(const Container&);
explicit stack(Container&& = Container());
template <class Alloc> explicit stack(const Alloc&);
template <class Alloc> stack(const Container&, const Alloc&);
template <class Alloc> stack(Container&&, const Alloc&);
template <class Alloc> stack(stack&&, const Alloc&);
//操作函数
bool empty() const;
size_t size() const;
T& top();
T const& top() const;
void push(T const&);
void push(T&&);
void pop();
void swap(stack&&);
};
#include <exception>
#include <memory> // For std::shared_ptr<>
struct empty_stack: std::exception
{
const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{//线程安全
public:
threadsafe_stack();
threadsafe_stack(const threadsafe_stack&);
threadsafe_stack& operator=(const threadsafe_stack&) = delete; // 赋值操作被删除
void push(T new_value);
std::shared_ptr<T> pop();//这个地方用到了智能指针,智能指针有c++管理,可以防止内存泄漏
void pop(T& value);
bool empty() const;
};
两个接口中,线程安全会削减一部分接口,对栈进行一定的限制(简化接口是为了保证互斥锁的效果)。同时添加了一个异常处理的函数,这个函数是为了对空的栈进行pop时发生的异常进行处理。
完整的程序:
#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack: std::exception
{
const char* what() const throw() {//当对空栈进行违法的操作的时候,调用这个处理函数
return "empty stack!";
};
};
template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;//变量封装在内部,尽量不外泄
mutable std::mutex m;//将锁的状态定义为可变的,一般get之类的函数会定义为const,但是为了保证同步性或者希望对变量进行一定的改变又不想改变const属性
//定义为mutable的变量可以不受const的限制改变变量的状态,锁的情况是为了保证同步
public:
threadsafe_stack()
: data(std::stack<T>()){}//构造函数
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);//c++标准库提供的RALL语法的锁,初始化时上锁,析构是解锁,保证真确解锁
data.push(new_value);
}
std::shared_ptr<T> pop()//返回的是一个智能指针
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack(); // 在调用pop前,检查栈是否为空
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);//此处用可变的锁对状态进行同步,因为empty是const
return data.empty();
}
};
利用拷贝上锁的方法比直接用构造器初始化列表的方式要好,可以保证复制的正确性。上面内容大部分进行了注释。书中提到锁的粒度,粗粒度用一个锁将大量共享数据一起锁住,细粒度则是分别使用多个锁去锁住这些数据。
使用锁还可能引发死锁,与竞争条件相反,它们互相等待什么都不做。
死锁
死锁就是资源互相等待,什么都不做。比如两个人要吃牛排,只有一副餐具,其中一个人有刀,另一个有叉(两个人都是强迫症,必须要凑齐刀叉才吃),他们都在等着对方把餐具给自己,然后两个人什么都不做,干瞪眼。这就造成了死锁,想要的资源在其他人的手里并且被锁住了,造成循环等待。
死锁产生的条件
- 互斥:资源独占且排他,不接受一个以上的对象的操作。
- 不可剥夺:资源只能有进程主动释放,不能被剥夺。
- 请求和保持:进程申请其他资源的时候对已占有资源不进行释放。
- 循环等待:资源的申请和占有形成了环路,无限循环。
预防死锁
- 破坏不可剥夺:进程不能获得全部资源处于等待时,隐形释放已获得资源。
- 破坏请求和保持:1、一口气满足进程的所有要求,静态的分配所需要的所有资源。2、动态分配,要求进程没有占据资源。
- 破坏循环等待:给资源进行编号,有序的进行分配,紧缺的编号大,只有获得小编号后,才能申请大编号。
书中提到一般的避免死锁采用的顺序上锁,这个有一定的效果,但是还是会引发死锁。书中的例子是数据交换,如果是两个线程试图对相同的两个实例进行数据交换,这是会引发死锁(没太看明白,个人理解两个线程的上锁顺序不同,引发了互相等待,比如1,2和2,1,这样有顺序的锁也会引发死锁)。
c++提供的解决
C++标准库对这个问题提供了std::lock,可以一次性锁住多个互斥量,同时没有死锁风险(std::lock内部采用了避免死锁的算法)。
书中示例:
// 这里的std::lock()需要包含<mutex>头文件
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(lhs.m,rhs.m); // 可以同时锁住多个互斥量不产生死锁
//产生两个锁的实例
std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); // adopt_lock表示之前已经将这个变量锁住了,这里不用再创建锁,而是将锁的管理权交给lock_guard
std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); // 如果adopt_lock没有提前锁,会报异常
swap(lhs.some_detail,rhs.some_detail);
}
};
这样保护在多数情况下不出错,也允许使用return进行返回。std::lock上锁的时候可能会产生异常,这种异常会传播到lock的外面,当尝试获取另外一个锁的时候会抛出异常,第一个锁也会释放(简单的说跟事务比较像,要么不锁,要么一起锁)。std::lock同时获取锁可以避免死锁,但如果是分别获取就没有作用了。
避免死锁进阶
除了锁会产生死锁外,错误使用join也会产生死锁,线程互相等待一样会产生死锁。书中提供了几点建议。
- 避免嵌套锁:每个线程只获取一个锁,尽量避免获取多个锁造成的等待。需要多个锁的时候,使用std::lock来避免。
- 避免在持有锁的时候调用用户提供的代码:个人理解尽量避免在持有锁的时候,调用由外部传进来的代码,例如函数指针等,因为不确定它内部逻辑,它的内部也可能会需要锁,造成锁的嵌套。
- 使用固定顺序获取锁:如果一定要获取多个锁并且不能使用std::lock是,那么固定获得锁的顺序。
- 层次锁:通过分层定义一个上锁的顺序,对程序进行分层,根据层次锁需要遵守的原则上锁。如果一个互斥量在低层已经被上锁,那么高层则不能再上锁(因为底层的东西已经被控制住了)。
书中例子:
hierarchical_mutex high_level_mutex(10000); //
hierarchical_mutex low_level_mutex(5000); //
int do_low_level_stuff();
int low_level_func()
{
std::lock_guard<hierarchical_mutex> lk(low_level_mutex); //
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex); // 先对高层上锁
high_level_stuff(low_level_func()); // 然后对低层上锁
}
void thread_a() // 遵守原则,可以正常运行
{
high_level_func();
}
hierarchical_mutex other_mutex(100); //
void do_other_stuff();
void other_stuff()
{
high_level_func(); //
do_other_stuff();
}
void thread_b() //
{
std::lock_guard<hierarchical_mutex> lk(other_mutex); // 先锁了底层
other_stuff();//再锁高层会出错,会抛出错误或者停止程序
}
层次锁不产生死锁,因为本身是遵守严格规定的,但是当同一级存在多个互斥量时,只能持有一个锁,不能持有多个锁,互斥量必须是链条式的锁定,并且层级由高到低。(有些情况无法使用)
例子中lock_guaud与用户定义的互斥量一起使用,hierarchical_mutex是一个用户定义的互斥量,因为对lock(),unlock(),try_lock()进行了重写,这样就可以跟模板类一起使用(std::lock中try_lcok会作为避免死锁的算法的一部分)。
书中关于互斥量的实现:
class hierarchical_mutex
{
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value; // 代表当前线程的层级
void check_for_hierarchy_violation()
{
if(this_thread_hierarchy_value <= hierarchy_value) // 当前层必须要大于需要上锁的层才可以上锁
{
throw std::logic_error(“mutex hierarchy violated”);//底层已经上锁时抛出异常
}
}
void update_hierarchy_value()
{
previous_hierarchy_value=this_thread_hierarchy_value; // 只有保存层级才能释放
this_thread_hierarchy_value=hierarchy_value;
}
public:
explicit hierarchical_mutex(unsigned long value):
hierarchy_value(value),
previous_hierarchy_value(0)
{}
void lock()
{
check_for_hierarchy_violation();//检查是否可以上锁
internal_mutex.lock(); // 上锁
update_hierarchy_value(); // 更新层级信息
}
void unlock()
{
this_thread_hierarchy_value=previous_hierarchy_value; // 对之前一层进行解锁
internal_mutex.unlock();
}
bool try_lock()
{
check_for_hierarchy_violation();//顺序上锁,必须由高到低
if(!internal_mutex.try_lock()) // 检测是否可以上锁,不可以则返回false,可以的话上锁
return false;
update_hierarchy_value();//更新层次值
return true;
}
};
thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); // 将当前线程的层级初始化为最大值,第一次上锁时也是最大值
thread_local
其中用到了thread_local类型的变量,这是 一种线程存储类型,与auto等关键字类似,均用来定义变量,使用thread_local定义的变量将具有线程声明周期,线程生命周期符合RAII原则,即创建就初始化,析构就销毁,与线程的声明周期保持一致(类似线程内部的static变量,一个线程内是常驻的)。每个线程都拥有自己的变量副本(个人理解,如果声明是线程周期的变量就只对一个线程负责,因为每个线程都会拥有这个变量的副本,所以这个变量在线程之间是互不影响的,变量会以相同的初始值出现在不同的线程中,之后就是各自处理各自的变量,相当于一个局部变量)。thread_local可以与static或extern联合使用,但是会影响链接属性(根据作用域的不同分为内部,外部,无链接性,外部链接在其他文件中可见,内部链接只在本文可见,无属性只在当前代码可见,基本就是变量的作用域问题)。
命名空间内的变量,静态成员变量,以及本地变量局可以声明为线程变量。
thread_local int x; // 命名空间内的线程本地变量
class X
{
static thread_local std::string s; // 线程本地的静态成员变量
};
static thread_local std::string X::s; // 这里需要添加X::s
void foo()
{
thread_local std::vector<int> v; // 一般线程本地变量
}
- 命名空间变量和静态成员变量要在使用前进行初始化。
- 本地静态变量和本地变量只有在使用线程初始化时才会构造。
- 静态变量与线程本地变量共享一些属性(这里之前看过一个例子,个人理解,对于单个线程而言,线程本地变量与静态变量类似,在这个线程内它始终存在),如果初始化报错,则会调用std::terminate结束程序。
- 析构函数是逆序进行析构的,所以确定变量之间不能有相互的依存关系,如果析构抛出异常会调用terminate终止程序。
- 如果线程调用std::exit或者在main函数中调用std::exit返回值时,线程本地变量会被销毁。如果此时还有其他线程在运行,则其他线程的变量不受影响。
- 线程之间可以通过指针或者引用的形式调用这些变量,所要要确保使用时变量没有被释放。