第三章 线程间共享数据

3.1 共享数据带来的问题

  • 条件竞争
    当不变量遭到破坏时,才会产生条件竞争。恶性条件竞争通常发生于完成对多于一个的数据块的修改时。

3.2 使用互斥量保护共享数据

  • 任何一个线程在执行这些代码时,其他任何线程试图访问共享数据结构,就必须等到那一段代码执行结束。这样其它线程就不会破坏不变量了。
  • 具体操作:
    当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。
  • 使用互斥量:
    • 创建互斥量:std::mutex
    • 上锁:std::lock();
      解锁:std::unlock();
      不推荐实践中直接去调用成员函数,因为调用成员函数就意味着,必须记住在每个函数出口都要去调用unlock(),也包括异常的情况。
    • 所以经常使用std::lock_guard来创建锁;其优点在于会主动解锁,而不需人为去操作;但其解锁在解析函数中,故只有解析的时候才能解锁。不灵活!!
    • 实例:
#include <list>
#include <mutex>
#include <algorithm>

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

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();
}
  1. 在该例中使用的是全局变量,大多数时候是写在类中的。可将上诉两个函数写成成员函数。
  2. 当其中一个成员函数返回的是保护数据的指针或引用时,会破坏对数据的保护。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。
  3. 互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,并且不留后门——只要没有成员函数通过返回值或者输出参数的形式向其调用者返回指向受保护数据的指针或引用,数据就是安全的。
  4. 故而,切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。

3.2.1 接口内在的条件竞争

  • 不使用指针和引用作为返回值,且确保指针和引用都在锁内仍旧不能完全避免条件竞争。
  • 接口也会引起条件竞争,如stack:某一线程判断是否为空,另一线程进行pop,可能使得第一个线程的判断失效;
  • 故将top和pop进行互斥保护,问题在于:拷贝构造函数在栈中抛出一个异常,这样的处理方式就会有问题
  • 假设有一个stack<vector>,vector是一个动态容器,当你拷贝一个vetcor,标准库会从堆上分配很多内存来完成这次拷贝。当这个系统处在重度负荷,或有严重的资源限制的情况下,这种内存分配就会失败,所以vector的拷贝构造函数可能会抛出一个std::bad_alloc异常。
  • 当vector中存有大量元素时,这种情况发生的可能性更大。当pop()函数返回“弹出值”时(也就是从栈中将这个值移除),会有一个潜在的问题:这个值被返回到调用函数的时候,栈才被改变;但当拷贝数据的时候,调用函数抛出一个异常会怎么样? 如果事情真的发生了,要弹出的数据将会丢失;它的确从栈上移出了,但是拷贝失败了!
  • std::stack的设计人员将这个操作分为两部分:先获取顶部元素(top()),然后从栈中移除(pop())。这样,在不能安全的将元素拷贝出去的情况下,栈中的这个数据还依旧存在,没有丢失。当问题是堆空间不足,应用可能会释放一些内存,然后再进行尝试。
  • 解决方法:
  1. 传入一个引用
std::vector<int> result;
some_stack.pop(result);

大多数情况下,这种方式还不错,但有明显的缺点:需要构造出一个栈中类型的实例,用于接收目标值。对于一些类型,这样做是不现实的,因为临时构造一个实例,从时间和资源的角度上来看,都是不划算。

  1. 无异常抛出的拷贝构造函数或移动构造函数
    很多类型都将会有一个移动构造函数,即使他们和拷贝构造函数做着相同的事情,它也不会抛出异常。一个有用的选项可以限制对线程安全的栈的使用,并且能让栈安全的返回所需的值,而不会抛出异常。

  2. 返回指向弹出值的指针
    指针的优势是自由拷贝,并且不会产生异常,这样你就能避免异常问题了。缺点就是返回一个指针需要对对象的内存分配进行管理,这个方案的开销相当大。
    对于选择这个方案的接口,使用std::shared_ptr是个不错的选择;不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁),而且标准库能够完全控制内存分配方案,也就不需要new和delete操作。

  3. “选项1 + 选项2”或 “选项1 + 选项3”
    例:定义线程安全的堆栈
    下例是一个接口没有条件竞争的堆栈类定义,它实现了选项1和选项3:重载了pop(),使用一个局部引用去存储弹出值,并返回一个std::shared_ptr<>对象。它有一个简单的接口,只有两个函数:push()和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;

public:
  threadsafe_stack()
    : data(std::stack<T>()){}

  threadsafe_stack(const threadsafe_stack& other)
  {
    std::lock_guard<std::mutex> lock(other.m);
    data = other.data; // 1 在构造函数体中的执行拷贝
  }

  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(); // 在调用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);
    return data.empty();
  }
};

3.2.2 锁的粒度

锁的粒度表明了锁内代码量,数据量的多少。锁的粒度越小,则锁住的内容越少,性能越好;
但如果锁的粒度过小,则不能避免条件竞争,使得结果不可靠;
因此,对于有多个临界量需要保护时,引入多个互斥量和锁。
当具有两个及其以上的锁时,则有可能出现死锁问题。

3.2.3 死锁:问题描述及解决方案(std::lock()函数)

  • 一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。
  • 避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。
  • 但若是下次调用时,调用顺序发生了变化,又会死锁。
  • 因此可采用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 代表已上锁
    std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock); // 使用adopt_lock不重复上锁
    swap(lhs.some_detail,rhs.some_detail);
  }
};

基本思想:

  • 避免嵌套锁
    一个线程已获得一个锁时,再别去获取第二个。如果能坚持这个建议,因为每个线程只持有一个锁,锁上就不会产生死锁。
    当你需要获取多个锁,使用一个std::lock来做这件事(对获取锁的操作上锁),避免产生死锁。

  • 避免在持有锁时调用用户提供的代码
    因为代码是用户提供的,你没有办法确定用户要做什么;用户程序可能做任何事情,包括获取锁。你在持有锁的情况下,调用用户提供的代码;如果用户代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时,这是无法避免的)。

  • 使用固定顺序获取锁
    当硬性条件要求你获取两个以上(包括两个)的锁,并且不能使用std::lock单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们获取它们(锁)。
    例子:
    双向连边删除节点,需要获得三个锁:删除节点,该节点previous node和next node。若两个线程,一个从头开始,一个从尾开始遍历,则会产生死锁。

  • 使用锁的层次结构

  1. 对你的应用进行分层,并且识别在给定层上所有可上锁的互斥量。当代码试图对一个互斥量上锁,在该层锁已被低层持有时,上锁是不允许的。你可以在运行时对其进行检查,通过分配层数到每个互斥量上,以及记录被每个线程上锁的互斥量
  2. 在层级互斥量上产生死锁,是不可能的,因为互斥量本身会严格遵循约定顺序,进行上锁。这也意味,当多个互斥量在是在同一级上时,不能同时持有多个锁
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类型的变量,生命周期与线程相同
  // 使用 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())  // 尝试加锁
      return false;
    update_hierarchy_value();
    return true;
  }
};
thread_local unsigned long
     hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);  
     // 设置初始值为ULONG_MAX,大于所有层级

3.2.3 std::unique_lock——灵活的锁

  • 可以实现随时释放锁(使用std::unlock()来实现),而不是如std::lock_guard一般析构时才释放锁
  • 可将std::adopt_lock作为第二个参数传入构造函数,对互斥量进行管理,表明该互斥量已经上锁了。
  • 将std::defer_lock作为第二个参数传递进去,表明互斥量应保持解锁状态(不对互斥量上锁)
  • 若无第二参数,则默认当下对互斥量上锁

3.3 保护共享数据的替代设施

3.3.1 保护共享数据的初始化过程

  • 延迟初始化(Lazy initialization)在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是否被初始化,然后在其使用前决定,数据是否需要初始化;
  • 由此可以减小锁的粒度,使得上锁范围变小(只有当需要初始化的时候才进行上锁)
  • 例子:
std::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();
}
  • 类似的还有使用双重判断来实现此功能:
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();  
}
  • 该中情况存在潜在的条件竞争,即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调用do_something()后,得到不正确的结果。——数据竞争
  • 比起锁住互斥量,并显式的检查指针,每个线程只需要使用std::call_once,在std::call_once的结束时,就能安全的知道指针已经被其他的线程初始化了。使用std::call_once比显式使用互斥量消耗的资源更少,特别是当初始化完成后。
补充:关于std::call_once:
  1. 某些场景下,我们需要代码只被执行一次,比如单例类的初始化,考虑到多线程安全,需要进行加锁控制。
  2. 调用格式:void call_once (once_flag& flag, Fn&& fn, Args&&…args);
    第一个参数是std::once_flag的对象(once_flag是不允许修改的,其拷贝构造函数和operator=函数都声明为delete),第二个参数可调用实体,即要求只执行一次的代码,后面可变参数是其参数列表。
  3. call_once保证函数fn只被执行一次,如果有多个线程同时执行函数fn调用,则只有一个活动线程(active call)会执行函数,其他的线程在这个线程执行返回之前会处于”passive execution”(被动执行状态)——不会直接返回,直到活动线程对fn调用结束才返回。对于所有调用函数fn的并发线程,数据可见性都是同步的(一致的)。
  4. 如果活动线程在执行fn时抛出异常,则会从处于”passive execution”状态的线程中挑一个线程成为活动线程继续执行fn;一旦活动线程返回,所有”passive execution”状态的线程也返回,不会成为活动线程。
  5. once_flag的生命周期,它必须要比使用它的线程的生命周期要长。所以通常定义成全局变量比较好。

例子:

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;  // 定义once_flag

void init_resource()
{
  resource_ptr.reset(new some_resource);
}

void foo()
{
  std::call_once(resource_flag,init_resource);  // 可以完整的进行一次初始化
  resource_ptr->do_something();
}
  • 还有一种情形的初始化过程中潜存着条件竞争:其中一个局部变量被声明为static类型。这种变量的在声明后就已经完成初始化;对于多线程调用的函数,这就意味着这里有条件竞争——抢着去定义这个变量。
  • 在多线程中,每个线程都认为他们是第一个初始化这个变量线程
  • 解决:初始化及定义完全在一个线程中发生,并且没有其他线程可在初始化完成前对其进行处理(利用call_once 和 once_flag),条件竞争终止于初始化阶段

  • 这种方式一般适用于只读模式,仅在初始化的时候上锁后续支持多个线程同时操作

3.3.2 保护很少更新的数据结构

  • 当且仅当数据更新的时候需要锁,只有一个线程访问;其余时候可以多个线程同时访问;
  • 使用boost::shared_mutex来做同步, 使用std::lock_guard< boost::shared_mutex>和std::unique_lock< boost::shared_mutex>上锁
  • 保证更新线程的独占访问, 因为其他线程不需要去修改数据结构,所以其可以使用boost::shared_lock< boost::shared_mutex>获取访问权。
  • 唯一的限制:当任一线程拥有一个共享锁时,这个线程就会尝试获取一个独占锁,直到其他线程放弃他们的锁;同样的,当任一线程拥有一个独占锁时,其他线程就无法获得共享锁或独占锁,直到第一个线程放弃其拥有的锁。
例子(DNS)

利用域名找到IP地址,查询DNS表;DNS表每隔一段时间可能会更新一次;

#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
  {
    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)
  {
    std::lock_guard<boost::shared_mutex> lk(entry_mutex);  
    // 独占锁,阻止其他线程对数据结构进行修改,并且阻止线程调用find_entry()
    entries[domain]=dns_details;
  }
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值