避免恶性条件竞争
双向链表具有前后两个链,当一个线程修改一个双向链表的前链还未来及修改后链时,另一个线程进行了数据访问,此时就会发生恶性条件竞争。多线程编程中,我们总会遇到多个线程对一个数据块进行修改,所以需要避免恶性条件竞争。避免恶性条件竞争有三个主要方法。
1、对数据块进行加锁等数据保护机制。同一时间,只有一个线程可对数据进行修改操作。
2、实现无锁化编程。对数据结构进行无锁化设计。
3、利用数据库中事务的思想,采用和事务一样的原理进行数据修改。
使用互斥量保护共享数据
互斥量是c++中最基本数据保护的方式。使用std::mutex来声明互斥量,使用lock()和unlock()函数进行加锁和解锁操作。因为使用lock()后,必须使用unlock()来解锁(包括异常情况),所以操作繁琐。我们可以利用RAII(获取资源即初始化)来完成这一操作。c++库提供了模板类std::lack_guard来完成在构造时加锁,析构时解锁的操作。两者都声明在<mutex>头文件。
#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();
}
foo1和foo2都加了互斥量,对数据进行了保护。
注意:要避免将保护数据的引用或者指针作为返回值传出或者参数传入,这将导致保护数据在保护域外被使用。
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
void pop(T & t){
if(!stack.empty())
t = stack.top();
stack.pop();
}
假设stack的每一个函数都是线程安全的,但是我们的pop函数线程安全的吗?答案肯定是不安全。调用empty()之后,别的线程可能做了入栈操作或者2个线程一起完成了empty()准备进入top()读取数据,所以上述函数并不是线程安全的。
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if(data.empty()) throw empty_stack();
value=data.top();
data.pop();
}
上面是一个线程安全的函数。函数线程安全,并不意味函数的组合安全。小范围的互斥量效率更高,但是容易出现恶性条件竞争。大范围的互斥量覆盖广,但是效率相对低下。
死锁
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);
}
当foo1,foo2同时运行时,foo1和foo2会分别对m1,m2加锁。之后他们就会陷入foo1等待m2解锁,而foo2等待m1解锁的状态。这就是死锁状态。当我们要对2个以上的互斥量进行加锁的时候,要避免这种死锁状态。
c++库提供的lock函数,可以允许我们同时对2个锁进行加锁。
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表示对象已经上锁。
避免死锁的一些建议:
1、避免嵌套锁。一个线程已获得一个锁时,再别去获取第二个。
2、避免在持有锁时调用用户提供的代码。使用函数指针传入用户的函数是十分危险的事情。因为你并不知道用户会执行什么操作。
3、使用固定顺序获取锁。当无法使用lock同时加锁时,那么最好在每个线程上,用固定的顺序获取它们。
使用层次锁避免死锁
层次锁的思想是限定加锁顺序。高级锁后加低级锁合法,反之,低级到高级抛出错误。
hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(500);
void foo1(){ //高级->低级合法
lock_guard<hierarchical_mutex> guard1(high_level_mutex);
lock_guard<hierarchical_mutex> guard2(low_level_mutex);
}
void foo2(){ //低级->高级非法
lock_guard<hierarchical_mutex> guard1(low_level_mutex);
lock_guard<hierarchical_mutex> guard2(high_level_mutex);
}
层次锁hierarchical_mutex并不是c++标准的一部分,我们需要手动实现它。为了能在lock_guard<>模板中使用,我们需要实现lock(),unlock()和try_lock()三个函数。
class hierarchical_mutex{
std::mutex internal_mutex;
const unsigned hierarchical_value; //当前层号
unsigned previous_hierarchical_value; //记录上级锁的层号,用于解锁
static thread_local unsigned this_thread_hierarchical_value; //当前线程中已锁的最小的层号
void check_for_hierarchical_violation(){
if (this_thread_hierarchical_value <= hierarchical_value){
throw std::logic_error(“mutex hierarchical violated!”);
}
}
void update_hierarchical_value(){
previous_hierarchical_value = this_thread_hierarchical_value;
this_thread_hierarchical_value = hierarchical_value;
}
public:
explicit hierarchical_mutex(unsigned value) :hierarchical_value(value), previous_hierarchical_value(0){}
void lock(){ //加锁
check_for_hierarchical_violation();
internal_mutex.lock();
update_hierarchical_value;
}
void unlock(){ //解锁
this_thread_hierarchical_value = previous_hierarchical_value;
internal_mutex.unlock();
}
bool try_lock(){ // 如果互斥量被其他线程加锁,将返回false
check_for_hierarchical_violation();
if (!internal_mutex.try_lock())
return false;
update_hierarchical_value();
return true;
}
};
// 初始化为uint最大值,保证第一个锁不管多大都可以加锁成功
thread_local unsigned hierarchical_mutex::this_thread_hierarchical_value(UNSIGND_MAX);
thread_local使得一个线程中只有一个this_thread_hierarchy_value副本,用来标识该线程当前已加锁的层级。
previous_hierarchical_value用于记录上一级的锁。解锁时,便于找到上级,将this_thread_hierarchy_value进行替换。
更加灵活的锁——unique_lock
unique_lock是lock_guard一样是一个模板类,用于更好地利用和管理互斥量。使用lock和unlcok可以对unique_lock进行加锁和解锁操作。在初始化传入defer_lock常量,可以推迟初始化时的加锁。由于会进行锁的状态检查,所以unique_lock会比lock_guard效率稍微低一点。
void foo(){
mutex m;
unique_lock<mutex> lock1(m, defer_lock); // m未上锁
do_something();
lock1.lock();
do_something();
lock1.unlock();
}
但是unique_lock不会与唯一的互斥量绑定,可以通过移动的方式,将一个互斥量在多个unique_lock之间进行移动。
注意:unique_lock通过接收右值来进行互斥量所有权的转移,左值传递非法。
void foo(){
mutex m;
unique_lock<mutex> lock(m);
unique_lock<mutex> lock_move(move(lock)); //lock_move获得m的所有权
// lock_move = lock; 非法!
// unique_lock<mutex> lock_move(lock); 非法!
}
当然,我们也可以通过函数返回值的方式来返回一个unique_lock
mutex m;
unique_lock<mutex> foo(){
unique_lock<mutex> lock(m);
return lock;
}
void foo1(){
unique_lock<mutex> lock(foo());
do_something();
}
特殊情况下的数据保护
1、只有在初始化过程中才需要数据保护。
有时候,对于一些只读数据,我们只在初始化的时候进行数据保护。相较于开销更大的互斥量,双重锁模式具有更小的开销。
void undefined_behaviour_with_double_checked_locking()
{
if(!resource_ptr)
{
std::lock_guard<std::mutex> lk(resource_mutex);
if(!resource_ptr)
{
resource_ptr.reset(new some_resource);
}
}
resource_ptr->do_something();
}
这里做了2次指针判断,只有在指针为初始化的情况下,才会进行加锁初始化。但是双重锁还是存在恶性条件竞争的情况。指针初始化时,包括对象调用构造函数和对象赋值指针2个步骤,但是他们的顺序却是不确定了。所以当对象先赋值却没有执行构造函数时,另一个线程就会发现指针不为空,并调用do_someting,执行一个未初始化的对象。
c++提供了更加安全的机制来完成只要初始化时才需要的数据保护操作。使用std::call_once()和std::once_flag来完成之多线程的初始化。
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();
}
call_once()接受一个once_flag变量和一个初始化函数来进行初始化的数据保护。初始化函数也可以的类的成员函数。
class demo{
private:
once_flag flag;
void init();
public:
void thread_do(){
call_once(flag, &demo::init, this);
do something();
}
}
2、保护很少更新的数据结构
boost提供了boost::shared_mutex和boost::shared_lock()来实现读者-写者锁。当使用lock_gurad,unique_lock对shard_mutex加锁后,无法使用shared_lock()加锁,反之亦然。但是shard_lock()加锁后,其他线程仍可以使用shared_lock()加锁。这就是读者优先。
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>
class dns_entry;
class dns_cache
{
std::map<std::string, dns_entry> entries;
mutable boost::shared_mutex entry_mutex;
public:
dns_entry find_entry(std::string const& domain) const
{
// 阻塞lock_guard
boost::shared_lock<boost::shared_mutex> lk(entry_mutex);
std::map<std::string, dns_entry>::const_iterator const it =
entries.find(domain);
return (it == entries.end()) ? dns_entry() : it->second;
}
void update_or_add_entry(std::string const& domain, dns_entry const& dns_details)
{
// 阻塞shard_lock
std::lock_guard<boost::shared_mutex> lk(entry_mutex);
entries[domain] = dns_details;
}
};
嵌套锁
假设一个类的成员函数都有加锁操作。当在一个成员函数中,调用另一个时,就会对一个互斥量加锁2次。c++提供了std::recursive_mutex来支持这种多次嵌套加锁。
但是这种做法是不推荐。我们可以把调用函数作为私有对象放入类中,去除加锁操作。