03 重修C++之并发实战3.1-3.2

上一篇:03 重修C++之并发实战1-2

03 重修C++之并发实战3.1-3.2

3.1 线程之间共享数据的问题

从整体上来看,所有线程之间共享数据的问题,都是修改数据导致的。如果所有共享数据都是只读的,就没有问题。但是如果数据是在线程中共享的,同时一个或多个线程开始修改数据,就可能有很多麻烦。一个被广泛帮助程序员推导代码的概念,就是不变量(invariants)——对特定的数据结构总是为真的语句,例如:“此变量包含了列表中项目的数量。”这些不变量经常在更新中被打破,尤其是在数据结构比较复杂或是更新需要修改超过一个值时。

考虑一个双向链表,它的每一个节点持有指向表中下一个节点和上一个节点的指针。其中一个不变量就是如果你跟从一个节点(A)到另一个节点(B)的“下一个”指针,则那个节点(B)的“前一个”指针指回到前一个结点(A)。为了从表中删除一个节点,两边的节点都必须更新为彼此指向。一旦其中一个被更新,直到另一侧的节点也被更新前不变量时打破的,当更新完成后,再次持有不变量。【我的简单理解就是,可能出现多个线程(进程)同时读写或改变数据结构的时候就需要对共享资源进行管理。】

修改线程之间共享数据的最简单的潜在问题就是破坏不变量。不变量损坏的后果可能有所不同,例如对于双向链表,结构损坏可能导致由左到右读取链表时会跳过被删除的节点或在被删节点的前一个节点处停止。如果又有一个线程尝试删除被删节点两侧的节点,将会对数据结构造成永久性破坏,并使得程序崩溃。

3.2 竞争条件

3.2.1 避免有问题的竞争条件

有几种方法来处理有问题的竞争条件。最简单的选择是用保护机制封装数据结构,以确保只有实际执行修改的线程能够在不变量损坏的地方看到中间数据。从其他访问该数据结构线程的角度看,这种修改要么没开始要么已完成。另外一个选择是修改数据结构的设计及其不变量,从而令修改作为一系列不可分割的变更来完成,每个修改均保留其不变量。这通常被称为无锁编程,且难以尽善尽美。如果内存模型的细微差异和确认哪些线程可能看到哪组值,会变得很复杂。

处理竞争条件的另一种方式是将对数据结构的更新作为一个事务处理,就如同在一个事务内完成数据库的更新一样,所需要的一系列数据修改和读取被存储在一个事务日志中,然后在单个步骤中进行提交,如果该提交因为数据结构已被另一个线程修改,该事务将重新启动。这称为软件事务内存(STM)。

在C++标准提供的保护共享数据的最基本机制是互斥元(mutex)

3.2.2 用互斥元保护共享数据

互斥元也就是互斥锁,这里假定大家对锁都有基本了解。C++中,通过构造std::mutex的实例创建互斥元,调用成员函数lock()来锁定它,调用成员函数unlock()来解除锁定,然而直接调用成员函数是不推荐的做法,因为这意味着必须记住离开函数的每一条代码路径上都调用unlock(),包括由于异常导致的在内。作为替代,C++标准库提供了std::lock_gurad类模板,实现了互斥元的RAII惯用语法;它在构造时锁定所给的互斥元,在析构时将互斥元解锁,从而始终保证锁定的互斥元被正确解锁。下面代码展示了如何使用std::mutex保护一个了被多线程访问的列表。

#include <iostream>
#include <thread>
#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> gurad(some_mutex);
    some_list.push_back(new_value);
}

void list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);
    if (std::find(some_list.begin(), some_list.end(), value_to_find)
        != some_list.end())
    {
        std::cout << "have found value = " << value_to_find << std::endl;
    }
    else
    {
        std::cout << "have not found value = " << value_to_find << std::endl;
    }
}

int main(int argc, const char** argv) {
    std::thread t1(add_to_list, 5);
    std::thread t2(add_to_list, 7);
    std::thread t3(list_contains, 5);
    std::thread t4(list_contains, 8);
    std::thread t5(add_to_list, 8);
    std::thread t6(add_to_list, 9);

    t1.join(); t2.join(); t3.join(); 
    t4.join(); t5.join(); t6.join();

    for (auto it = some_list.begin(); it != some_list.end(); it++)
        std::cout << *it << std::endl;
    
    return 0;
}

上述代码只是一个演示,正常是要把需要保护的数据以及互斥锁封装到一个类中并声明为私有成员,并提供统一的访问和修改接口,以保证在所有线程访问被保护的数据之前都要拿到锁,且不留后门。但是即使这样做有时也难免出现漏洞,下面是一个错误示例:

#include <iostream>
#include <thread>
#include <string>
#include <mutex>

class some_data
{
    int a;
    std::string b;
public:
    void do_something()
    {
        std::cout << "do_something()" << std::endl;
    }
};

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

int main(int argc, const char** argv) {
    data_wrapper x;
    //利用恶意函数获取私有成员的指针
    x.process_data(malicious_function);
    //绕过锁调用私有成员的方法/成员
    unprotected->do_something();

    return 0;
}

正如上述代码所示,除了检查成员函数本身没有向其调用者传出指针和引用,检查它们没有向其调用的不在当前开发者掌控之下的函数传入这种指针和引用,也是非常重要的。例如上面的例子,恶意函数通过“合法”的手段获取到的data的地址并将指针带了出来,将来这个指针就可以绕过锁的保护直接对data进行操作。所以说,不要将对手保护数据的指针和引用传递到锁的范围之外,无论是通过函数返回它们、将其存放在外部可见的内存中,还是作为参数传递给用户提供的函数。 这是使用互斥锁保护共享数据的一个常见错误,但不是唯一错误,所以需要我们时刻警惕对共享数据的保护。

3.2.3 发现接口中固有的竞争条件

仅仅因为使用了互斥元或其他机制来保护共享数据,未必就会避免竞争条件,你仍然需要确定保护了适当的数据。再次考虑双向链表的例子。为了安全删除节点你需要确保已阻止对三个节点的并发访问(要删除的节点,以及其两边的节点)。如果你分别保护每个访问节点的指针,就不会比未使用互斥元的代码更好,因为竞争条件仍发生,需要保护的不是个别步骤中的个别节点,而是整个删除过程中的整个数据结构。这种情况下最简单的解决方法就是用单个互斥元保护整个表。

仅仅因为在列表上的个别操作是线程安全的,你还没有摆脱困境。你仍然会遇到竞争条件,即便是一个很简单的接口。考虑像std::stack容器适配器这样堆栈数据结构,除了构造函数和swap(),对std::stack你只有5件事可以做:

  • push()一个新元素入栈;
  • pop()一个元素出栈;
  • top()元素;
  • 检查它是否为空empty()
  • 读取栈的大小size()

如果更改top()使得它返回一个副本,而不是引用(但事实上返回的是一个引用),同时用互斥元保护内部数据,该接口依然固有地受制于竞争条件。这个问题对基于互斥元的实现并不是独一无二的,是一个接口问题,因此对于无锁实现仍然会发生竞争条件。这里的问题是empty()size()的结果并不可靠。虽然它们在被调用时是正确的,一旦它们返回,在调用了empty()size()的线程可以使用该信息之前,其他线程可以自由地访问堆栈,并可能进行push()pop()操作。特别的,std::stack实例是非共享的,如果栈非空,检查empty()并调用top()访问顶部元素是安全的。

std::stack<int> s;

if (!s.empty())
{
    int const value = s.top();
    s.pop();
} //单线程安全,多线程不安全

上述代码仅在单线程中是安全的,在空栈上调用top()是未定义行为。对于共享的stack对象,这个调用序列显然不再安全,因为在empty()top()的调用中间可能会有其它线程对栈的数据结构进行调整,例如删除了最后一个元素,这样调用top()时就会出错。这就是一个典型的竞争条件,为了保护栈的内容而在内部使用互斥锁,却未能将其阻止,这就是接口的影响。

怎么解决这个问题,发生这个问题是接口设计的后果。在最简单的情况下,你只要声明top()在调用的时候,如果栈中没有元素则引发异常。虽然这直接解决了问题,但是它使编程变得更麻烦。因为现在你得能捕捉异常,即使对empty()的调用返回false。这使得empty()调用变得多余。所以目前在C++的标准库中这种行为还是未定义的行为。

上面这些问题,要求对接口进行更激进的改变,在互斥元保护下结合对top()pop()两者调用,如果栈上对象的拷贝函数能够引发异常,结合调用可能会导致问题。从异常安全的观点考虑,这个问题被处理得比较全面,但是潜在的竞争条件给这个结合带来了新的问题。举个例子:一个stack<vector<int>>的栈。现在vector是一个动态大小的容器,所以当复制vector时,为了复制其内容,库就必须从堆中分配更多的内存。如果系统负载过重或有明显的资源约束,此次分配就可能失败,于是vector的拷贝构造函数就可能引发std::bad_alloc异常。如果vector中含有大量元素的话则尤其可能。如果pop()函数被定义为返回出栈值,并从栈中删除它,就会有潜在问题。

仅在栈被修改后,出栈值才会返回给调用者,但复制数据以返回给调用者的过程可能会引发异常。如果发生这种状况,刚从栈中出栈的数据会丢失,数据已经从栈中删除了,但是该复制却没有成功。所以stack接口的设计者笼统的将操作一分为二、获取顶部元素top()和将其从栈中删除pop(),为了保证在没有安全复制出栈顶元素时能够将栈顶元素保留在栈上。但是这种划分正是在消除竞争条件时要避免的,下面是几种有偿替代方案。

选项1:传入引用

第一个选项是把希望接受出栈值的变量的引用作为参数传递给pop()的调用。

/*********声明**********
template <typename T>
void pop(T& top);
************************/
std::vector<int> result;
some_stack.pop(result);

这在很多情况下都适用,但是这种方法有一个明显的缺点,要求在调用代码之前先构造一个该栈值类型的实例,以便将其作为目标传入。对于某些类型而言这是行不通的,因为构造一个实例在时间和资源方面是非常昂贵的。对于其他类型,这并不总是可能的,因为构造函数可能需要参数,而在代码的这个位置不一定可用。最后,这种方法要求所存储的类型是可赋值的。这是一个重要的限制。许多用户定义类型不支持赋值,尽管它们可能支持移动构造函数,或者甚至是拷贝构造函数(从而允许通过返回值来返回)。

选项2:要求不引发异常的拷贝构造函数或移动构造函数

对于有返回值的pop()而言只有一个异常安全问题,就是以值进行返回可能引发异常。许多类型具有不引发异常的拷贝构造函数,并且在C++标准中有了新的右值引用的支持,越来越多的类型将不会引发异常的移动构造函数,即便它们的拷贝构造函数会引发异常。一个有效的选择就是安全的使用线程堆栈,限制在能够安全地通过值来返回且不引发异常地类型之内。

但是在编译时检测一个不引发异常地拷贝或移动构造函数地存在是受一定限制的。相比于具有不引发异常地拷贝和移动构造函数的类型,更常见的是具有不引发异常地拷贝且没有移动构造函数的类型。如果这种类型不能被存储在线程的安全堆栈中,就无法解决这个问题。

选项3:返回指向出栈顶的指针

第三个选项是返回一个指向出栈项的指针,而不是值返回。优点是指针可以被自由地复制而不会引发异常;缺点是返回指针时需要一种手段来管理分配给对象地内存,对于像整数这样简单地类型,这种内存管理成本可能会超过仅通过值返回该类型。对于任何使用此选项的接口,std::shared_ptr会是指针类型的一个好的选择,它不仅避免了内存泄漏,因为一旦最后一个指针被销毁则该对象也会被销毁,并且库可以完全控制内存分配方案且不必使用new和delete。对于优化用途来说这是很重要的,要求使用new分别分配堆栈中的每一个对象,会比原来非线程安全的版本带来大得多的开销。

选项4:同时提供选项1以及2或3

灵活性永远不排除在外,特别是在同代码中。如果你选择选项2或3,那么同时提供选项1也是相对容易的,这也是为你的代码的用户提供了选择的权力,可供用户在他们觉得合适的方案中选择。

【2021.10.29】

下一篇:03 重修C++之并发实战3.3-3.4

3.3 一个线程安全堆栈的示范定义

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值