线程间共享数据


并发的优势在于可以简单直接的共享数据,共享数据需要遵循一定规则,否则并发带来的不再是优势。

竞争

  • 在并发编程中,由多个线程完成的操作,线程争先完成各自操作,而结果取决于相对的次序。这个时候就出现了条件竞争,大多时候,结果都可以接受,即便线程执行顺序不同,这些竞争属于良性的。
  • 而在特定情况下,比如在一份数据完成改动时,被别的线程访问,这个时候可能导致为止的结果,这种就是我们不希望出现的恶性竞争。

避免竞争

避免竞争的常用方法有三种。

  1. 竞争问题是在共享数据修改时出现的,最直接的办法就是保证数据的变动时只对正在执行修改操作的线程可见,其他线程只能获得修改前或者修改后的状态。
  2. 需要深入研究内存模型的微妙细节,将修改变为一连串不可拆分的改动,通常成为无锁编程。
  3. 数据修改当做事务来处理。把需要的操作作为一个序列,再把序列当做单一步骤提交运行。如果别的线程改动导致无法完整执行则重新开始。

使用互斥保护共享数据

如何使用互斥

  • c++中我们通过std::mutex来创建互斥,调用lock()加锁,unlock()解锁。
  • 因为异常导致退出的问题,忘记unlock()。所以通常配合std::lock_guard<>使用, 融合RAII手法。
std::mutex some_mutex
std::lock_guard<std::mutex> guard(some_mutex);

组织代码以保护共享数据

  • 大多场景下,我们把互斥与受保护对象组成一个类。这样可以清楚表明他们的关系,同时封装函数增强保护。
  • 不得向锁的作用域之外传递受保护对象的指针或引用,无论是作为成员函数的返回或者其他函数的参数。

接口中固有的竞争

// 定义
template<typename T, typename Container=std::deque<T>>
{
    public:
        bool empty() const;
        size_t size() const;
        T& top();
        T const& top();
        void push(T const&);
        void push(T&&);
        void pop();
};
//使用
stack<int> s;
if (!s.empty())
{
    int const value = s.top();
    s.pop();
    do_somethin(value);
}

考虑这样的结构,与std::stack类似, 我们提供了操作。empty()判断是否非空,size()返回长度, top()读取栈定元素,push()压入,pop()弹出。如果我们想在多线程中使用这个结构作为共享数据,遵循组织代码的原则,top()应该返回元素的副本而不是引用,同时应该使用互斥来保护内部数据。

empty与top之间的竞争

尽管如此,仍然会存在问题,在下面的例子在单线程环境下是没有问题的,但是多线程环境
中empty()的返回结果是不可信的。在empty()和top()之间,其他线程可能修改队列,如果是弹出元素,当前线程top取得的结果是未定义的。考虑解决问题可能方式,对于empty()和top()之间的竞争,可以让top()在栈为空时抛出异常, 但是这样在调用top()时需要捕获异常,同时empty()的校验只能作为一个预防异常的优化。

top与pop之间的竞争

再观察样例empty()到top()之间,同样还有top()和pop()之间。可能存在两个线程top()获得同一个对象,但是pop()两次。这个问题,可以吧top()和pop()合并成一个接口解决,但是这会引入一个新的问题。在拷贝复制时,需要分配更多内存,分配内存是可能失败的,尤其对于容器对象。如果先取出栈顶的元素在拷贝给副本。在弹出的元素已经移除但是拷贝失败就会造成数据丢失。为了数据安全所以分为了top()和pop()两个接口,先获取拷贝再删除。

解决办法

首先我们要解决接口竞争,要简化接口。接下来就是考虑pop如何设计,返回弹出元素,并且避免在pop过程中因为分配内存而导致的异常。

  1. 传入引用接受pop()弹出元素。大多情况线下安全,但是要求存储的对象支持赋值。
  2. 假设仍然是返回值,提供不抛出异常的拷贝构造,或者移动构造函数。但是现有大多是不支持的。
  3. 返回指针,指向被抛出的元素。智能指针可以避免内存泄漏的问题。
  4. 结合1、2或者1、3

线程安全的stack

struct empty_stack: std::exception
{
    const char* what() const noexcept;
}

template<typename T>
class threadsafe_stack
{
    public:
        threadsafe_stack()
        {}
        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(std::move(new_value));
        }
        std::shared_ptr<T> pop()
        {
            std::lock_guard(std::mutex) lock(m);
            if (data.empty()) throw empty_stack();
            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();
        }
    private:
        std::stack<T> data;
        mutable std::mutrex m;
};

死锁

假设有两个线程,需要两个互斥锁才能完成功能。如果他们分别持有一个锁,等待再给另一个加锁,这时就会造成死锁。

解决

通常来说,如果始终按照相同顺序加锁,就可以避免,但是也有棘手的情况。比如swap函数,交换两个变量。对两个变量加锁取决于变量传入的位置。
例如 线程一调用swap(a,b)线程二调用swap(b,a)。
c++ 标准库提供了std::lock()函数来解决这个问题, 示例如下

class X
{
    private:
        some_big_object some_detail;
        std::mutex x;
    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);
            std::lock_guard<std::mutex> lock_b(rhs.m, std::adopt_lock);
            swap(lhs.some_detail, rhs.some_detail);
        }
}
  • 最开始一定要判断是否lhs与rhs是否是同一个,对于已经上锁的互斥再次上锁是未定义的行为。
  • std::adopt_lock是一个标记,表示互斥量已经上锁,不需要在std::lock_guard中再次上锁。
  • std::lock()内部对于lhs和rhs加锁时可能抛出异常,但是他保证了all-or-nothing。获得互斥量返回或者没有获得任何互斥量抛出异常,不会存在只获得部分互斥量返回的情况。

如何避免死锁

死锁并不只发生在锁操作中,只要有一个线程等待当前线程完成某项工作,那么当前线程就绝对不能再反过来去等待它。下边一些准则可以帮助我们避免。

  1. 避免嵌套锁。如果已经持有一个锁,就不要尝试再获得另一个,有需要获得多个锁时,用std::lock一次性获得。
  2. 一点持有锁,避免调用用户提供的接口。属于上一条延伸,因为无法确定用户接口会做什么事情。
  3. 如果多个锁时必要的,又无法一次性获得,那么每个线程内部需要按照固定顺序获得锁。例如链表的遍历,必须同一个方向。
  4. 按照层级加锁,就是规定加锁顺序,可以在运行时检查是否遵循了预设的规则.

其他共享数据的情况

初始化过程中保护共享数据

有一种常见的情形,共享数据在初始化过程中受到保护,之后就无需同步操作。因为创建成功就只处于只读状态。
在创建数据时,一个常见做法是延迟初始化,只在真正使用时才开始初始化。例如:

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

上述代码转化为多线程形式,则仅有初始化过程需要保护。

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

修改后的代码很明显有个问题,在初始化之后,他迫使多个线程排队检查是否已经初始化。一个优化方案就是双指针检查锁.

std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void undefined_behavior_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();
}

首先判空,来决定是否初始化,在加锁后再判空,来判断别的线程是否在加锁间隙完成了初始化。但是这个方案也是存在风险。假设一个线程来到第一次判空,然后其中一个获得锁,执行初始化,但是因为指令重排,先将指针指向了一块内存,但还没进行初始化,这时第二个线程运行到第一次判空,直接执行do_something,发生空指针的问题。

为了解决上述问题,C++标准库提供了std::once_flag类和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();
}

保护更少更新的数据

一些数据,大部分时间不会变化,偶尔进行更新,比如DNS缓存,大部分时间可能几年不会发生变化。我们希望有一种数据结构,在更新操作期间,锁定并发访问,直到更新完成。使用std::mutex保护则过于严苛,因为即使没有更新,也会阻塞并发访问。我们需要一个新的互斥量,允许单独的写线程进行完全排他的访问,多个读线程共享数据。
c++14中提供了std::shared_timed_mutex, 而c++17中提供了std::shared_mutex和std::shared_timed_mutex。

  • 写操作锁定, 排它锁
std::lock_guard<std::shared_mutex>
std::unique_lock<std::shared_mutex>
  • 读操作锁定, 共享锁
std::shared_lock<std::shared_mutex>

多个线程可以锁定一个共享锁,排他锁需要所有共享锁释放后才能锁定。
排它锁锁定之后,其他线程就无法再获得对应锁,直到释放。

递归加锁

某个线程已经斥候std::mutex锁时,再次试图加锁就会出错。标准库提供了std::recursive_mutex,工作方式与std::mutex相似,但是允许统一线程对同一个互斥实例反复加锁。其他线程想要持有需要当前线程解开全部锁之后才可以。
递归锁大多时候并不是必须的,可以通过改良设计来规避。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值