保护共享数据

来源:互联网 发布:in java.library.path 编辑:程序博客网 时间:2024/05/18 02:48

在多线程环境中,读取操作(不管几个线程同时进行)共享数据不会影响到数据的,但是有一个线程或多个线程意图修改数据是,就需要一些机制来保证所有线程都正常工作。

 

C++中的互斥量

C++标准库中提供了std::mutex来创建互斥量,调用其成员函数lock()可以对互斥量上锁,调用成员函数unlock()进行解锁。但是手动调用这两个成员函数会很麻烦,必须记住每次跳出执行流的时候调用unlock(),由此我们使用RAII来管理std::mutex对象

:std::mutex对象不可拷贝和移动

 

RAII

C++标准库中提供了一个模板类std::lock_guard,它在构造的时候将互斥量上锁,在析构时解锁,以保证一次锁定对应一次解锁

 

一个栗子:

#include <list>#include <mutex>//std::mutex与std::lock_guard声明在此头文件#include <algorithm>class phread_list{private:std::list<int> some_list; std::mutex some_mutex; public:void add_to_list(int new_value){std::lock_guard<std::mutex> guard(some_mutex); some_list.push_back(new_value);} bool 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();}};


上面的例子中,为了符合面向对象的设计原则,将互斥量与受保护数据放在同一个类中,且都声明为私有成员,在公有函数成员访问受保护数据(构造std::lock_guard<>对象)时上锁,结束时解锁

 

避免危险的使用指针与引用

仅仅在成员函数操作受保护数据之前构造一个std::lock_guard对象,并由此对象来管理互斥量不一定就是万无一失的,一切向外部返回受保护数据的地址或是引用的方式都能让这一策略功亏一篑。

:

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);}

上述代码放到多线程环境时,可能会出现问题,因为在当前线程调用empty()top()之间,或是调用top()pop()之间,可能另外一个线程调用了pop()....

 

我们可以像之前一样提供一个互斥量来对栈实现保护:将栈封装起来,在接口函数中,操作受保护数据前给互斥量上锁,但并不意味着这样就高枕无忧了,考虑这样一种情况:

 

Thread A

Thread B

If( !s.empty() )

 

 

If( !s.empty() )

Int const value = s.top();

 

 

Int const value = s.top();

S.pop();

 

do_something(value);

S.pop();

 

do_something(value);

 

上例中,两个线程top()时获取到同样的值两次,两个线程用同样的值执行了do_something(),

出现这种可能是因为获取顶部对象的操作与弹出顶部对象的操作是分开的。

 

新的问题

那么仅仅将这两个操作合起来可以吗?不行!如果用户意图使用栈中弹出的对象去拷贝构造一个新的对象,但是新对象在拷贝构造时正好构造函数抛出了一个异常(虽然在构造函数中抛出异常是不被建议的),这样就会导致数据丢失:源对象从栈中移除了但是构造新对象时,拷贝又失败了

 

解决办法

1、在封装类的pop()函数中,将待弹出对象的引用作为参数,这需要先构造一个对象用于接收目标值,且需要对象支持可复制的或是可移动的,因为需要在函数中将传入的对象作为拷贝或是移动操作的目标对象

2、使用无异常抛出额拷贝构造函数或移动构造函数,但我们不能避免使用到的用户自定义类型一定就不会在构造时抛出异常

3、返回指向弹出对象的副本的指针,在这个办法下,我们最好使用std::shared_ptr对象与库函数std::make_shared<>()配合来避免内存泄露,并且std::make_shared<>()相对于new还有些性能上的提升

 

一个线程安全的栈

 

#include <exception>#include <memory>#include <mutex>#include <stack>struct empty_stack: std::exception{const char* what() const throw() //throw()声明此函数不会抛出异常{return "empty stack!";};};template<typename T>class threadsafe_stack{ private:std::stack<T> data;mutable std::mutex m;public:threadsafe_stack() : data(std::stack<int>()){}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(new_value);}std::shared_ptr<T> pop(){std::lock_guard<std::mutex> lock(m);if(data.empty()) throw empty_stack(); //由于const的影响智能指针的指向不能修改,指向的对象的内容可以被修改std::shared_ptr<T> const res(std::make_shared<T>(data.top())); //C++11特性std::make_shared<>()使用其参数来构造给定类的对象,库函数在内存中分配一个对象并初始化它,返回指向此对象的shared_ptr,此函数位于头文件memory中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();}};


死锁

在多线程编程中,如果存在两个或以上的互斥量,且这两个互斥量都是用来锁定一个操作时,当一个线程获取了其中一个互斥量并上锁,另一个线程获取了另一个互斥量并上锁,所有线程都等待对方释放互斥量

 

C++标准库提供了一个库函数std::lock()(库函数的参数必须是可锁定的),可以一次性锁住两个及以上的互斥量,并且没有死锁风险

个栗子

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函数不能锁住指定的所有互斥量时,它会解锁所有已成功锁住的互斥量std::lock(lhs.m,rhs.m); //锁住两个互斥量后再交换std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock); //std::adopt_lock参数假定调用线程的std::lock_guard<>已经拥有了锁,即构造std::lock_guard<>对象时不会重复给互斥量上锁,仅仅是管理已有的锁,std::lock_guard<>对象在析构时解锁,如果不构造std::lock_guard<>则需要手动解锁互斥量m.unlock() (m为std::mutex)std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); swap(lhs.some_detail,rhs.some_detail);}};

 

需要说明的一点时std::lock虽然可以锁住多个互斥量,并且可以一定程度上避免死锁,但是他不能获取一个锁

 

避免死锁

1、避免嵌套锁,如果一个线程已经获得了一个锁,别再去获取第二个,如果真的需要获取多个锁,使用std::lock来避免死锁

2、避免在有锁时调用用户提供的代码,因为不能保证用户代码不会获取锁

3、使用固定顺序获取锁,当必须要获取两个及以上锁的时,但有没有办法同时获取它们时,应该在每个线程上用固定的顺序获取锁(注意别天真的直接使用std::mutex对象)

4、使用层次锁,这也是一种顺序获取锁的情况,但层次锁可以在运行时检查每个线程是否真的使用了固定的顺序获取锁,层次锁的规则是:将程序分成几个层次。区分每个层次中使用的锁,在程序运行时给不同的锁加上层次号,记录每个线程持有的锁。当一个线程已经持有更低层次的锁时,不允许再获取高层次的锁。

层次锁的实现

 

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; //thread_local声明的变量具有线程的生命周期,每个获取到thread_local变量的线程都有一份独立的拷贝,因此线程间的thread_local变量不会相互影响,thread_local变量在声明时必须为 命名空间的全局变量、静态成员变量、本地成员变量之一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()) //当互斥量已被其他线程上锁时,try_lock()将立即返回false而不是等待,他也是避免死锁的好帮手return false;update_hierarchy_value();return true;}};thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); 

 

使用的例子

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();}

 

std::unique_lock

std::unique_lock对象在构造时,第二个参数传递:

std::adopt_lock,指明构造std::unique_lock对象在构造时不会锁定互斥量,而是假定它已被当前线程锁定,使std::::unique_lock管理互斥量

Std::defer_lock,指明构造std::unique_lock对象时不会锁定互斥量,将其初始化为不拥有锁,即表明互斥量应该保持解锁状态,随后可以调用std::unique_lock对象的lock()函数或者std::lock()函数获取锁,std::::unique_lock管理互斥量

 

由于std::unique_lock对象内部维护了互斥量是否被锁定的标志,因此std::unique_lock对象比起std::lock_guard占用内存大一些,性能也稍微差一些,但是它比std::lock_guard对象更加灵活,因为它有比std::lock_guard更多的成员函数,比如lock(), try_lock(), unlock(),这使得它可以更加灵活的控制锁的粒度,提升性能

 

一个栗子

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::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); //同时锁定两个互斥量swap(lhs.some_detail,rhs.some_detail);}};

 

不同域中互斥量所有权的转移

Std::unique_lock对象之间可以进行互斥量所有权(包含锁定状态)的移动(可移动不可拷贝),如果待移动目标已经对一个互斥量上锁,则在移动赋值发生之前std::unique_lock对象会调用unlock函数释放已持有的锁

被移动的std::unique_lock对象将不能再管理互斥量

 

一个栗子

std::unique_lock<std::mutex> get_lock()  {      extern std::mutex some_mutex;      std::unique_lock<std::mutex> lk(some_mutex);  //上锁    prepare_data();      return lk;  }  void process_data()  {      std::unique_lock<std::mutex> lk(get_lock());  //传递锁的所有权    do_something();  }  //解锁

 

为了使程序拥有较好的性能,应该做到

1、如果可能,尽量在实际访问共享数据时锁定互斥量

2、除非必须,在持有锁时不应该做一些费时的操作,特别像文件I/O那样的事情

3、不要在持有锁时试图上另一个锁,因为你并不知道是否会造成死锁

即在执行必要操作时,尽可能将持有锁的时间减缩到最短,但是也需要选择好合适的锁粒度,以保证受保护数据被成功保护

 

class Y  {  private:      int some_detail;      mutable std::mutex m;         int get_detail() const      {          std::lock_guard<std::mutex> lock_a(m);          return some_detail;      }  public:      Y(int sd):some_detail(sd){}        friend bool operator==(Y const& lhs, Y const& rhs)      {          if(&lhs==&rhs)              return true;            int const lhs_value=lhs.get_detail();  //成员函数调用之后数据已经不受保护        int const rhs_value=rhs.get_detail();            return lhs_value==rhs_value;  //锁粒度过小,返回值可能不能代表真正的情况    }  };

 

保护共享数据初始化

 

一个单线程中单例模式的例子

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

但上面这个代码并不适合多线程的情况,为了使上述代码在多线程环境下任然有效,我们可以加锁

td::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();  } 

还有其他的方法,比如并不安全的的双重检查锁

C++标准库有更好的办法来解决这些问题,标准库提供了std::once_flagstd::call_once

td::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);//仅有一个线程执行init_resource()resource_ptr->do_something();  }  

std::call_once消耗的资源比互斥量更小,特别是当初始化已经完成后,std::call_once可以和任何函数或者可调用对象一起使用,std::call_once的第一个参数是std::once_flag第二个参数为函数指针,第三个参数是函数的参数。。。(linux中也有仅初始化一次的方法)

 

:std::one_flag对象不可拷贝与移动

 

c++11标准中,被声明为static类型的局部变量不会再出现被多个线程多次初始化的问题,标准中,初始化及定义完全在一个线程中发生,其他线程不能在其初始化完成前对其处理

class my_class;  my_class& get_my_class_instance()  {  static my_class instance;   //仅初始化一次return instance;  }  

 

递归锁

对于普通锁(std::mutex)而言,一个线程对一个普通锁上锁两次是一个错误的未定义行为

C++标准库中提供了一个递归锁(std::recursive_mutex),递归锁可以使一个线程对其多次上锁,但是需要记住,解锁的次数必须与上锁的次数相等才能正确地解锁,我们可以将对递归锁的管理交给std::lock_guardstd::unique_lock

 

递归锁一般用在可被多线程并发访问的类上,每个公有成员函数都会对互斥量上锁,完成操作之后再解锁,但有时候一个公共函数可能调用了另一个公共函数

但是这样做是不被建议的,取而代之的方法是,提取一个函数作为私有成员函数,使其他所有公有成员函数可以对其调用,而这个私有成员函数不能有对互斥量上锁的操作


注:文中的例代码大部分来自  C++并发编程实战


参考: C++并发编程实战


 

原创粉丝点击