2024.4.1记——C++多线程系列文章(四)之互斥

本文探讨了C++多线程编程中线程间共享数据的问题,重点介绍了条件竞争的概念以及互斥、无锁编程、事务内存等策略。通过std::mutex和std::lock_guard的使用,展示了如何保护共享数据并避免死锁等问题。
摘要由CSDN通过智能技术生成

线程间共享数据问题

多线程共享数据的问题多由数据改动引发。如果所有共享数据都是只读数据,就不会有问题。因为,若数据被某个线程读取,无论是否存在其他线程也在读取,该数据都不会受到影响。然而,如果多个线程共享数据,只要一个线程开始改动数据,就会带来很多隐患,产生麻烦。鉴于此,我们必须小心、谨慎,保证一切都正确运作。

为了帮助我们分析代码,“不变量”(invariant)被广泛使用,它是一个针对某一特定数据的断言,该断言总是成立的,例如“这个变量的值即为链表元素的数目”。数据更新往往会破坏这些不变量,尤其是涉及的数据结构稍微复杂,或者数据更新需要改动的值不止一个时。

条件竞争

设想你去看某明星演唱会。一般来说,演唱会门票可以在很多平台购买,假如你在购票的同时。别人也在购买,供选择的座位取决于你们谁先下单。如果只剩少量座位,事实上就形成了竞争,谁能买到最后几张票,下单的先后顺序至关重要。以上事例即为条件竞争:你得到什么座位(甚至是否能买到票),取决于购票的相对次序。

在并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争。很多时候,这是良性行为,因为全部可能的结果都可以接受,即便线程变换了相对次序。例如,两个线程向队列添加数据项以待处理,只要维持住系统的不变量,先添加哪个数据项通常并不重要。当条件竞争导致不变量被破坏时,才会产生问题。当论及并发时,条件竞争通常特指恶性条件竞争。

我们有几种方法防止恶性条件竞争。

  1. 互斥——最简单的就是采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。在其他访问同一数据结构的线程的视角中,这种改动要么尚未开始,要么已经完成。
  2. 无锁编程——修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。这通常被称为无锁编程,难以正确编写。
  3. 还有一种防止恶性条件竞争的方法,将修改数据结构当作事务(transaction)来处理,类似于数据库在一个事务内完成更新:把需要执行的数据读写操作视为一个完整序列,先用事务日志存储记录,再把序列当成单一步骤提交运行。若别的线程改动了数据而令提交无法完整执行,则事务重新开始。这称为软件事务内存(Software Transactional Memory,STM)。但是,因为C++没有直接支持STM,(C++标准委员会已公布了一份技术规约,内容正是软件事务内存的C++扩展)。

互斥

假定有一个用于共享的数据结构,我们要保护它,避免条件竞争,并防止不变量被坡坏。如果我们能标记访问该数据结构的所有代码,令各线程在其上相互排斥(mutually exclusive),那么,只要有线程正在运行标记的代码,任何别的线程意图访问同一份数据,则必须等待,直到该线程完事,这岂不妙哉?如此一来,除了正在改动数据的线程自身,任何线程都无法看见不变量被破坏。互斥(mutual exclusion,略作mutex)就能达到我们想要的效果。访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁互斥。

C++线程库保证了,一旦有线程锁住了某个互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。

互斥是C++最通用的共享数据保护措施之一,但非万能的灵丹妙药。我们务必妥善地组织和编排代码,从而正确地保护共享数据,同时避免接口固有的条件竞争。互斥本身也有问题,表现形式是死锁、对数据的过保护或欠保护,这将会在后面的文章中一一介绍。

在C++中,我们通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁,如下代码。但尽量不要使用该方式。原因是,若按此处理,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。

#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list;    // ⇽---  ①
std::mutex some_mutex;    // ⇽---  ②
void add_to_list(int new_value) {
    some_mutex.lock();    // ⇽---  ③
    some_list.push_back(new_value);
    some_mutex.unlock(); // ⇽--- ④
}
bool list_contains(int value_to_find) {
    some_mutex.lock();     // ⇽---  ⑤
    bool find = std::find(some_list.begin(),some_list.end(),value_to_find)
        != some_list.end();
    some_mutex.unlock(); // ⇽---  ⑥
    return find;
}

上述代码中有一个独立的全局变量①,由对应的std::mutex实例保护(另一个全局变量)②。函数add_to_list()③④和函数list_contains()⑤⑥内部都使用了调用了some_mutex的lock和unlock函数,以保证其中一个线程在改或者读取链表的时候,另外一个对其不可见。

取而代之,C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁.如下代码展示了如何用std::mutex类和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();
}

上述代码中有一个独立的全局变量①,由对应的std::mutex实例保护(另一个全局变量)②。函数add_to_list()③和函数list_contains()④内部都使用了std::lock_guard< std::mutex>,其意义是使这两个函数对链表的访问互斥:假定add_to_list()正在改动链表,若操作进行到一半,则list_contains()绝对看不见该状态。

尽管有些场景适合模仿上述例子使用全局变量,不过大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增强保护。

如果成员函数返回指针或引用,指向受保护的共享数据,那么即便成员函数全都按良好、有序的方式锁定互斥,仍然无济于事,因为保护已被打破,出现了大漏洞。只要存在任何能访问该指针和引用的代码,它就可以访问受保护的共享数据(也可以修改),而无须锁定互斥。所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行锁定,再对受保护的共享数据进行访问,并保证不留后门。

如下例子,意外地向外传递引用,指向受保护共享数据

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();   // ⇽---  ③以无保护方式访问本应受保护的共享数据
}

在上述例子中,process_data()函数内的代码显然没有问题,由std::lock_guard很好地保护,然而,其参数func是使用者提供的函数①。换言之,foo()中的代码可以传入malicious_function②,接着,就能绕过保护,调用do_something(),而没有锁住互斥③。

本质上,以上代码的问题在于,它未能真正实现我们原有的设想:把所有访问共享数据的代码都标记成互斥。上例中,我们遗漏了foo(),调用unprotected->do_something()的代码未被标记。无奈,C++线程库对这个问题无能为力;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。从乐观的角度看,只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。

前面文章

2024.3.27记——C++多线程系列文章(一)
2024.3.28记——C++多线程系列文章(二)之向线程函数传递参数
2024.3.29记——C++多线程系列文章(三)线程归属权转移及线程识别

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值