C++并发编程(三):线程间共享数据

本文介绍了C++并发编程中如何避免恶性条件竞争,使用互斥量保护共享数据,以及如何处理死锁。讲解了互斥量的使用、层次锁的概念和unique_lock的优势,并给出了特殊情况下的数据保护策略,如初始化时的数据保护和读者-写者锁的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

避免恶性条件竞争


双向链表具有前后两个链,当一个线程修改一个双向链表的前链还未来及修改后链时,另一个线程进行了数据访问,此时就会发生恶性条件竞争。多线程编程中,我们总会遇到多个线程对一个数据块进行修改,所以需要避免恶性条件竞争。避免恶性条件竞争有三个主要方法。

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来支持这种多次嵌套加锁。

但是这种做法是不推荐。我们可以把调用函数作为私有对象放入类中,去除加锁操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值