Kevin

游戏开发闲谈

导航

3-线程间共享数据

本章主要内容

  • 共享数据带来的问题
  • 使用互斥量保护数据
  • 数据保护的替代方案

3.1 共享数据带来的问题

不变量(invariants)的概念对程序员们编写的程序会有一定的帮助——对于特殊结构体的描述;比如,“变量包含列表中的项数”。不变量通常会在一次更新中被破坏,特别是比较复杂的数据结构,或者一次更新就要改动很大的数据结构

3.1.1 条件竞争

恶性条件竞争通常发生于完成对多于一个的数据块的修改时

3.1.2 避免恶性条件竞争

这里提供一些方法来解决恶性条件竞争

  1. 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始
  2. 对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程
  3. 使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”

保护共享数据结构的最基本的方式,是使用C++标准库提供的互斥量

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

互斥量是 C++ 中一种最通用的数据保护机制,但它不是“银弹”;精心组织代码来保护正确的数据(见3.2.2节),并在接口内部避免竞争条件(见3.2.3节)是非常重要的。但互斥量自身也有问题,也会造成死锁(见3.2.4节),或是对数据保护的太多(或太少)(见3.2.8节)。

3.2.1 C++中使用互斥量

C++标准库为互斥量提供了一个RAII
语法的模板类 std::lock_guard ,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁

#include <list>
#include <mutex>
#include <algorithm>

std::list<int> some_list; // 1
std::mutex some_mutex; // 2

void add_to_list(int new_value)
{
 std::lock_guard<std::mutex> guard(some_mutex); // 3
 some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
 std::lock_guard<std::mutex> guard(some_mutex); // 4
 return
  std::find(some_list.begin(),some_list.end(),value_to_find) !=
   some_list.end();
}

3.2.2 精心组织代码来保护共享数据

切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数中去。

3.2.3 发现接口内在的条件竞争

stack<int> s;
if (! s.empty()){ // 1
    int const value = s.top(); // 2
    s.pop(); // 3
    do_something(value);
}

上面代码在单线程来看没什么问题,但是在多线程来说,就不安全,因为在调用empty()和调用top()之间有可能来自另外一个线程的pop()嗲用并删除了最后一个元素.这是一个经典的条件竞争,使用互斥量对内部数据进行保护,依旧不能阻止条件竞争的发生,合适接口固有的问题.
还有另外一个问题

ThreadA ThreadB
if(!s.empty())
if(!s.empty())
int const value = s.top();
int const value = s.top();
s.pop()
do_somthing(value); s.pop();
do_somthing(value);
当线程运行时,调用两次top(),栈没被修改,所以每个线程能得到同样的值。不仅是这样,在调用top()函数调用的过程中(两次),pop()函数都没有被调用。这样,在其中一个值再读取的时候,虽然不会出现“写后读”的情况,但其值已被处理了两次。这种条件竞争,比未定义的empty()/top()竞争更加严重;虽然其结果依赖于do_something()的结果,但因为看起来没有任何错误,就会让这个Bug很难定位.
解决上述问题的办法就是 使用同一互斥量来保护top()pop()
清单3.5扩充线程安全堆栈
#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.4 死锁:问题描述及解决方案

描述

试想有一个玩具,这个玩具由两部分组成,必须拿到这两个部分,才能够玩。例如,一个玩具鼓,需要一个鼓锤和一个鼓才能玩。现在有两个小孩,他们都很喜欢玩这个玩具。当其中一个孩子拿到了鼓和鼓锤时,那就可以尽情的玩耍了。当另一孩子想要玩,他就得等待另一孩子玩完才行。再试想,鼓和鼓锤被放在不同的玩具箱里,并且两个孩子在同一时间里都想要去敲鼓。之后,他们就去玩具箱里面找这个鼓。其中一个找到了鼓,并且另外一个找到了鼓锤。现在问题就来了,除非其中一个孩子决定让另一个先玩,他可以把自己的那部分给另外一个孩子;但当他们都紧握着自己所有的部分而不给予,那么这个鼓谁都没法玩。

现在没有孩子去争抢玩具,但线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。

解决方案

避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁
std::lock ——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)
清单3.6 交换操作中使用 std::lock()std::lock_guard

// 这里的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); // 1
  std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock);  // 2
  std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);  // 3
  swap(lhs.some_detail,rhs.some_detail);
 }
};

3.2.5 避免死锁的进阶指导

  1. 避免嵌套锁
  2. 避免在持有锁时调用用户提供的代码
  3. 使用固定顺序获取锁
  4. 使用锁的层次结构
hierarchical_mutex high_level_mutex(10000); // 1 层级值为10000
hierarchical_mutex low_level_mutex(5000); // 2 层级值为5000

int do_low_level_stuff();

int low_level_func()
{
 std::lock_guard<hierarchical_mutex> lk(low_level_mutex); // 3 低等级的层级
 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); // 4 高等级的层级
 high_level_stuff(low_level_func()); // 5 之后执行低等级的层级 这是符合规则的
}

void thread_a() // 6
{
 high_level_func();
}

hierarchical_mutex other_mutex(100); // 7 层级值为100

void do_other_stuff();

void other_stuff()
{
 high_level_func(); // 8 高等级的,由于之前执行的是100层级的,所以不符合规则 可能会抛出异常
 do_other_stuff();
}

void thread_b() // 9
{
 std::lock_guard<hierarchical_mutex> lk(other_mutex); // 10 最低等级的层级
 other_stuff(); // 执行其他
}

列表3.8 简单的层级互斥量实现

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; // 1 使用了thread_local的值来代表当前线程的层级值 因为其声明中有thread_local,所以每个线程都有其拷贝副本

 void check_for_hierarchy_violation()
 {
  if(this_thread_hierarchy_value <= hierarchy_value) // 2 第一次检查 this_thread_hierarchy_value 被初始化成最大值,所以通过
  {
   throw std::logic_error(“mutex hierarchy violated”);
  }
 }

 void update_hierarchy_value()
 {
  previous_hierarchy_value=this_thread_hierarchy_value; // 3
  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(); // 4 代表内部互斥锁已被锁住
  update_hierarchy_value(); // 5
 }

 void unlock()
 {
  this_thread_hierarchy_value=previous_hierarchy_value; // 6
  internal_mutex.unlock();
 }

 bool try_lock()
 {
  check_for_hierarchy_violation();
  if(!internal_mutex.try_lock()) // 7
   return false;

  update_hierarchy_value();
  return true;
 }
};

thread_local unsigned long hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX); // 8 它被初始化为最大值 所以最初所有线程都能被锁住

3.2.6 std::unique_lock——灵活的锁

3.2.7 不同域中互斥量所有权的传递

3.2.8 锁的粒度

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

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

std::once_flagstd::call_once

C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了 std::once_flagstd::call_once 来处理这种情况。比起锁住互斥量,并显式的检查指针,每个线程只需要使用 std::call_once ,在 std::call_once 的结束时,就能安全的知道指针已经被其他的线程初始化了。使用 std::call_once 比显式使用互斥量消耗的资源更少,特别是当初始化完成后。下面的例子展示了与清单3.11中的同样的操作,这里使用了 std::call_once 。在这种情况下,初始化通过调用函数完成,同样这样操作使用类中的函数操作符来实现同样很简单。如同大多数在标准库中的函数一样,或作为函数被调用,或作为参数被传递, std::call_once 可以和任何函数或可调用对象一起使用。

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; // 1

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

void foo()
{
 std::call_once(resource_flag,init_resource); // 可以完整的进行一 次初始化
 resource_ptr->do_something();
}

清单3.12 使用 std::call_once 作为类成员的延迟初始化(线程安全)

class X
{
private:
 connection_info connection_details;
 connection_handle connection;
 std::once_flag connection_init_flag;

 void open_connection()
 {
  connection=connection_manager.open(connection_details);
 }

public:
 X(connection_info const& connection_details_):
  connection_details(connection_details_)
 {}

 void send_data(data_packet const& data) // 1
 {
  std::call_once(connection_init_flag,&X::open_connection,this); // 2
  connection.send_data(data);
 }

 data_packet receive_data() // 3
 {
  std::call_once(connection_init_flag,&X::open_connection,this); // 2
  return connection.receive_data();
 }
};

std::call_once 的替代方案

class my_class;
my_class& get_my_class_instance()
{
    static my_class instance; // 线程安全的初始化过程
    return instance;
}

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

boost::shared_lock

3.3.3 嵌套锁

当一个线程已经获取一个 std::mutex 时(已经上锁),并对其再次上锁,这个操作就是错误的,并且继续尝试这样做的话,就会产生未定义行为
std::recursive_mutex 其功能与 std::mutex 类似,除了你可以从同一线程的单个实例上获取多个锁。互斥量锁住其他线程前,你必须释放你拥有的所有锁,所以当你调用lock()三次时,你也必须调用unlock()三次

posted on 2018-06-07 16:02  iamkevin  阅读(201)  评论(0编辑  收藏  举报