《C++并发实例》3.2 使用互斥保护数据

因此,您有一个共享数据结构,例如上一节中的链表,并且您希望保护它免受竞争场景和可能随之而来的破坏不变量的潜在影响。如果将访问数据结构的所有代码片段标记为互斥,这样如果任何线程正在对其执行操作,任何其他尝试线程都必须等到第一个线程完成,这样的操作会好吗?这将使线程不可能看到损坏的不变量,除非线程正在执行修改。

嗯,这不是一个童话般的愿望——这正是使用被称为互斥(mutex exclusion)的同步原语(synchronization primitive)所能得到的.您需要锁定与该数据关联的互斥锁,当您完成访问数据结构时,您可以解锁。然后,线程库确保一旦一个线程锁定了特定互斥锁,尝试锁定同一互斥锁的所有其他线程都必须等待,直到锁定该互斥体的线程将其解锁。这确保了所有线程都能看到共享数据的自洽视图,而不会出现任何破坏的不变量。

互斥锁是 C++ 中最通用的数据保护机制,但它们并不是灵丹妙药。构建代码以保护正确的数据(请参阅第 3.2.2 节)并避免接口中固有的竞争场景(请参阅第 3.2.3 节)非常重要。互斥锁也有其自身的问题,表现为死锁(请参阅第 3.2.4 节)和保护太多或太少的数据(请参阅第 3.2.8 节)。让我们从基础开始。

3.2.1 C++中使用互斥

在 C++ 中,通过构造 std::mutex 实例来创建互斥,通过调用成员函数 lock() 来锁定它,并通过调用成员函数unlock() 来解锁。但是,不建议直接调用成员函数,因为这意味着您必须记住函数退出时调用unlock(),包括异常退出。相反,标准 C++ 库提供了 std::lock_guard 类模板,它实现了互斥的RAII(Resource Acquisition Is Initialization)原则。在构造时锁定互斥并在析构时解锁,从而保证锁定的互斥始终正确解锁。下面的列表显示了如何使用 std::mutex 以及 std::lock_guard 来保护可由多个线程访问的队列。这两个都在< mutex >文件头中声明。

Listing 3.1 Protecting a list with a mutex

#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list;		- [1]
std::mutex some_mutex;		- [2]
void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);	- [3]
    some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);		- [4]
    return std::find(some_list.begin(),some_list.end(),value_to_find)
        != some_list.end();
}

在listing3.1 中,有一个全局变量 [1], 它受到相应全局实例 std::mutex 的保护[2]。add_to_list() [3] 和 list_contains() [4] 中使用 std::lock_guardstd::mutex 意味着这些函数的访问是互斥的:list_contains() 永远不会在 add_to_list 修改过程中访问到队列。

尽管在某些情况下使用全局变量是合适的,但在大多数情况下,通常将互斥锁和受保护数据分组在一个类中,而不是使用全局变量。这符合面向对象设计规则:通过将它们放入一个类中,您可以清楚了解它们的相关性,,并且可以封装功能加强保护。在这种情况下,函数 add_to_list 和 list_contains 将成为类的成员函数,mutex和受保护数据都将成为类的私有成员,从而更容易识别哪些代码可以访问数据以及那些代码需要锁定互斥。如果类的所有成员函数在访问任何数据成员之前加锁并在完成后解锁,则数据在访问中可以受到很好保护。

好吧,这并不完全正确,机灵的你们会注意到:如果其中一个成员函数返回了受保护数据的指针或者引用,那么成员函数是否都良好地加解锁并不重要,因为这层保护有个巨大的漏洞。任何有权访问该指针或引用的代码现在都可以访问(并可能修改)受保护的数据,而无需锁定互斥锁。 因此,使用互斥锁保护数据需要仔细的接口设计,以确保互斥锁在对受保护数据进行任何访问之前被锁定,并且没有后门。

3.2.2 受保护共享数据的代码结构

正如您刚刚看到的,使用互斥锁保护数据并不像在每个成员函数中添加一个 std::lock_guard 对象那么简单;一个游离的指针或引用,使得所有的保护都变成徒劳。一方面,检查游离指针或引用很容易;只要所有成员函数不会在返回或者对外参数中暴露受保护数据的指针或者引用,数据就很安全。如果你再深入一点,就会发现事情并不是那么简单——没那么简单的事。除了检查成员函数不会向调用者传递指针或者引用,检查它们不会在你无意识时向函数中传递指针或者引用也很重要。这非常危险:这些函数可能将指针或者引用保存在一个地方,稍后在不被互斥锁保护的时候调用。尤其危险的是在运行时通过函数参数或其他方式传递,如下面列表所示。

Listing 3.2 Accidentally passing out a reference to protected data

some_data* unprotected;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);         [1] Pass “protected” data to user-supplied function
    }
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
    unprotected=&protected_data;
}
void foo() {
    x.process_data(malicious_function);     [2] Pass in a malicious function
    unprotected->do_something();            [3] Unprotected access to protected data
}

在这个例子中,process_data中的代码看起来足够无害,用std::lock_guard很好地保护,但是对用户提供的函数func [1]的调用意味着foo可以传入malicious_function来绕过保护[2],然后调用do_something()而不需要互斥锁 [3].

从根本上来说,这段代码的问题在于它没有完成您打算做的事情:访问数据结构的所有代码片段标记为互斥。。在只鹅哥例子中,它没有意识到 foo() 中调用 unprotected->do_something()。不幸的是,这部分问题 C++ 线程库无法帮助您解决。作为程序员,您需要正确地加锁来保护您的数据。从好的方面来说,您可以遵循一个准则,这将在这些情况下为您提供帮助:不要将指针和引用传递到锁范围之外,无论是从函数返回它们,还是将它们存储在外部可见的内存中,或将它们作为函数调用参数传递。

尽管这是尝试使用互斥锁来保护共享数据时的常见错误,但这远不是唯一的潜在陷阱。正如您将在下一节中看到的,即使数据受到互斥锁的保护,仍然可能出现竞争场景。

3.2.3 接口中固有的竞争场景

仅仅使用互斥锁或者其他机制来保护数据,并不能保证完全避免竞争场景;您必须保证数据被恰当地受到保护。再次考虑双向链表的例子。为了使线程安全地删除一个节点,您需要确保防止并发访问三个节点:被删除的节点和两侧的节点。如果单独对每个节点保护,那么情况不会比不用互斥锁更好,因为竞争场景依然存在——在整个删除过程中,需要保护的不是单独的节点,而是整个数据结构。最简单的方法是对整个队列加锁,像listing3.1中那样。

仅仅因为列表中的个别操作是安全的,您还没有脱离困境;即使是简单的接口,也可能进入竞争场景。考虑一个像 std::stack 一样的堆数据结构容器,如listing3.3所示。先不考虑构造函数和swap(),您只能对 std::stack 做五件事:将新的元素push()到堆中,将一个元素pop()出堆,读取top()元素,检查它是否为empty(),以及读取元素数量——堆的size()。如果你将top()改为返回一份拷贝而不是引用(遵循第 3.2.2 节中的原则)然后用互斥锁来保护内部数据,则该接口本质上仍然受到竞争场景的影响。这个问题并不是互斥锁独有的,这是个接口问题,因此在无锁实现中仍然会出现竞争场景。

** Listing 3.3 The interface to the std::stack container adapter**

template<typename T,typename Container=std::deque<T> >
class stack
{
    public:
    explicit stack(const Container&);
    explicit stack(Container&& = Container());
    template <class Alloc> explicit stack(const Alloc&);
    template <class Alloc> stack(const Container&, const Alloc&);
    template <class Alloc> stack(Container&&, const Alloc&);
    template <class Alloc> stack(stack&&, const Alloc&);
    bool empty() const;
    size_t size() const;
    T& top();
    T const& top() const;
    void push(T const&);
    void push(T&&);
    void pop();
    void swap(stack&&);
};

这里的问题是empty()和size()的结果不可靠。尽管它们在调用时可能是正确的,但一旦返回,其他线程就可以自由访stack,并可能在调用empty()或size()之前向stack中push()新元素或者pop()原有的元素。

特别是,如果堆栈实例不是共享的,则可以安全地检查empty(),然后在不为空的情况下通过pop()获取元素。

stack<int> s;
if(!s.empty())      - [1]
{
    int const value=s.top();   -[2]
    s.pop();                -[3]
    do_something(value);
}

它不仅在单线程代码中安全,而且符合预期:在空堆栈上调用 top() 是未定义的行为(undefinded behavior)。当stack对象共享时,该调用顺序不再安全,因为在调用empty()[1]和pop()[2]之间可能会有另一个线程调用pop()并且移除掉最后一个元素。这是一个典型的竞争场景,并且在内部使用互斥锁来保护堆栈内容并不能阻止它;这是接口的结果。

解决办法是什么?嗯,这个问题是接口设计的结果,所以解决方案是改变接口。然而,这仍然引出了一个问题:需要做出哪些改变?在最简单的情况下,您可以声明如果调在没有任何元素时调用 top() 会抛出异常。虽然这直接解决了这个问题,但它使编程变得更冗余,因为现在您需要能够捕获异常,即使对empty()的调用返回false。这本质上使得对empty()的调用完全多余。

如果仔细观察前面的代码片段,还可能存在另一个竞争场景,但这次是在 top() [2]和 pop() [3]的调用之间。考虑两个线程运行前面最小片段代码都引用了相同的stack对象,s。这不是极端场景;当使用线程来提高性能时,让多个线程在不同的数据上运行相同的代码是很常见的,而共享堆stack对象是在它们之间划分工作的理想选择。假设最初堆栈有两个元素,因此您不必担心任一线程上的empty()和top()之间的竞争,并考虑潜在的执行模式。

如果堆栈在内部受到互斥锁的保护,则任何时候只有一个线程可以运行堆栈成员函数,因此调用可以很好地交错,而对 do_something() 的调用可以同时运行。一种可能的执行如表 3.1 所示。

Table 3.1 A possible ordering of operations on a stack from two threads

正如您所看到的,如果只有两个线程在运行,在两次调用 top() 之间没有任何修改,因此两个线程将看到相同的值。不仅如此,在对 pop() 的调用之间也没有对 top() 的调用。总的来说,两个stack值的其中一个在读取之前就被丢弃掉了,另一个却被操作了两次。这是另一个竞争场景,比empty()/top() 竞争的未定义行为更加隐秘;没有发生任何明显的错误,并且错误的后果可能与原因相去甚远,尽管它们显然取决于 do_something() 真正做了什么。

这需要对接口进行更根本的改变,即在互斥锁的保护下将对 top() 和 pop() 的调用结合起来(combine the call)。 Tom Cargill 指出,结合调用(combined call)时如果堆栈上对象的拷贝构造函数抛出异常可能导致问题。Herb Sutter 从异常安全的角度相当全面地处理了这个问题,但竞争场景潜在地给它带来了一些新的东西。

对于那些不了解这个问题的人,请考虑 stack<vector>。现在,vertor是一个动态大小的容器,所以当你拷贝vector时库必须从堆上分配更多内存来拷贝内容。如果系统负载很重,或者存在严重的资源限制,则此内存分配可能会失败,所以vector的拷贝构造可能会抛出std::bad_alloc 异常。如果向量包含大量元素,这种情况尤其可能发生。如果pop()函数定义为返回被pop的值,同样将它从stack中移除,则会出现一个潜在的问题:只有在stack被修改后,pop的值才会返回给调用者,但是复制数据以返回给调用者可能会抛出异常。如果出现这种情况,pop的数据就会丢失;已从堆栈中移除,但拷贝不成功! td::stack 接口的设计者有助于将操作分为两部分:获取顶部元素 (top()),然后将其从堆栈中删除 (pop()),这样,如果您无法安全地复制数据,它保留在堆栈上。如果问题是堆内存不足,应用程序可能可以释放一些内存并重试。

不幸的是,您在消除竞争场景时正试图避免这种分割!值得庆幸的是,还有其他选择,但它们并非没有代价。

选项1:通过引用传递
第一个选择是传递一个对变量的引用,用来接收调用pop()时被pop的值:

std::vector<int> result;
some_stack.pop(result);

这在很多情况下有用,即它要求调用代码在调用之前构造tsack的类型实例,以便将其作为目标传递。对于某些类型,这是不切实际的,因为构建实例在时间或资源方面造价昂贵。而对于其他类型,这并不总是可能,因为构造需要的参数此时不一定可用。最后,它要求存储的类型是可分配的。这是一个重要的限制:许多用户定义的类型不支持赋值(assignable),尽管它们可能支持移动构造甚至拷贝构造(允许返回值)。

选项 2:需要一个不抛出异常的拷贝构造函数或移动构造
只有在pop()的返回值抛出异常时才会有异常安全问题,很多类型的拷贝构造不会抛出异常,随着C++标准支持rvalue-reference新特性请参阅附录 A,A.1 节),很多拥有移动构造的类型不会抛出异常,虽然它们的拷贝构造会。一种有效的选择是限制你线程安全的stack使用这些不会抛出异常的类型。

虽然这很安全,但这并不是一个办法。尽管您可以在编译时使用 std::is_nothrow_copy_constructible 和 std::is_nothrow_move_constructible 类型特征检测是否存在不引发异常的拷贝或移动构造函数,但它的局限性很大。会有更多用户定义类型拷贝构造会抛出异常并且没有移动构造函数(尽管随着用户习惯使用C++11折可能会发生改变)。非常遗憾这些类型不能存储在你线程安全的stack中。

选项3: 返回被pop元素的指针
第三个选项是返回被pop元素的指针而不是返回元素的值。这里的优点是可以自由复制指针而不会引发异常,你可以避免Cargill的异常问题。缺点是返回指针需要一种管理对象内存分配的方法,对于简单的类型比如integer,这种内存管理的开销可能超过仅按值返回类型的成本。于任何使用此选项的接口,std::shared_ptr 将是一个不错的指针类型选择;它不仅避免了内存泄漏,因为对象在最后一个指针释放时就会被销毁,而且库完全控制内存分配方案,不必使用 new 和 delete。这对于优化目的非常重要:要求对stack上每一个对象使用new单独分配比原始非线程安全版本开销大得多

选项4: 同时选择1和2或3
永远不应该排除灵活性,尤其是在通用代码中。如果您选择了选项 2 或 3,那么再提供选项 1也相对容易,让用户在使用你的代码时根据最小代价选择他们合适的选项。

线程安全栈的定义示例
listing3.4展示了实现了选项1和3的没有竞争场景的stack定义,一种采用了对存储值地址的引用,另一种返回 std::shared_ptr<>。它有简单的接口,只有两个函数:push() 和 pop()。

** Listing 3.4 An outline class definition for a thread-safe stack**

#include <exception>
#include <memory>                 -  For std::shared_ptr<>
struct empty_stack: std::exception
{
    const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack&);
    threadsafe_stack& operator=(const threadsafe_stack&) = delete; 
						—[1] Assignment operator is deleted
    void push(T new_value);
    std::shared_ptr<T> pop();
    void pop(T& value);
    bool empty() const;
};

通过减少接口,您可以实现最大程度的安全性;甚至整个stack上的操作也受到限制。stack本身不能被赋值,因为赋值运算符被删除了[1](参见附录 A,A.2 节),并且没有 swap() 函数。然而,它可以被拷贝,假设stack元素可以被拷贝。当stack为空时pop()会抛出一个empty_stack 异常,因此即使在调用empty() 后stack被修改,一切仍然有效。正如选项 3 的描述中提到的,使用 std::shared_ptr允许stack在需要时处理内存分配问题以避免过度调用new 和 delete。您的五个stack操作现在变成了三个:push()、pop() 和empty()。即使是empty()也是多余的。您可以确保互斥锁在整个操作过程中都处于锁定状态。下面的listing显示了封装std::stack<>的简单实现。

** Listing 3.5 A fleshed-out class definition for a thread-safe stack**

#include <exception>
#include <memory>
#include <mutex>
#include <stack>
struct empty_stack: std::exception
{
    const char* what() const throw();
};
template<typename T>
class threadsafe_stack
{
private:
    std::stack<T> data;
    mutable std::mutex m;
public:
    threadsafe_stack(){}
    threadsafe_stack(const threadsafe_stack& other)
    {
        std::lock_guard<std::mutex> lock(other.m);
        data=other.data;            -[1]Copy performed in constructor body
    }
    threadsafe_stack& operator=(const threadsafe_stack&) = delete;
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lock(m);
        data.push(new_value);
    }
    std::shared_ptr<T> pop()
    {
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack();       -[2]Check for empty before trying to pop value
        std::shared_ptr<T> const res(std::make_shared<T>(data.top()));      -[3]Allocate return value before modifying stack
        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();
    }
};

这个stack实现实际上是可复制的——拷贝构造锁住原对象然后拷贝整个stack内容。您在构造中拷贝[1]而不是初始化成员列表,以确保互斥锁在整个拷贝活成中有效。

正如对 top() 和 pop() 的讨论所示,接口中出现问题的竞争场景本质上是由于锁定粒度太小;保护并不涵盖整个操作。出现互斥问题是由于锁的颗粒度太大;极端情况是全局一个锁保护所有共享数据。在存在大量共享数据的系统中,这可能会消除并发的所有性能优势,因为线程被迫同一时间只能运行一个,即使它们正在访问不同的数据位。第一版多处理器系统的Linux内核就使用了一个全局内核锁。尽管这种方法有效,但这意味着双处理器系统的性能通常比两个单处理器系统差得多,四处理器系统的性能远不及四个单处理器系统。内核竞争过多,因此在附加处理器上运行的线程无法执行有用的工作。Linux 内核的后续版本已转向更细粒度的锁定方案,因此四处理器系统的性能更接近单处理器系统四倍的理想性能,因为竞争要少得多。

细粒度锁定方案的一个问题是,有时您需要多个互斥锁才能保护操作中的所有数据。如前所述,有时正确的做法是增加互斥锁所覆盖的数据的粒度,以便只需要锁定一个互斥锁。然而,有时这是不理想的,比如用互斥锁来保护一个类的单独实例。在这种情况下,下一级的锁定意味着要么把锁定状态留给用户,要么使用单个互斥锁来保护该类的所有实例,这两种情况都不是特别理想的。

如果您最终必须为给定的操作锁定两个或多个互斥锁,那么还会出现另一个潜在的问题:死锁。这几乎与竞争场景相反:不是两个线程竞争成为第一个,而是每个线程都在等待另一个线程,因此两者都没有取得任何进展。

3.2.4 死锁:问题和解决方案

想象一下,您有一个由两部分组成的玩具,并且您需要两个部分来玩它,例如玩具鼓和鼓槌。现在想象一下您有两个小孩,他们都喜欢玩它。如果其中一个人同时得到了鼓和鼓槌,那个孩子就可以快乐地打鼓,直到玩累为止。如果另一个孩子想玩,他们就必须等待,无论这让他们多么难过。现在想象一下,鼓和鼓槌(分别)埋在玩具盒中,并且您的孩子决定同时玩它们,因此他们在玩具盒中翻找。一个找到鼓,另一个找到鼓槌。现在他们被困住了;除非一个人决定友善地让对方玩,否则每个人都会保留自己拥有的一切,并要求对方给他们另一块,所以双方都无法玩。

现在想象一下,您没有两个孩子争论玩具,而是两个线程争论互斥锁:每一对线程需要一对互斥锁执行某些操作,并且每一个线程都在等待另一个锁。没有线程在操作,因为每一个线程都在等待对方释放锁。这种情况称为死锁,这是两个或更多互斥锁执行操作时最大问题。

避免死锁的常见建议是始终以相同的顺序锁定两个互斥锁:如果始终在锁定 B 之前锁定 A,则永远不会发生死锁。有时这很简单,因为互斥锁服务于不同的目的,但其他时候则不是那么简单,例如当互斥体各自保护同一类的单独实例时。假设,有同一个类的两个实例交换数据的操作,为了确保数据正确交换,且不受并发修改的影响,两个实例上的互斥锁都要锁定。但是,如果选择固定顺序,这可能会适得其反:只要两个线程尝试在相同的两个实例间交换参数,就会出现死锁!

** Listing 3.6 Using std::lock() and std::lock_guard in a swap operation**

void swap(some_big_object& lhs,some_big_object& rhs);
class X
{
private:
    some_big_object some_detail;
    std::mutex m;
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);		-[1]
        std::lock_guard<std::mutex> lock_a(lhs.m,std::adopt_lock);		-[2]
        std::lock_guard<std::mutex> lock_b(rhs.m,std::adopt_lock);		-[3]
        swap(lhs.some_detail,rhs.some_detail);
    } 
};

首先,通过检查参数确保它们属于不同实例,因为当您已经持有 std::mutex 时尝试获取该锁是未定义的行为。(允许同一线程进行多个锁定的互斥体以 std::recursive_mutex 的形式提供。有关详细信息,请参阅第 3.3.3 节。)然后,调用 std::lock() 锁定两个互斥锁[1],并为每一个互斥锁构造一个 std::lock_guard 实例[2][3],除了互斥锁之外还提供了 std::adopt_lock 参数,以向 std::lock_guard 对象指示互斥锁已被锁定,并且它们应该只接受互斥锁上现有锁的所有权,而不是尝试锁定互斥锁在构造函数中。

这可以保证在正常情况下及时受保护操作抛出异常互斥锁也能在函数退出时正确解锁,它还允许简单的返回。另外,值得注意的是,在 std::lock 调用中锁定 lhs.m 或 rhs.m 可能会抛出异常;在这种情况下,异常会从 std::lock 传播出去。如果 std::lock 已成功获取一个互斥锁上的锁,并且当它尝试获取另一个互斥锁上的锁时抛出异常,则第一个锁将自动释放:std::lock 提供了非全有则全无(all-or-nothing)的语义锁定的指定的互斥锁。

虽然 std::lock 可以帮助您在需要同时获取两个或多个锁的情况下避免死锁,但如果单独获取它们则无济于事。在这种情况下,您必须依靠开发人员的原则来确保不会陷入僵局。。这并不容易:死锁是多线程代码中遇到的最棘手的问题之一,并且通常是不可预测的,大多数时候一切都工作正常。然而,有一些相对简单的规则可以帮助您编写无死锁的代码。

3.2.5 避免死锁的进一步准则

死锁不仅仅发生在锁上,尽管这是最常见的原因。你可以通过两个无锁线程创建死锁,只需要在每一个线程上调用对方std::thread 对象的 join()。在这种情况下,两个线程都无法执行,因为它正在等待另一个线程完成,就像孩子们争夺玩具一样。这种简单的循环可以发生在任何一个线程需要等待另一个线程同样在等待第一个线程执行相同操作的时候,并且这不仅限于两个线程:三个会更多线程也可以造成死锁。避免死锁的准则都归结为一个理念:不要去等待可能在等你的另一个线程。各个准则提供了鉴别和消除了其他线程等待你的可能性。

避免嵌套锁
第一个想法是最简单的:已经持有锁时不要获取锁。如果您遵循此准则,则不可能仅因锁的使用而导致死锁,因为每个线程仅持有一个锁。您仍然可能因其他原因(例如相互等待的线程)而陷入死锁,但互斥锁可能是死锁的最常见原因。如果您需要获取多个锁,请使用 std::lock 单独进行操作,以便在没有死锁的情况下获取它们。

避免在加锁时调用用户提供代码(user-supplied code)
这是上一个准则的简单延续。因为代码是用户提供的,所以你不知道它能做什么;它可以做任何事情,包括获取锁。如果您在持有锁的同时调用用户提供的代码,并且该代码获取了锁,则您违反了避免嵌套锁的准则,并且可能会出现死锁。。如果您正在编写类似 3.2.3 节中stack的通用代码,每一次操作的类型都是用户提供代码。在这种情况下,您需要一个新的准则。

固定流程获取锁
如果您必须使用两个或多个锁,并且无法简单使用 std::lock 单独获取它们,那么次优的做法是在每个线程中以相同的顺序获取它们。我在第 3.2.4 节中介绍如何避免两个互斥锁时死锁时谈到了这一点:关键是以线程之间一致的方式定义顺序。在某些情况下,这相对容易。例如第 3.2.3 节中的堆栈——互斥锁在每个stack的内部实现,但是存储在stack中的数据元素操作需要调用用户提供代码。然而,你可以加一些限制:所有stack中的数据元素操作都不允许操作stack本身。这将压力给到了stack的使用者,但容器中的数据再去获取容器的操作非常罕见,并且在发生时很明显,所以不是特别难承受的压力。

在其他情况下,这并不那么简单,正如您在第 3.2.4 节中的交换操作中发现的那样。至少在这种情况下,您可以同时锁定互斥锁,但这并不总是可能的。如果你回过头看3.1节中的链表,你会发现一种保护链表的方式是在每个节点上加锁。那么,为了获取链表,线程必须对每个感兴趣的节点请求锁。对于要删除节点的线程,它必须获取三个节点的锁:被删除的节点以及两端节点,因为它们需要同时被修改。同样,遍历列表线程需要获取当前节点和这个方向的下一个节点的锁,第一个节点可以释放,因为它不再需要了。

这种交替式的加锁风格允许多个线程访问列表,前提是每一个线程访问不同节点。然而,为了避免死锁,节点必须以相同的顺序锁定:如果两个线程以相反的顺序交替式遍历列表,它们会在列表中间死锁。如果节点A和B在列表中相邻,一个方向的线程会持有A节点的锁申请B节点的锁,另一个方向的线程会持有B节点的锁申请A节点的锁——这是死锁的典型场景。

同样的,当删除节点A和C之间的B节点,如果该线程在锁定A和C之前获取B节点的锁,则它有可能与遍历列表的线程发生死锁。。这样的线程会尝试首先锁定 A 或 C(取决于遍历的方向),但随后会发现它无法获取 B 上的锁,因为执行删除操作的线程持有 B 上的锁并尝试获取A 和 C 上的锁。

这里防止死锁的一种方法是定义遍历顺序,因此线程必须始终在 B 之前锁定 A,在 C 之前锁定 B。这将消除死锁的可能性,但代价是不允许反向遍历。类似的约定在其他数据结构也成立。

使用层次锁
尽管这是定义锁顺序的特殊情况,锁的层次可以提供在运行时检验约定的方法。这个理念是将你的应用分层然后在每一检查可能使用的锁。在代码尝试加锁时,低层级已经被锁定则不允许加锁。你可以在运行时给每层标记然后持续记录每一次线程加锁。下面的列表显示了使用层次锁的一个简单示例。

Listing 3.7 Using a lock hierarchy to prevent deadlock

hierarchical_mutex high_level_mutex(10000);	-[1]
hierarchical_mutex low_level_mutex(5000);		-[2]
int do_low_level_stuff();
int low_level_func()
{
    std::lock_guard<hierarchical_mutex> lk(low_level_mutex);	-[3]
    return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func()
{
std::lock_guard<hierarchical_mutex> lk(high_level_mutex);	-[4]
high_level_stuff(low_level_func());		-[5]
}
void thread_a()	-[6]
{
    high_level_func();
}
hierarchical_mutex other_mutex(100);	-[7]
void do_other_stuff();
void other_stuff()
{
    high_level_func();			-[8]
    do_other_stuff();
}
void thread_b()			-[9]
{
    std::lock_guard<hierarchical_mutex> lk(other_mutex);	-[10]
    other_stuff();
}

thread_a()[6]遵守准则,因此运行良好。。另一方面,thread_b() [9] 忽略准则,因此会在运行时失败。thread_a() 调用 high_level_func(),后者锁定 high_level_mutex [4] (层级为 10000 [1])并且调用low_level_func()[5],以获取high_level_stuff(). low_level_func()的参数,然后锁定low_level_mutex [3]。该操作没有问题,因为该锁层级更低为5000[2]。

另一方面,thread_b() 则不太好。首先,它锁定了other_mutex[10],该锁的层级是100[7]。这意味着它确实应该保护超低层级的数据。当 other_stuff() 调用 high_level_func() 时[8],这违反了层级::high_level_func() 尝试获取 high_level_mutex,其值为 10000,远大于当前层级 100。 因此hierarchy_mutex 将报告错误,可能是抛出异常或中止程序。所以层级锁之间产生死锁是不可能的,因为互斥锁本身会强制锁定顺序。这确实意味着如果两个锁在同一层级,则不可能同时锁定,因此交替式加锁方案中要求链表中每一个锁的层级比前一个更低,在某些场景中可能不切实际。

该示例还演示了另一点,即通过用户定义互斥锁使用 std::lock_guard<> 模板。 hierarchical_mutex不是标准的一部分,但很容易编写;列表3.8中展示了一个简单的实现。虽然它是用户定义的类型,但它可以与 std::lock_guard<> 一起使用,因为它实现了满足互斥概念所需的三个成员函数:lock()、unlock() 和 try_lock()。您还没有看到直接使用 try_lock(),但它相当简单:如果互斥锁上的锁由另一个线程持有,则它返回 false而不是等待调用线程可以获得互斥锁上的锁。它也可以由 std::lock() 在内部使用,作为死锁避免算法的一部分。

Listing 3.8 A simple hierarchical mutex

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;	-[1]
    void check_for_hierarchy_violation()
    {
        if(this_thread_hierarchy_value <= hierarchy_value)	-[2]
        {
            throw std::logic_error(“mutex hierarchy violated”);
        }
    }
    void update_hierarchy_value()
    {
        previous_hierarchy_value=this_thread_hierarchy_value;	-[3]
        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();          -[4]
        update_hierarchy_value();    -[5]
    }
    void unlock()
    {
        this_thread_hierarchy_value=previous_hierarchy_value;	-[6]
        internal_mutex.unlock();
    }
    bool try_lock()
    {
        check_for_hierarchy_violation();
        if(!internal_mutex.try_lock())		-[7]
            return false;
        update_hierarchy_value();
        return true;
    }
};
thread_local unsigned long
    hierarchical_mutex::this_thread_hierarchy_value(ULONG_MAX);	-[8]

这里的关键是使用thread_local值表示当前线程的层级:this_thread_hierarchy_value[1]。它被初始化到最大值[8],因此任何锁的初始化都会被锁定。因为它被声明为 thread_local,每个线程都有自己的副本,所以一个线程中变量的状态完全独立于从另一个线程中读取的状态。有关 thread_local 的更多信息,请参阅附录 A 的 A.8 节。

因此,线程第一次锁定hierarchy_mutex 实例时,this_thread_hierarchy_value 的值为ULONG_MAX。就其本质而言,该值大于任何其他值,因此 check_for_hierarchy_violation() [2]中的检查通过。通过该检查,lock() 委托内部锁进行实际锁定[4]。一旦锁定成功,您就可以更新层级值[5]。

如果你在锁定第一个锁时同时锁定另一个hierarchy_mutex,那么this_thread_hierarchy_value的值反应第一个锁的层级。第二个锁的层级必须比现有锁层级更低以便通过检查[2]。

现在,保存当前线程之前的层级非常重要,以便你在unlock()中恢复[6];否则你无法锁定更高层级的锁,即使是在没有线程加锁的情况下。因为你仅在持有internal_mutex时才才存储先前的层级值[3],并且在内部锁解锁之前恢复[6],您可以安全地将其存储在hierarchical_mutex 当中,因为它受到内部锁的保护。

try_lock() 的工作方式与 lock() 相同,除了调用 internal_mutex 的 try_lock() 调用失败时不会获取锁,所以在返回失败时不用更新层级。

尽管是在运行时检测,但它至少不依赖于时间——您不必等待极端情况下死锁的出现。并且,这种要求模块化应用的设计模式和锁有助于在编写之前消除死锁。及时没有编写运行时检测,练习设计也值得尝试。

将准则扩展到加锁之外
正如我在本节开头提到的,死锁不仅仅发生在锁上;也会发生在所有可能导致等待循环的异步操作上。因此有必要将这些准则扩展到这些情况下。比如,正如你需要尽量避免嵌套锁,在持有锁的情况下等待线程也是一个坏主意,因为该线程可能需要获取锁才能继续。类似的,如果您要等待线程完成,则可能需要确定线程的层级,线程只等待比它更低层级的线程结束。一种简单的方法是在启动线程的函数中调用join(),如 3.1.2 和 3.3 节中所述。

一旦您设计了避免死锁的代码,std::lock() 和 std::lock_guard 就涵盖了大多数简单锁定的情况,但有时需要更大的灵活性。对于这些情况,标准库提供了 std::unique_lock 模板。与 std::lock_guard 一样,这是一个在互斥锁上的参数化类模板,它还提供与 std::lock_guard 相同的 RAII 风格(Resource Acquisition Is Initialization RAII)的锁管理,但具有更多的灵活性。

3.2.6 std::unique_lock的灵活加锁

std::unique_lock通过放宽非变量,提供了比 std::lock_guard 更多的灵活性;td::unique_lock 实例并不总是拥有与其关联的互斥锁。首先,正如你可以将 std::adopt_lock 作为构造函数第二个参数来管理加锁对象的互斥,你也可以std::defer_lock 作为第二个参数来表示互斥锁应该在构造时保持解锁状态。然后通过调用std::unique_lock(不是锁本身)的lock()或者将std::unique_lock本身传递给std::lock()来加锁。listing 3.6 可以像listing 3.9 一样轻松编写,使用 std::unique_lock 和 std::defer_lock [1] 而不是 std::lock_guard 和 std::adopt_lock。该代码具有相同的行数,并且本质上是等效的,除了一个小问题:std::unique_lock 占用更多空间,并且使用速度比 std::lock_guard 慢一点。允许 std::unique_lock 实例不拥有互斥锁的灵活性是有代价的:必须存储该信息,并且必须更新它。

Listing 3.9 Using std::lock() and std::unique_lock in a swap operation

class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X
{
private:
    some_big_object some_detail;
    std::mutex m;
public:
    X(some_big_object const& sd):some_detail(sd){}
    friend void swap(X& lhs, X& rhs)
    {
        if(&lhs==&rhs)
            return;
        std::unique_lock<std::mutex> lock_a(lhs.m,std::defer_lock);      |
        std::unique_lock<std::mutex> lock_b(rhs.m,std::defer_lock);     -| [1] std::defer_lock  leaves mutexes unlocked

        std::lock(lock_a,lock_b);                   -[2] Mutexes are locked here
        swap(lhs.some_detail,rhs.some_detail);
    }
};

在listing3.9中,std::unique_lock对象可以传递给std::lock()[2],因为td::unique_lock提供了lock()、try_lock()和unlock()成员函数。这些转发到底层互斥锁上的同名成员函数执行实际操作,std::unique_lock更新内部状态来表示当前互斥锁是否属于该实例,为了确保在析构函数中正确调用unlock(),此标志是必需的。如果实例确实拥有互斥锁,则析构函数必须调用unlock(),如果实例不拥有互斥锁,则不得调用unlock()。可以通过调用 owns_lock() 成员函数来查询该标志。

正如您所猜想的,这个标志必须存储在某个地方。因此,std::unique_lock 对象的大小通常大于 std::lock_guard 对象的大小,并且在使用 std::unique_lock 相较于 std::lock_guard 也会有轻微的性能损失,因为必须更新标志或视情况进行检查。因此如果 std::lock_guard 足以满足您的需求,我建议优先使用它。也就是说,在某些情况下 std::unique_lock 更适合手头的任务,因为您需要利用额外的灵活性。正如您已经看到的,一个例子是延迟锁定;另一种情况是锁的所有权需要从一个作用域转移到另一个作用域。

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

由于 std::unique_lock 实例不必拥有关联的互斥锁,因此可以通过转移实例在实例间转移互斥锁的所有权。在某些情况下,这种转移是自动的,例如从函数返回实例时,而在其他情况下,您必须通过调用 std::move() 显式地执行此操作。。从根本上来说,这取决于源是左值(真正的变量或者对变量的引用)还是右值(某种临时变量)。如果源是右值,则所有权转移是自动的,如果是左值则必须显式完成,以避免意外地将所有权从变量转移。std::unique_lock 是可移动但不可复制的类型的示例。有关移动语义的更多信息,请参阅附录 A 的 A.1.1 节。

一种可能的用途是允许函数锁定互斥锁并将该锁的所有权转移给调用者,以便调用者可以在同一锁的保护下执行其他操作。以下代码片段显示了一个示例:函数 get_lock() 锁定互斥锁,然后在将锁返回给调用者之前准备数据:

std::unique_lock<std::mutex> get_lock()
{
    extern std::mutex some_mutex;
    std::unique_lock<std::mutex> lk(some_mutex);
    prepare_data();
    return lk;			-[1]
}
void process_data()
{
    std::unique_lock<std::mutex> lk(get_lock());	-[2]
    do_something();
}

因为lk是函数内声明的自动变量,所以可以直接返回[1]无需调用std:move();编译器负责调用移动构造函数。然后,process_data() 函数可以将所有权直接转移到其自己的 std::unique_lock 实例中[2],并且对 do_something() 的调用可以依赖于正确准备的数据,而无需其他线程同时更改数据。

通常这类模型的使用场景是加锁时需要依赖当前问题的状态或者传入参数返回std::unique_lock对象,其中一种用法是不直接返回锁,而是返回一个用于保护数据的网关类。这种情况下,对数据的所有访问都是通过这个网关类:当您希望访问数据时获得网关类的实例(通过调用诸如前面示例中的 get_lock() 之类的函数),该实例获取锁。然后,您可以通过网关对象的成员函数访问数据。完成后,您将销毁网关对象,该对象会释放锁并允许其他线程访问受保护的数据。这样的网关对象很可能是可移动的(所以它可以被函数返回),在这种情况下,锁对象数据成员也需要是可移动的。

std::unique_lock 的灵活性还允许实例在被销毁之前放弃其锁定。您可以使用unlock()成员函数来做到这一点,就像互斥锁一样:std::unique_lock支持与互斥锁相同的基本加解锁成员函数操作,以便它可以与 std::lock 一样的通用函数一起使用。可以在std::unique_lock销毁前释放锁意味着,当不再需要加锁时,你可以在代码的某一个分支释放锁。对于应用程序的性能很重要;持有锁的时间过长可能会导致性能下降,因为其他等待锁的线程会被阻止更长时间。

3.2.8 以适当的粒度锁定

锁的粒度是我之前在 3.2.3 节中提到的内容:锁粒度是一个简单的术语,用于描述单个锁保护的数据量。细粒度锁保护少量数据,粗粒度锁保护大量数据。不仅选择足够粗的锁粒度以确保所需数据受到保护很重要,而且仅在必要操作时加锁也很重要。我们都知道在超市里等待排队结账有多糟糕,如果推着装满杂货的购物车在收银台排队等候时,却发现正在接受服务的人突然意识到他们忘记了一些蔓越莓酱,然后让每个人都在等待,而他们去寻找蔓越莓酱;或者收银员准备好付款,然后顾客才开始在钱包里翻找钱包。如果每个人都带着他们想要的一切并准备好适当的付款方式到结帐处,那么一切都会变得更加容易。

这同样适用于线程:如果多个线程正在等待相同的资源(结账时的收银员),那么如果任何线程持有锁的时间超过必要的时间,它将增加等待的总时间(在你到达并找到蔓越莓酱之前不要等待)。如果可能,仅在实际访问共享数据时锁定互斥锁;任何对数据的操作在锁的范围外。特别是,不要在持有锁时执行任何真正耗时的活动,例如文件 I/O。文件 I/O 通常比从内存中读取或写入相同量的数据慢数百(甚至数千)倍。因此,除非锁确实是为了保护对文件的访问,否则在持有锁的同时执行 I/O 将不必要地延迟其他线程(因为它们在等待获取锁时会被阻塞),从而可能消除多线程性能。

std::unique_lock 在这种情况下效果很好,因为您可以在代码不再需要访问共享数据时调用unlock(),然后在代码稍后需要访问时再次调用lock():

void get_and_process_data()
{
    std::unique_lock<std::mutex> my_lock(the_mutex);
    some_class data_to_process=get_next_data_chunk();
    my_lock.unlock();				-[1] Don’t need mutex locked across call to process()
    result_type result=process(data_to_process);
    my_lock.lock();				-[2] Relock mutex to write result
    write_result(data_to_process,result);
}

您不需要在调用 process() 期间锁定互斥锁,因此您可以在调用之前手动解锁它[1],然后在调用 之后再次锁定[2]。

希望足够明显:如果您有用一个互斥锁保护整个数据结构,那么不仅可能会出现更多的锁竞争,而且减少锁持有时间的可能性也会更小。更多的操作步骤将需要对同一个互斥锁进行锁定,因此必须保持锁定更长时间。开销的双重打击也促进了更细颗粒度的锁定。

如本示例所示,适当粒度的锁定不仅与锁定的数据量有关,还涉及锁持有多长时间以及锁持有期间执行哪些操作。总的来说,锁的持续时间应该是必要操作的最短时间。这也意味着除非绝对必要,否则不应在持有锁时执行耗时的操作,例如获取另一个锁(即使您知道它不会死锁)或等待 I/O 完成。

在listing 3.6 和 3.9 中,需要锁定两个互斥锁的操作是交换操作,这显然需要同时访问两个对象。假设你只需要比较两个简单的数据成员例如普通的int。这会有所不同吗?复制int的成本很低,因此您可以轻松地复制每个正在比较的对象的数据,同时仅持有该对象的锁,然后比较复制的值。这意味着您在每个互斥锁上持有锁的时间最短,而且您在锁定另一个锁时不会持有一个锁。下面的列表显示了属于这种情况的class Y 以及相等比较运算符的示例实现。

Listing 3.10 Locking one mutex at a time in a comparison operator

class Y
{
private:
    int some_detail;
    mutable std::mutex m;
    int get_detail() const
    {
        std::lock_guard<std::mutex> lock_a(m);      -[1]
        return some_detail;
    }
public:
    Y(int sd):some_detail(sd){}
    friend bool operator==(Y const& lhs, Y const& rhs)
    {
        if(&lhs==&rhs)
            return true;
        int const lhs_value=lhs.get_detail();       -[2]
        int const rhs_value=rhs.get_detail();       -[3]
        return lhs_value==rhs_value;                -[4]
    }
};

在这个例子中,比较运算符首先通过调用 get_detail() 成员函数来获取要比较的值[2][3]。该函数去的值并用锁保护它[1]。然后比较运算符比较取得的值[4]。但请注意,除了减少锁定周期以便一次只持有一个锁(从而消除死锁的可能性)之外,与同时持有两个锁相比,这还狡猾地改变了操作的语义。在listing 3.10中,如果运算符返回 true,则意味着某个时间点的 lhs.some_detail 值等于另一时间点的 rhs.some_detail 值。在两次读取之间,这两个值可以以任何方式改变;例如,这些值可以在 [2] 和 [3] 之间交换,从而使比较毫无意义。相等比较可能会返回 true 以表示这些值相等,即使这些值实际上从来没有相等的时刻。因此,在进行此类更改时要小心,不要以有问题的方式更改操作的语义,这一点很重要:如果您没有在操作的整个持续时间内持有所需的锁,那么您就会面临竞争场景

有时候并没有适当的粒度级别,因为并非所有对数据结构的访问都需要相同级别的保护。在这种情况下可以使用适合替代机制,而不是普通的 std::mutex。

posted @ 2024-07-13 01:11  李思默  阅读(84)  评论(0)    收藏  举报