Part.3 重修C++之并发实战3

【在线程间共享数据】

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】

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

下面将使用上述的1、3选项实现一个线程安全的堆栈,其中pop()有两个重载,一个介绍存储该值的位置引用,另一个返回一个std::shared_ptr<>的指针,简单设计仅包含两个函数接口push()pop()

//File:threadsafe_stack.h
#ifndef _THREADSAFE_STACK_
#define _THREADSAFE_STACK_

#include <stack>
#include <mutex>
#include <exception>
#include <typeinfo>
#include <memory> //For std::shared_ptr<>

struct empty_stack: std::exception //异常函数
{
    const char* what() const throw(){return "This is a empty stack!\n";};
};

//模板类(模板类的实现一定要都放在头文件中,否则链接时不识别cpp中的实现)
//模板类需要在使用到的地方利用声明模板的typename或者class参数的时候,才会即时生成代码。
//那么当我把模板声明和实现分开的时候,这个即时过程因为编译器只能通过代码include“看到”
template<typename T> //头文件而找不到模板实现代码,所以会产生链接问题。
class threadsafe_stack 
{					   
private:              
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack &);
    threadsafe_stack &operator=(threadsafe_stack &&) = delete;
    threadsafe_stack &operator=(const threadsafe_stack &) = delete;
    virtual ~threadsafe_stack();

    void push(T new_value);
    void pop(T& value);
    std::shared_ptr<T> pop();
    bool empty() const;

};

template<typename T>
threadsafe_stack<T>::threadsafe_stack() { }

template<typename T>
threadsafe_stack<T>::threadsafe_stack(const threadsafe_stack &other)
{
    std::lock_guard<std::mutex> lock(other.m);
    data = other.data;
}

template<typename T>
threadsafe_stack<T>::~threadsafe_stack() { }

template<typename T>
void threadsafe_stack<T>::push(T new_value)
{
    std::lock_guard<std::mutex> lock(m);
    data.push(new_value);
}

template<typename T>
void threadsafe_stack<T>::pop(T& value)
{
    std::lock_guard<std::mutex> lock(m);
    if (data.empty()) throw empty_stack();
    value = data.top();
    data.pop();
}

template<typename T>
std::shared_ptr<T> threadsafe_stack<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;
}

template<typename T>
bool threadsafe_stack<T>::empty() const
{
    std::lock_guard<std::mutex> lock(m);
    return data.empty();
}

#endif // !_THREADSAFE_STACK_
复制代码

main_test.c

#include <iostream>
#include <thread>
#include <cstdlib>
#include <unistd.h>

#include "threadsafe_stack.h"

#define MAXNUM 20
int main(int argc, const char** argv) {
    threadsafe_stack<int> stack;
    bool state1 = true;
    bool state2 = true;

    std::thread add1([&]() //线程add1
    {
        for (int i = 0; i < MAXNUM / 2; i++)
        {
            stack.push(i);
            std::cout << "thread add1::push " << i << " in the stack.\n" << std::endl;
            sleep(1);
        }
        state1 = false;

    });
    
    std::thread add2([&]() //线程add2
    {
        for (int i = MAXNUM / 2; i < MAXNUM; i++)
        {
            stack.push(i);
            std::cout << "thread add2::push " << i << " in the stack.\n" << std::endl;
            sleep(1);
        }
        state2 = false;
    });

    std::thread del([&]() //线程del 包含两种方式
    {
        std::shared_ptr<int> p;
        int value;
        int i = 0;
        while (state1 || state2)
        {
            if (stack.empty()) continue;
            if (i++ / 2 == 1)
            {
                p = stack.pop();
                std::cout << "std::shared_ptr<int> p = " << *p << std::endl;
            }
            else
            {
                stack.pop(value);
                std::cout << "value is " << value << std::endl;
            }
        }
        
    });

    add1.join();
    add2.join();
    del.join();

    return 0;
}
复制代码

上述方法通过削减接口,考虑了最大安全性,甚至对整个堆栈的操作都受限。这里堆栈本身不能被赋值,因为删除了赋值运算符的操作,然而堆栈可以被复制。如果栈是空的,pop()将引发一个empty_stack异常,如果需要,std::shared_ptr的使用允许栈来处理内存分配问题同时避免对newdelete的过多使用。五个堆栈操作现在变成三个pop()push()empty(),甚至不需要empty(),接口的简化可以更好的控制数据,并且使用互斥锁保证整体操作是在锁定的情况下进行的。

上述的讨论表明,接口中有问题的竞争条件基本上因为锁定的粒度过小而引起的。保护没有覆盖期望操作的整体。当然互斥锁的锁定粒度过大也是不合适的,在一个有大量共享数据的系统中大粒度的互斥锁会大大削弱并发性能。但是细粒度的锁定方案有一个问题,就是有时为了保护操作中的所有数据,需要不止一个互斥锁。然而这是不合适的,因为互斥锁保护一个类的各个实例,在这种情况下,在下个级别进行锁定将意味着,要么将锁丢给用户,要么就让单个互斥锁保护该类的所有实例,这些都不是很理想。

3.4 死锁:问题和解决方案

3.4.1 简单死锁

如果对于一个给定的操作最终需要两个或者更多的互斥锁,那么就还有可能出现另一个潜在问题:死锁。一对线程中的每个都需要同时锁定两个互斥元来执行一些操作,并且每个线程都拥有一个互斥元,同时等待另一个,那么这两个线程都无法继续,这种情况被称为死锁。举两个例子:

例1:有1、2两个线程都需要同时锁定A、B两个互斥元才能操作,但是此时1拿到A,2拿到B,且两者都在等待另一个锁被释放,那么就会产生死锁。

例2:有三个线程1、2、3和三个互斥元A、B、C。1需要同时锁定A、B;2需要同时锁定B、C;3需要同时锁定C、A。此时1拿到A,2拿到B,3拿到C,且都等待另一把锁被释放,那么这三个线程都将陷入死锁。

为了避免死锁,常见的建议是始终按照相同的顺序锁定这两个互斥元。比如例1,如果总在锁定B之前锁定A,那么这两个线程永远不会死锁。这是理想条件,但是事实上大多互斥元服务于不同的目的,很难保证每次锁定的顺序相同,如果一味选择固定顺序锁定可能会使整个程序出现错误。暂时不考虑顺序的问题,C++标准库中的std::lock()允许用户同时锁定两个或更多的互斥元,同时不产生死锁。使用方法如下:

void swap(int& ldata, int& rdata)
{
    int temp;
    temp = ldata;
    ldata = rdata;
    rdata = temp;
}


class X
{
private:
    int _data;
    std::mutex m;
public:
    X(const int& data):_data(data){ }
    virtual ~X() {}

    friend void swap(X& lhs, X& rhs)
    {	
        //检查参数是不是相同的实例
        if (&lhs == &rhs) //试图在已经锁定的 std::mutex 上获取锁是未定义行为
        {
            return; //允许在同一线程中多重锁定的互斥元为 std::recursive_mutex
        }
        std::lock(lhs.m, rhs.m); //同时锁定两个互斥元
        
        //额外参数 std::adopt_lock 告知该方法,锁对象已被锁定,
        //并沿用已有锁的所有权而不是试图在构造函数中锁定互斥元。
        std::lock_guard<std::mutex> lock_l(lhs.m, std::adopt_lock);
        std::lock_guard<std::mutex> lock_r(rhs.m, std::adopt_lock);
        
        swap(lhs._data, rhs._data);
    }
};
复制代码

在对std::lock的调用中,获取任何一个锁都可能引发异常,一旦在获取锁的过程中失败就会引发异常,之前获取的所有锁都会被释放。

3.4.2 避免死锁的进一步指南

死锁不仅仅产生与锁定,虽然这是最常见的诱因。还有一种情况,通过两个线程来制造死锁,不用锁定,只需要每个线程在 std::thread对象尚未另一个线程调用join()。在这种情况下,两个线程在互相等待,都无法获取进展。这种简单的问题可以发生在很多地方,比如一个线程在等待另一个线程完成,而另一个线程同时又在等待第一个线程,又或是三个线程互相等待,同上锁定造成死锁的两个例子。避免死锁的准则全都可以归结为一个思路,如果另外有一个线程可能在等待,那就别让它等。虽然这种方法有一些特殊情况不适用,但是能够解决大多数死锁的问题。

1.避免嵌套锁

第一个思路是最简单,如果已经持有一个锁那就别再获取其它锁。光凭单个锁是不可能造成死锁的。如果要获取多个锁最好使用std::lock,当然还有线程互相等待造成死锁的风险,但是至少在互斥元这方面不会造成死锁。

2.在持有锁时避免调用用户提供代码

因为在用户提供的代码中,你不知道会做什么,如果在持有一个锁时下调用用户代码,就有可能获取其它锁进而产生死锁。有时候这种情况是无法避免的。如果在泛型编程中,在参数类型上的每一个操作都是用户提供的,这种情况下就需要新的准则。

3.以固定次序获取锁

如果绝对需要两个甚至多个锁,并且不能以std::lock单个操作获取锁,次优的做法是在每个线程中以固定的次序获取它们,在这种情况下无论有多少锁,由于获取次序是固定的,所以无论中间那个线程卡住了锁的获取流程,总会有一个线程拿够所需的锁,并在执行结束后释放,然后其它线程会慢慢”解锁“;如果后面没有其它线程,那么卡住锁的获取流程的线程很快就能拿全锁并开始执行,也就不会产生死锁。

4.使用层次锁

层次锁实际上是定义顺序锁的一个特例,但锁层次能够提供一种方法来检查在运行时是否遵守了约定。其思路是将应用程序分层,并确认所有能够在任意给定的层级上的互斥锁是否被锁定。当代码试图锁定一个互斥元时,如果它在较低层已经持有锁定,那么就不允许它锁定该互斥元。通过给每一个互斥元分配一个层号,并记录下每个线程都锁定了那些互斥元,就可以在运行时检查了。

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

#include "hierarchical_mutex.h" //自己定义头文件

using namespace std;

hierarchical_mutex hight_level_mutex(10000); //高层次锁
hierarchical_mutex low_level_mutex(5000); //低层次锁

int do_low_level_stuff()
{
    return 0;
}
int low_leve_func() //获取低层次锁 执行相应方法
{
    std::lock_guard<hierarchical_mutex> lk(low_level_mutex);
    return do_low_level_stuff();
}

void do_high_level_stuff(int data)
{
    if (data == 0)
        std::cout << "do_high_level_stuff(int data):data == 0" << std::endl;
}

void high_level_func() //获取高层次锁 并执行相应方法调用低层次方法
{
    std::lock_guard<hierarchical_mutex> lk(hight_level_mutex);
    do_high_level_stuff(low_leve_func());
}

void thread_a() //测试线程a
{
    high_level_func(); //由高到低 依次获取锁没有问题可以正确执行
}

hierarchical_mutex other_level_mutex(100); //其他层次锁
void do_other_level_stuff()
{
    std::cout << "do_other_level_stuff()" << std::endl;
}

void other_level_func() //先执行高层次函数 在调用 do_other_level_stuff
{
    high_level_func(); //目前没有问题
    do_other_level_stuff();
}

void thread_b() //测试线程b
{
    //获取其他层次锁
    std::lock_guard<hierarchical_mutex> lk(other_level_mutex);
    //调用other_level_func
    other_level_func(); //这里出现问题先拿到了低级锁(500)再要高层锁是非法的
}

int main(int argc, const char** argv) {

    thread ta(thread_a); //ta能正常执行
    thread tb(thread_b); //tb不能正常执行

    ta.join();
    tb.join();

    return 0;
}
/*************************************************
运行结果:
[wangs7@localhost 3rd_chapter]$ ./exec
do_high_level_stuff(int data):data == 0
terminate called after throwing an instance of 'std::logic_error'
  what():  mutex hierarchy violated
Aborted (core dumped)
*************************************************/
复制代码

这种层次锁是严格要求索取锁的层次要逐节向下的,不能越级向上拿锁,hierarchical_mutex.h的实现如下。比较简单不做分析。

#ifndef _HIERARCHICAL_MUTEX_
#define _HIERARCHICAL_MUTEX_

#include <mutex>
#include <climits>

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;
    void check_for_hierarchy_violation()
    {
        if (this_thread_hierarchy_value <= hierarchy_value)
        {
            throw std::logic_error("mutex hierarchy violated");
        }
    }
    void update_hierarchy_value()
    {
        previous_hierarchy_value = this_thread_hierarchy_value;
        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();
        update_hierarchy_value();
    }
    void unlock()
    {
        this_thread_hierarchy_value = previous_hierarchy_value;
        internal_mutex.unlock();
    }
    bool try_lock()
    {
        check_for_hierarchy_violation();
        if (!internal_mutex.try_lock())
            return false;
        update_hierarchy_value();
        return true;
    }

    static unsigned long get_thread_hierarchy_value()
    {
        return this_thread_hierarchy_value;
    }
};

thread_local unsigned long
hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);

#endif // !_HIERARCHICAL_MUTEX_
复制代码
5.将这些设计准则扩展到锁之外

由于死锁不只是出现再锁定中,它可以发生再任何可以导致循环等待的同步结构中。因此扩展上面所述的准则来涵盖哪些情况也是值得的。举个例子,正如应该避免在持有所的时候等待另一个线程。因为该线程可能就会因为这个锁难以向下运行。类似的,如果要等待一个线程完成,指定线程的层次结构可能也是可行的,这样线程就只需要等待低层次上的线程。一个简单的做法就是,确保线程在启动它们的同一个函数中被join()【哪个函数启动的哪个函数join】。

【2021.11.01】

3.5 用 std::unique_lock 灵活锁定

通过松弛不变量,std::unique_lockstd::lock_gurad提供了更多的灵活性,一个std::unique_lock实例并不总是拥有与之相关联的互斥元。首先,就像可以把std::adopt_lock作为第二个参数传递给构造函数,以便让锁对象来管理互斥元上的锁那样,也可以把std::defer_lock作为第二个参数传递,来表示该互斥元在构造时应该保持未被锁定。这个锁就可以在这之后通过std::unique_lock对象(不是互斥元)上调用lock(),或是通过将std::unique_lock对象本身传递给std::lock()来获取。使用std::unique_lockstd::defer_lock,而不是std::lock_guradstd::adopt_lock,能够很容易实现上述的一个例子。但是std::unique_lock占用更多的空间,并且比std::lock_gurad略慢。允许std::unique_lock实例其它互斥元不具有的灵活性是有代价的,代价就是有额外的信息必须被存储且随时更新。

void swap(int& ldata, int& rdata)
{
    int temp;
    temp = ldata;
    ldata = rdata;
    rdata = temp;
}

class X
{
private:
    int _data;
    std::mutex m;
public:
    X(const int& data):_data(data){ }
    virtual ~X() {}

    friend void swap(X& lhs, X& rhs)
    {	
        //检查参数是不是相同的实例
        if (&lhs == &rhs) //试图在已经锁定的 std::mutex 上获取锁是未定义行为
        {
            return; //允许在同一线程中多重锁定的互斥元为 std::recursive_mutex
        }
        /**********************使用lock_guard的方案***************************
        std::lock(lhs.m, rhs.m); //同时锁定两个互斥元
        //额外参数 std::adopt_lock 告知该方法,锁对象已被锁定,
        //并沿用已有锁的所有权而不是试图在构造函数中锁定互斥元。
        std::lock_guard<std::mutex> lock_l(lhs.m, std::adopt_lock);
        std::lock_guard<std::mutex> lock_r(rhs.m, std::adopt_lock);
        ********************************************************************/
        
        //使用unique_lock的方案 与原方案效果相同
        std::unique_lock<std::mutex> lock_l(lhs.m, std::defer_lock);
        std::unique_lock<std::mutex> lock_r(rhs.m, std::defer_lock);
        std::lock(lock_l, lock_r); //同时锁定两个unique_lock对象
        swap(lhs._data, rhs._data);
    }
};
复制代码

在上述代码中std::unique_lock对象能够被传递得std::lock()是因为std::unique_lock提供了lock()unlock()try_lock()三个成员函数。他们会转发给底层互斥元同名的方法去做实际的工作,并且只是更新在std::unique_lock实例内部的一个标识,来表示该实例当前是否拥有此互斥元。这个标识是用来在判断析构时是否需要调用unlock()的。正因为要存放这个标识并且要维护这个标识的状态,所以相比于std::lock_guradstd::unique_lock的性能略有损失。一般都是如果std::lock_gurad能够满足需求会优先考虑使用std::lock_gurad。另外有一些情况比如延迟锁定,锁的所有权的域间转移等是需要std::unique_lock的参与。

3.6 在作用域之间转移锁的所有权

因为std::unique_lock实例并没有拥有与其相关的互斥元,所以通过四处转移(move)实例,互斥元的所有权可以在实例之间进行转移。在某些情况下这种转移是自动的,比如从一个函数中返回一个实例,而在这种情况下,必须通过调用std::move()来显示实现。从根本上说,这却决于源是左值(lvalue)还是右值(rvlaue)。

  • 如果源为右值,则所有权的转移是自动的。
  • 如果源是左值,所有权的转移必须是显式的。以免从变量中意外地转移了所有权。

std::unique_lock就是典型的可移动但是不可复制的类型。还有另一种用法就是允许函数锁定一个互斥元,并将此锁的所有权转移给调用者,于是调用者接下来可以在同一个锁的保护下执行额外的操作。

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

std::mutex some_mutex;

std::unique_lock<std::mutex> get_lock()
{
    //构造unique_lock对象
    std::unique_lock<std::mutex> lk(some_mutex, std::defer_lock);
    lk.lock();
    //some other operations
    std::cout << "prepare_data()" << std::endl;
    //这里可以直接返回,因为编译器负责调用移动构造函数。
    return lk; 
}

void process_data()
{
    //将锁的所有权转移到自己身上
    std::unique_lock<std::mutex> lk_xxx(get_lock());
    lk_xxx.try_lock(); //尝试锁定 这里一定会出错,是为了测试
    //do something
    std::cout << "do_something()" << std::endl;
}
int main(int argc, const char** argv) {
    process_data();
    return 0;
}

/*************************************************
运行结果:
[wangs7@localhost 3rd_chapter]$ ./exec
prepare_data()
terminate called after throwing an instance of 'std::system_error'
  what():  Resource deadlock avoided
Aborted (core dumped)
*************************************************/
复制代码

这里看到在尝试再次锁定的时候抛出了异常,说明锁的所有权已经转移到新的函数中了(也可以用unlock测试)。通常这种模式是待锁定的互斥元依赖于当前的状态,或者依赖于传递给返回std::unique_lock对象的函数的参数的地方。这种用法之一,就是不直接返回锁,使用一个网关类的数据成员,以确保正确锁定了需要保护的数据的访问。这种情况下所有对该数据的访问都是通过这个网关类,当想要访问数据时,就获取这个网关类的实例(类似上述的get_lock()函数),他会获取锁。然后,可以通过网关对象的成员函数访问数据。在完成后,销毁网关对象,从而释放锁,并允许其它线程访问受保护的数据。这样的网关对象很可能是可移动的,在这种情况下,锁对象的数据成员也是需要可移动的。

简单理解就是锁和数据放在一起,但是内部访问数据不是直接取锁,要通过new一新的网关类来拿锁的所有权,拿到所有权之后开始操作,这时如果有其他线程也来new网关对象是拿不到所有权的,等上一个完成操作,销毁网关类之后其它线程才能去拿锁。大致是这种思想,具体的数据结构还是要根据具体情况去设计。

3.7 锁定在恰当的粒度

选择一个合适粒度的锁,来保证所有需要保护的数据都被保护是很重要的,而其要保证只有真正需要锁的操作中持有锁。在持有锁的时候不要做任何特别耗时的活动,如文件I/O,除非这个锁是为了保护文件访问。我们要在保证线程安全的前提下尽量减小锁的范围。

如果让一个锁保护整个数据结构,不仅可能会出先对锁的竞争,而且更多操作步骤会需要在同一个锁的保护下进行,所以锁的持有时间会变长,并发性能会下降,所以在这种情况下细粒度的锁是很有必要的。

锁定在恰当的粒度不仅关乎锁定的数据量;也关系到锁会持有多长时间,以及在持有锁时执行哪些操作。**一般情况下,只应该以执行要求的操作所需的最小可能时间去持有锁。**这意味着耗时操作,比如获取另一个锁(即使你知道它不会死锁)或是等待I/O完成,都不应该在持有锁的时候去做,除非绝对有必要。如果不能在整个操作持续时间内持有锁,那么就会把自己暴露在竞争条件中。有些时候,或者说更多时候是没有一个合适粒度级别的,因为并非所有对数据结构的访问都需要同样级别的保护。这种情况下,使用替代机制来代替互斥元可能更加合适。

3.8 用于共享数据保护的替代工具

虽然互斥元是最通用的机制,但它不是保护数据的唯一选择,还有其它替代品可以在某些特定条件下提供更恰当的保护。一个比较极端的情况下(却很常见)就是共享数据只在初始化的时候需要并发访问的保护,但在初始化之后就不再需要显式同步。在数据初始化之后锁定互斥元,纯粹是为了保护初始化(?),但是这不是必要的,并且对性能会产生不必要的打击。所以C++标准提供了一种机制,纯粹为了在初始化过程中保护数据。

在初始化时保护数据

假设有一个构造起来非常昂贵的共享资源,只有在实际需要时你才会初始化。例如,它会打开一个数据库链接或分配大量内存。像这样的延迟初始化在单线程代码中是很常见的——每个请求资源的操作首先检查它是否经过初始化,如果没有就在使用前初始化。

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

std::shared_ptr<std::string> string_ptr;

void foo()
{
    if (!string_ptr)
    {
        string_ptr.reset(new std::string("hello!"));
    }
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}
复制代码

当然在多线程并发的过程中,如果共享资源本身对于并发访问是安全的,将其转化成多线程代码时唯一需要考虑要保护的部分就是初始话,例如下面这段代码(使用互斥元进行线程安全的延迟初始化),但是下面的代码有一个问题就是会引起使用该资源的线程产生不必要的序列化。这是因为每个线程都必须等待互斥元,以检查资源是否已经初始化。

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

std::shared_ptr<std::string> string_ptr;
std::mutex string_mutex;

void foo()
{
    std::unique_lock<std::mutex> lk(string_mutex);
    if (!string_ptr)
    {
        string_ptr.reset(new std::string("hello!"));
    }
    lk.unlock();
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}
复制代码

这段代码是很常见的,但是不必要的序列化问题已经很大了,以至于许多人都尝试想出一种更好的解决办法,包括臭名昭著的二次检查锁定,再不获取锁的前提下首次读取指针,并仅当空指针时获取锁,一旦获取锁就要再次检查,以防在首次检查和这个线程获取锁之间,另一个线程就已经完成初始化。

void undefined_behaviour_with_double_checked_locking()
{
    if (!string_ptr) //1
    {
        std::lock_guard<std::mutex> lk(string_mutex);
        if (!string_ptr) //2
        {
            string_ptr.reset(new std::string("hi!")); //3
        }
    }
    std::cout << "string_ptr = " << *string_ptr << std::endl;
}
复制代码

不行的是这种模式因为某些原因而臭名昭著。

有可能产生恶劣的竞争条件,因为在锁外部的读取【1】和锁内部又另一个线程完成写入【3】不同步,这个竞争条件不仅涵盖指针也涵盖了指向的对象。就算一个线程看见了另一个线程写入的指针,它也可能看不见新创建的string对象,从而导致下面的操作在错误的值上运行。这种竞争行为被定义成数据竞争是一种未定义的行为。

在我们看来一旦一个线程完成初始化,那么string_ptr一定不为空。但事实并非如此,如果两个线程同时进行初始化操作,A拿到了锁开始执行初始化,B没有拿到锁并等待锁,在A初始化完成后释放锁,且此时指针不为空,并指向有效对象。但是当B获得锁进行第二次检查时很有可能还会看到一个空指针,因为B可能不会从新去内存中读指针的数据,而是直接用缓存的数据做判断,这就造成了内存泄漏 。为了解决这个问题,需要把指针前面加上volatile,表示告诉编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

你可能没想到这个模式还有问题,这就是这个模式臭名昭著的原因。由于不同编译器不同操作系统,对于变量的初始化可能有不同的方法,包括但不限于一下两种。有的是先分配好内存并把地址赋给指针,然后开始数据初始化;有的是先分配内存完成数据初始化后再将地址赋给指针。就第一种而言对于多线程是很危险的,假定A正在初始化变量,刚刚将内存地址赋值给指针,但是还没来得及初始化数据,但是被调度器打断了,这时另一个线程B去检查指针,发现不为空,并拿着指针去执行其它动作。这是很危险的,所以这个看似合理的办法其实漏洞百出,一种挽救的做法是,先使用一个中间量初始化,初始化结束后再将中间量赋给指针。

千万别用这东西!!!看看就得了!!!

但是现在C++标准库提供了std::once_flagstd::call_once来处理这种情况。与其锁定互斥元不停显式地检查指针,还不如每个线程都使用std::call_once,到std::call_once返回时,指针会被某个线程初始化(以同步的方式),这样就安全了。使用std::call_once比显式地使用互斥元通常会有更低的开销,特别是初始化已经完成的时候所以在std::call_once符合所有要求的功能时应优先考虑使用。

#include <iostream> //改进后的上述方案
#include <thread>
#include <string>
#include <mutex>

std::shared_ptr<std::string> string_ptr;
std::once_flag string_flag;

void init_string()
{
    string_ptr.reset(new std::string("Hi~"));
}

void foo()
{
    std::call_once(string_flag, init_string);
    std::cout << "string = " << *string_ptr << std::endl;
}

int main(int argc, const char** argv) {
    foo();
    return 0;
}
复制代码
  • 其它示例:使用std::call_once的线程安全的类成员延迟初始化(单例模式改进版)
#include <iostream>
#include <memory>
#include <thread>
#include <mutex>

class X
{
private: 
    int num;
    X() { };
public:
    static X* get_instance()
    {
        static X* instance;
        static std::once_flag flag;
        std::call_once(flag, []{instance = new X;});   
        return instance;
    }
    
    void set_num(int new_num) { num = new_num; } 
    void show_num() { std::cout << "num = " << num << std::endl; }
    X(const X &) = delete;
    X &operator=(const X &) = delete;
    ~X(){};    
};

int main(int argc, const char** argv) {

    X* a = X::get_instance();
    std::cout << "a set num:2333333" << std::endl;
    a->set_num(2333333);
    std::cout << "a.show() num is " ;
    a->show_num();
    X* b = X::get_instance();
    std::cout << "b.show() num is " ;
    b->show_num();
    std::cout << "b set num:55555" << std::endl;
    b->set_num(55555);
    std::cout << "a.show() num is " ;
    a->show_num();

    std::thread t([]{
        X* c = X::get_instance();
        std::cout << "[Thread]c.show() num is " ;
        c->show_num();
    });
    t.join();
    return 0;
}
/*************************************************
运行结果:
[wangs7@localhost 3rd_chapter]$ ./exec
a set num:2333333
a.show() num is num = 2333333
b.show() num is num = 2333333
b set num:55555
a.show() num is num = 55555
[Thread]c.show() num is num = 55555
*************************************************/
复制代码

注:以上代码编译过程需要链接 pthread 库。

保护很少更新的数据结构

假设有一个用于存储DNS条目缓存的表,它用来将域名解析为相应的IP地址。通常,一个给定的DNS条目将在很长的一段时间里保持不变——许多情况下,DNS条目会保持数年不变。虽然随着用户访问不同的网站,新的条目可能暂时会不时地添加到表中,但这一数据却将在其整个生命周期中基本保持不变。定期检查缓存条目地有效性时很重要套的,但是只有细节已有事迹改变地时候才会需要更新。

虽然更新是罕见地,但是它们会发生,并且如果这个缓存可以从多个线程访问,它就需要在更新过程中适当进行保护,以确保所有线程在读取缓存时都不会看到损坏的数据结构。在缺乏完全符合预期用法并且为并发更新与读取专门设计的专用数据结构的情况下,这种更新要求线程在进行更新时独占访问数据结构,直到它完成了操作。一旦更新完成,该数据结构对多线程并发访问又是安全的。使用std::mutex来保护数据结构就显得大费周章了,因为这会在数据结构没有进行修改时消除并发读取数据结构的可能,因而我们需要的是另一种互斥元,这种互斥元通常称为读写互斥元(读写锁),因为它考虑到了两种不同的用法:单个“写”线程独占访问或共享,由多个“读”线程并发访问。

这里使用boost::shared_mutex的实例来实现同步,而不是std::mutex实例。对于更新操作,std::lock_gurad<boost::shared_mutex>std::unique_lock<boost::shared_mutex>可用于锁定。当然boost::shared_mutex不是万能的,性能依赖于处理器的数量以及读线程和更新线程的相对工作负载。因此,分析代码在目标系统上的性能是很重要的,以确保额外的复杂度会有实际的收益。

#include <iostream>
#include <thread>
#include <mutex>
#include <string>
#include <map>
#include <boost/thread/shared_mutex.hpp>

using namespace std;

class dns_entry{

};

class dns_cache
{
    map<string, dns_entry> entries;
    mutable boost::shared_mutex entry_mutex;
public:
    bool find_entry(string const& domain) const
    {
        //使用boost::shared_lock<>实例来保护它,以供共享和只读访问
        //多个线程可以同时调用find_entry()
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex);
        map<string, dns_entry>::const_iterator  it = entries.find(domain);
        return (it == entries.end()) ? false : true;
    }
    void update_or_add_entry(string const& domain, dns_entry const& dns_details)
    {
        //lock_guard表示更新时被占用,仅提供独享访问
        //所有调用update_or_add_entry()和find_entry()都会被阻塞
        lock_guard<boost::shared_mutex> lk(entry_mutex);
        entries[domain] = dns_details;
    }
     
};


int main(int argc, const char** argv) {
    dns_cache cache;
    dns_entry entry;
    cache.update_or_add_entry("baidu", entry);
    if (cache.find_entry("baidu"))
        std::cout << "find baidu" << std::endl;
    else
        std::cout << "can't find baidu" << std::endl;

    if (cache.find_entry("google"))
        std::cout << "find google" << std::endl;
    else
        std::cout << "can't find google" << std::endl;

    cache.update_or_add_entry("google", entry);

    if (cache.find_entry("google"))
        std::cout << "find google" << std::endl;
    else
        std::cout << "can't find google" << std::endl;

    return 0;
}
复制代码

说明:

boost库需要手动安装,添加环境变量,并且编译时要添加相应的静态库。

静态库:boost_log、boost_log_setup、boost_system、boost_filesystem、boost_serialization 、boost_thread、boost_chrono

上述代码编译时要额外添加 -lboost_thread 选项。

/*************************************************
运行结果:
find baidu
can't find google
find google
*************************************************/
复制代码

递归锁

在使用std::mutex的情况下,一个线程试图锁定已经拥有的互斥元是错误的,并且试图这么做将导致未定义行为。然而在某些情况下线程多次重复获取同一个互斥元却无需先释放它是可取的。为了这个目的,C++标准库提供了std::recursive_mutex,使用方法同std::mutex,不同的是可以在一个线程中的单个实例上获取多个锁。在互斥元能够被另一个线程锁定之前,当前锁定互斥元的线程一定要释放锁,而且lock多少次就要对应unlock多少次。使用std::lock_guradstd::unique_lock也能帮助你正确处理这些互斥元。

当然这种递归锁的做法是不推荐的,如果设计需要用到这样的结构,那么最好考虑改变你的设计。

【2021.11.02】

猜你喜欢

转载自juejin.im/post/7031429796189962248