C++并发编程实战:基于锁的并发数据结构

设计基于锁的并发数据结构的奥义就是,要确保先锁定合适的互斥,再访问数据,并尽可能缩短持锁时间。即使仅凭一个互斥来保护整个数据结构,其难度也不容忽视。我们在第3章已经分析过,需要保证不得访问在互斥锁保护范围以外的数据,且成员函数接口上不得存在固有的条件竞争。若针对数据结构中的各部分分别采用独立互斥,这两个问题就会互相混杂而恶化。另外,假使并发数据结构上的操作需要锁住多个互斥,则可能会引发死锁。所以,相比只用一个互斥的数据结构,如果我们考虑采用多个互斥,就需要更加谨慎。

我们将遵从上一节的指引,在这一节设计几种简单的并发数据结构,使用互斥和锁来保护数据。我们要为各种并发数据结构提高并发程度,增加并发操作的实现机会,同时保证其线程安全。

第3章曾实现过并发的栈容器。它是我们手上最简单的并发数据结构,而且只用了一个互斥。它是线程安全的数据结构吗?以真正的并发作为衡量,它实现的并发程度算高吗?

6.2.1 采用锁实现线程安全的栈容器

第3章曾介绍过线程安全的栈容器,代码清单6.1再度列出其代码。我们意在编写类似std::stack<>的线程安全的栈容器,以支持数据的压入和弹出。

代码清单6.1 线程安全的栈容器的类定义

#include <exception>
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;
    }
    threadsafe_stack& operator=(const threadsafe_stack&) = delete;
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lock(m);
        data.push(std::move(new_value));    ⇽---  ①
    }
    std::shared_ptr<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>(std::move(data.top())));    ⇽---  ③
        data.pop();    ⇽---  ④
        return res;
    }
    void pop(T& value)
    {
        std::lock_guard<std::mutex> lock(m);
        if(data.empty()) throw empty_stack();
        value=std::move(data.top());    ⇽---  ⑤
        data.pop();    ⇽---  ⑥
    }
    bool empty() const
    {
        std::lock_guard<std::mutex> lock(m);
        return data.empty();
    }
};

我们逐一核对每条指引,看看它们该怎样用于本例。

首先,我们看到每个成员函数都在内部互斥m之上加锁,因此这保障了基本的线程安全。这种方式保证了任何时刻都仅有唯一线程访问数据。因此,只要每个成员函数都维持不变量,就没有线程能见到不变量被破坏。

其次,在empty()和每个pop()重载之间,都潜藏着数据竞争的隐患。然而,pop()函数不仅可以加锁,其还可以明文判别内部的栈容器是否为空,所以这不属于恶性数据竞争。以上设计并未沿用std::stack<>的既有模式,即提供两个分离的成员函数top()和pop(),而是让pop()直接返回弹出的数据,因此避开了原本可能存在的数据竞争。

接着,有几个操作可能产生异常。给互斥加锁可能产生异常,但这极其罕见,因为只有互斥本身存在问题或系统资源耗尽,才可能出现这种状况。再者,每个成员函数的第一项内部操作就是加锁,所以栈容器所存储的数据尚未发生改动,即便抛出异常也是安全行为。互斥的解锁不可能失败,故它肯定安全,而std::lock_guard<>则保证了绝不遗漏互斥的解锁操作。

data.push()的调用①有可能抛出异常,其诱因可能是复制/移动数据的过程抛出异常,也可能是底层的std栈容器在扩展容量时,不巧遇上内存分配不足。无论遇到哪种异常,内部的std::stack<>均能保证自身的安全,所以并不成问题。

在pop()的第一个重载中,代码可能抛出empty_stack异常②,但任何改动都尚未发生,因此它是安全的抛出行为。共享指针res的创建③有机会抛出异常,原因可能是内存不足而无法为新对象分配空间,也无法为引用计数而设的内部数据分配空间;也可能是虽然分配了内存空间,但在数据的移动/复制过程中,其复制构造函数或移动构造函数抛出异常。

针对这两种情形,C++运行库和标准库都保证不会出现内存泄漏,若存在创建失败残留的新对象,则会正确地销毁。因为底层栈容器依旧还没有改动,所以所存的数据还是安全的。data.pop()的调用④的实质操作是返回结果,它绝不会抛出异常,所以这一pop()重载是异常安全的重载。

pop()的第二个重载与第一个重载类似,不同之处在于,拷贝赋值操作符或移动赋值操作符会抛出异常⑤,而非创建新对象和std::shared_ptr实例。在调用data.pop()⑥之前,数据结构同样不发生改动,而按定义pop()不会抛出异常,所以这一pop()重载也是异常安全的重载。

最后,empty()不改动任何数据,是异常安全的函数。

这段代码有可能引起死锁,原因是我们在持锁期间执行用户代码:栈容器所含的数据中,有用户自定义的复制构造函数(①和③处的res构造)、移动构造函数(③处的make_shared)、拷贝赋值操作符和移动赋值操作符⑤,用户也有可能自行重载new操作符[1]。假使栈容器要插入或移除数据,在操作过程中数据自身调用了上述函数,则可能再进一步调用栈容器的成员函数,因而需要获取锁,但相关的互斥却已被锁住,最后导致死锁。向栈容器添加/移除数据,却不涉及复制行为或内存分配,这是不切实际的空想。合理的解决方式是对栈容器的使用者提出要求,由他们负责保证避免以上死锁场景。

栈容器的所有成员函数都使用std::lock_guard<>保护数据,因此,同时调用各成员函数的线程没有数量限制。仅有构造函数和析构函数不是安全的成员函数,但这不成问题:每个对象都只能分别构造一次和销毁一次。若对象未完成构造或销毁到一半,转去调用成员函数,那么无论是否按并发方式执行,这都绝非正确之举。所以,必须由使用者自己保证:若栈容器还未构建完成,则其他线程不得访问数据,并且,只有当全部线程都停止访问之后,才可销毁栈容器。

尽管在本例的栈容器上,由多线程并发调用成员函数是安全行为,但不论具体执行什么操作,锁的排他性仅容许一次只有一个线程访问数据。这将令多线程激烈争夺栈容器,迫使它们串行化,应用程序的性能很可能因此而受限:线程一旦为了获取锁而等待,就变得无所事事。另外,该栈容器并未提供任何等待/添加数据的操作,因此,假如栈容器满载数据而线程又等着添加数据,它就必须定期反复调用empty(),或通过调用pop()而捕获empty_stack异常,从而查验栈容器是否为空。万一真的出现这种情况,本例的栈容器实现就绝非最佳选择,因为等待的线程只有耗费宝贵的算力查验数据,或者栈容器使用者不得不另行编写代码,以在外部实现“等待-通知”的功能(如利用条件变量),令内部锁操作变得多余且浪费。第4章的队列容器在内部采用条件变量,将等待行为融合到数据结构中。接下来,我们来分析队列容器。

6.2.2 采用锁和条件变量实现线程安全的队列容器

第4章实现了线程安全的队列容器,如代码清单6.2所示,我们把代码重新列出。并发栈容器的实现基于std::stack<>,与之类似,并发队列容器的实现则以std::queue<>为蓝本。本例的数据结构要顾及多线程并发访问的安全,因此接口与标准库的版本同样有所不同。

代码清单6.2 完整的类定义:采用条件变量实现的线程安全的队列容器

template<typename T>
class threadsafe_queue
{
private:
    mutable std::mutex mut;
    std::queue<T> data_queue;
    std::condition_variable data_cond;
public:
    threadsafe_queue()
    {}
    void push(T new_value)
    {
        std::lock_guard<std::mutex> lk(mut);
        data_queue.push(std::move(new_value));
        data_cond.notify_one();    ⇽---  ①
    }
    void wait_and_pop(T& value)    ⇽---  ②
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        value=std::move(data_queue.front());
        data_queue.pop();
    }
    std::shared_ptr<T> wait_and_pop()    ⇽---  ③
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});    ⇽---  ④
        std::shared_ptr<T> res(
            std::make_shared<T>(std::move(data_queue.front())));
        data_queue.pop();
        return res;
    }
    bool try_pop(T& value)
    {
        std::lock_guard<std::mutex> lk(mut);
        if(data_queue.empty())
            return false;
        value=std::move(data_queue.front());
        data_queue.pop();
        return true;
    }
    std::shared_ptr<T> try_pop()
    {
        std::lock_guard<std::mutex> lk(mut);
        if(data_queue.empty())
            return std::shared_ptr<T>();    ⇽---  ⑤
        std::shared_ptr<T> res(
            std::make_shared<T>(std::move(data_queue.front())));
        data_queue.pop();
        return res;
    }
    bool empty() const
    {
        std::lock_guard<std::mutex> lk(mut);
        return data_queue.empty();
    }
};

代码清单6.2是并发队列容器的实现,它与代码清单6.1的栈容器相似,不同之处在于push()中的data_cond.notify_one()调用①,另外还增加了两个成员函数wait_and_pop()②③。try_pop()具有两个重载,与代码清单6.1的pop()几乎毫无二致。区别是即使队列容器全空,它们也不抛出异常。

其中一个重载返回布尔值,指明是否通过传入的引用成功获取了值;另一个重载则返回一个NULL指针,表示容器内不存在数据,因而无法通过指针返回⑤。并发栈容器的pop()同样可以采用以上模式。除了两个wait_and_pop()函数之外,前文针对栈容器的并发设计和分析在这里也成立。

并发栈容器的插入操作并不支持等待-通知功能,这里新增的两个wait_and_pop()函数意在解决该问题。等待弹出的线程再也不必连续调用empty(),它可以改为调用wait_and_pop(),队列容器会通过条件变量处理其等待。data_cond.wait()的调用会被阻塞,直到底层的队列容器中出现最少一个数据才返回,所以我们不必忧虑队列为空的状况;又因为互斥已经加锁,在等待期间数据仍然受到保护,所以这两个函数不会引入任何数据竞争,死锁也不可能出现,不变量保持成立。

本例对线程安全的处理与栈容器稍有不同:假定在数据压入队列的过程中,有多个线程同时在等待,那么data_cond.notify_one()的调用只会唤醒其中一个。然而,若该觉醒的线程在执行wait_and_pop()时抛出异常(譬如新指针std::shared_ptr<>在构建时就有可能产生异常④),就不会有任何其他线程被唤醒。如果我们不能接受这种行为方式,则将data_cond.notify_one()改为data_cond.notify_all(),这轻而易举。这样就会唤醒全体线程,但要大大增加开销:它们绝大多数还是会发现队列依然为空[2],只好重新休眠。第二种处理方式是,倘若有异常抛出,则在wait_and_pop()中再次调用notify_one(),从而再唤醒另一线程,让它去获取存储的值。第三种处理方式是,将std::shared_ptr<>的初始化语句移动到push()的调用处,令队列容器改为存储std::shared_ptr<>,而不再直接存储数据的值。从内部std::queue<>复制std::shared_ptr<>实例的操作不会抛出异常,所以wait_and_pop()也是异常安全的。我们采用最后一种处理方式改进并发队列容器,如代码清单6.3所示。

代码清单6.3 存储std::shared_ptr<>实例的线程安全的队列容器

template<typename T>
class threadsafe_queue
{
private:
    mutable std::mutex mut;
    std::queue<std::shared_ptr<T>> data_queue;
    std::condition_variable data_cond;
public:
    threadsafe_queue()
    {}
    void wait_and_pop(T& value)
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        value=std::move(*data_queue.front());    ⇽---  ①
        data_queue.pop();
    }
    bool try_pop(T& value)
    {
        std::lock_guard<std::mutex> lk(mut);
        if(data_queue.empty())
            return false;
        value=std::move(*data_queue.front());    ⇽---  ②
        data_queue.pop();
        return true;
    }
    std::shared_ptr<T> wait_and_pop()
    {
        std::unique_lock<std::mutex> lk(mut);
        data_cond.wait(lk,[this]{return !data_queue.empty();});
        std::shared_ptr<T> res=data_queue.front();    ⇽---  ③
        data_queue.pop();
        return res;
    }
    std::shared_ptr<T> try_pop()
    {
        std::lock_guard<std::mutex> lk(mut);
        if(data_queue.empty())
            return std::shared_ptr<T>();
        std::shared_ptr<T> res=data_queue.front();    ⇽---  ④
        data_queue.pop();
        return res;
    }
    void push(T new_value)
    {
        std::shared_ptr<T> data(
            std::make_shared<T>(std::move(new_value)));    ⇽---  ⑤
        std::lock_guard<std::mutex> lk(mut);
        data_queue.push(data);
        data_cond.notify_one();
    }
    bool empty() const
    {
        std::lock_guard<std::mutex> lk(mut);
        return data_queue.empty();
    }
};

队列数据所属的类型从值变成了共享指针,因而需要连带改动相关代码:其中两个pop()函数接收外部变量的引用作为参数,其功能是保存结果。原来的代码直接向它存入从底层队列容器获取的值,这里则需先根据指针提取出值①②,再将其作为结果存入。而另外两个pop()函数则返回std::shared_ptr<>实例,它们先从底层队列容器取出结果③④,再返回给外部使用者。

如果数据通过std::shared_ptr<>间接存储,还会产生额外的好处:在push()中,我们依然要为新的std::shared_ptr<>实例分配内存⑤,这样可以脱离锁保护,但是按代码清单6.2的处理方式,内存操作必须在持锁状态下进行。内存分配往往是成本相当高的操作,而新的队列以安全方式为其免除了锁保护,遂缩短了互斥的持锁时长,在分配内存的时候,还容许其他线程在队列容器上执行操作,因此非常有利于增强性能。

这个并发队列容器与前文的栈容器相似,缺点都是由唯一的互斥保护整个数据结构,它所支持的并发程度因此受限。虽然多个线程上的阻塞可能在不同成员函数中发生,但是事实上每次只容许一个线程操作队列数据。该限制的部分原因是,这个实现基于标准库的std::queue<>容器,我们实际上将它视为一项大数据,或施加整体保护,或完全不保护。若能掌控数据结构的实现细节,我们就能提供粒度更精细的锁,以提高并发程度。

6.2.3 采用精细粒度的锁和条件变量实现线程安全的队列容器

在代码清单 6.2 和代码清单6.3中,我们其实仅保护了一项数据,即整个内部队列data_queue,遂只用到一个互斥。为了采取精细粒度的锁操作,我们需要深入队列的实现,分析其组成,为不同的数据单独使用互斥。

单向链表是可以充当队列的最简单的数据结构[3],如图6.1所示。队列含有一个“头指针head”,它指向头节点,每个节点再依次指向后继节点。队列弹出数据的方法是更改head指针:将指向目标改为其后继节点,并返回原来的第一项数据。

图6.1 单向链表形式的队列

新数据从队列末端加入,其实现方式是,队列另外维护一个“尾指针tail”,指向尾节点。假如有新节点加入,则将尾节点的next指针指向新节点,并更新tail指针,令其指向新节点。如果队列为空,则将head指针和tail指针都设置为NULL。

代码清单6.4是这种队列的简单实现,它以代码清单6.2为基础,接口有所裁减。这个版本仅支持单线程,它只有一个try_pop()函数,尚不具备wait_and_pop()函数。

代码清单6.4 单线程队列的简单实现

template<typename T>
class queue
{
private:
    struct node
    {
        T data;
        std::unique_ptr<node> next;
        node(T data_):
            data(std::move(data_))
        {}
    };
    std::unique_ptr<node> head;    ⇽---  ①
    node* tail;    ⇽---  ②
public:
    queue(): tail(nullptr)
    {}
    queue(const queue& other)=delete;
    queue& operator=(const queue& other)=delete;
    std::shared_ptr<T> try_pop()
    {
        if(!head)
        {
            return std::shared_ptr<T>();
        }
        std::shared_ptr<T> const res(
            std::make_shared<T>(std::move(head->data)));
        std::unique_ptr<node> const old_head=std::move(head);
        head=std::move(old_head->next);    ⇽---  ③
        if(!head)
            tail=nullptr;
        return res;
    }
    void push(T new_value)
    {
        std::unique_ptr<node> p(new node(std::move(new_value)));
        node* const new_tail=p.get();
        if(tail)
        {
            tail->next=std::move(p);    ⇽---  ④
        }
        else
        {
            head=std::move(p);    ⇽---  ⑤
        }
        tail=new_tail;    ⇽---  ⑥
    }
};

首先,请注意代码清单6.4采用std::unique_ptr<node>管控节点,通过其自身特性确保,当我们不再需要某个节点时,它和所包含的数据即被自动删除,我们不必明文编写相关操作的代码。从队列的头节点开始一直到队列末端,相邻节点之间都按前后顺序形成归属关系。末端节点已划归前方节点的std::unique_ptr<node>指针所有,但我们仍须对其进行直接操控,所以通过一个原生指针(前文提及的“tail指针”)指向它。

虽然这种实现在单线程模式下工作良好,但是若我们换成多线程模式,并试图配合精细粒度的锁,其中几个细节就会引发问题。假设队列含有两项数据——head指针①和tail指针②,原则上我们可以使用两个互斥分别保护head指针和tail指针,但问题随之而来。

最明显的问题是,push()可以同时改动head指针⑤和tail指针⑥,所以该函数就需要将两个互斥都锁住。尽管这并不合适,但同时锁住两个互斥的做法还算可行,问题不严重。严重的问题在于,push()和try_pop()有可能并发访问同一节点的next指针:push()更新tail→next④,而try_pop()则读取head→next③。如果队列仅含有一项数据,即head==tail,那么head→next和tail→next两个指针的目标节点重合,而它需要保护。假定我们没有读取头节点和尾节点的内部数据,无从辨别它们是否为同一个节点,就会在同时执行push()和try_pop()的过程中,无意中试图锁定同一互斥,相比以前并无改进。如何突破困局?

1.通过分离数据而实现并发

我们可以预先设立一个不含数据的虚位节点(dummy node),从而确保至少存在一个节点,以区别头尾两个节点的访问。如果队列为空,head 和tail两个指针都不再是NULL值,而是同时指向虚位节点。这很不错,因为空队列的try_pop()不会访问head→next。若我们向队列添加数据(则会出现一个真实节点),则head和tail指针会分别指向不同的节点,在head→next和tail→next上不会出现竞争。但其缺点是,为了容纳虚位节点,我们需要通过指针间接存储数据,额外增加了一个访问层级,如代码清单6.5所示。

代码清单6.5 带有虚位节点的简单队列

template<typename T>
class queue
{
private:
    struct node
    {
        std::shared_ptr<T> data;    ⇽---  ①
        std::unique_ptr<node> next;
    };
    std::unique_ptr<node> head;
    node* tail;
public:
    queue():
        head(new node),tail(head.get())    ⇽---  ②
    {}
    queue(const queue& other)=delete;
    queue& operator=(const queue& other)=delete;
    std::shared_ptr<T> try_pop()
    {
        if(head.get()==tail)    ⇽---  ③
        {
            return std::shared_ptr<T>();
        }
        std::shared_ptr<T> const res(head->data);    ⇽---  ④
        std::unique_ptr<node> old_head=std::move(head);
        head=std::move(old_head->next);    ⇽---  ⑤
        return res;    ⇽---  ⑥
    }
    void push(T new_value)
    {
        std::shared_ptr<T> new_data(
            std::make_shared<T>(std::move(new_value)));    ⇽---  ⑦
        std::unique_ptr<node> p(new node);    ⇽---  ⑧
        tail->data=new_data;    ⇽---   ⑨
        node* const new_tail=p.get();
        tail->next=std::move(p);
        tail=new_tail;
    }
};

try_pop()的改动相当小。首先,由于引入了虚位节点,head指针不再取值NULL,因此我们不再判别它是否为NULL,而改为比较指针head和tail是否重叠③。因为head指针的类型是std::unique_ptr<node>,所以我们调用head.get()来进行比较运算。其次,节点现已改为通过指针存储数据①,所以在弹出操作中,我们直接获取指针④,而不再构建T类型的实例。最大的变化是push(),我们必须先在堆数据段上创建T类型的新实例,通过std::shared_ptr<>管控其归属权⑦(请注意我们采用了std::make_shared(),以避免因引用计数而出现重复内存分配)。新创建的节点即为虚位节点,故无须向构造函数提供new_value值⑧。为了代替原来的增加数据的行为,我们将前面的共享指针⑦存入原来的虚位节点⑨,则该节点的数据变为新近创建的new_value副本。最后,我们在队列的构造函数中创建虚位节点②。

行文至此,相信读者会问,这些改动带来了什么好处?它们对队列的线程安全有何帮助?

回答是,push()只访问tail指针而不再触及head指针,这就是一个好处。虽然try_pop()既访问head指针又访问tail指针,但tail指针只用于函数中最开始的比较运算,所以只需短暂持锁。最大的好处来自虚位节点,它存在的意义是:try_pop()和push()不再同时操作相同的节点,所以我们不再需要由一个互斥统领全局。换言之,指针head和tail可以各用一互斥保护。但是,具体应该在哪一处加锁呢?

我们的目标是最大程度实现真正的并发功能,让尽可能多的操作有机会并发进行,所以希望持锁时长最短。push()不难处理。tail指针的全部访问都需要对互斥加锁,即新节点一旦创建完成,我们就马上锁住互斥⑧,在将数据赋予当前的尾节点之前⑨,也要锁住互斥。该锁需要一直持有,等到函数结束才释放。

try_pop()的处理则不太简单。首先,我们需要为head指针锁住互斥并一直持锁,等到它使用完成才解锁。互斥会被多个线程争抢,这将决定哪个线程弹出数据,故我们在最开始就要锁定互斥。一旦head指针的改动完成⑤,互斥即可解锁,结果的返回操作⑥无须互斥保护。

余下的只有tail指针的访问,它需要在对应的互斥上加锁。因为我们只需在try_pop()内部访问tail指针一次,所以在临近读取指针之前再对互斥加锁。最好将加锁和访问包装成同一个函数。实际上,因为仅有try_pop成员函数中的部分语句需锁住head_mutex,所以将它们包装成一个函数会显得更清晰,如代码清单6.6所示。

代码清单6.6 带有精细粒度锁的线程安全队列

template<typename T>
class threadsafe_queue
{
private:
    struct node
    {
        std::shared_ptr<T> data;
        std::unique_ptr<node> next;
    };
    std::mutex head_mutex;
    std::unique_ptr<node> head;
    std::mutex tail_mutex;
    node* tail;
    node* get_tail()
    {
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        return tail;
    }
    std::unique_ptr<node> pop_head()
    {
        std::lock_guard<std::mutex> head_lock(head_mutex); 

        if(head.get()==get_tail())
        {
            return nullptr;
        }
        std::unique_ptr<node> old_head=std::move(head);
        head=std::move(old_head->next);
        return old_head;
    }
public:
    threadsafe_queue():
        head(new node),tail(head.get())
    {}
    threadsafe_queue(const threadsafe_queue& other)=delete;
    threadsafe_queue& operator=(const threadsafe_queue& other)=delete;
    std::shared_ptr<T> try_pop()
    {
        std::unique_ptr<node> old_head=pop_head();
        return old_head?old_head->data:std::shared_ptr<T>();
    }
    void push(T new_value)
    {
        std::shared_ptr<T> new_data(
            std::make_shared<T>(std::move(new_value)));
        std::unique_ptr<node> p(new node);
        node* const new_tail=p.get();
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        tail->data=new_data;
        tail->next=std::move(p);
        tail=new_tail;
    }
};

我们回想6.1.1节的指引,按严格的标准评判这段代码。我们先来明确程序含有哪些不变量,接着再查证它们是否被破坏。

  • tail→next==nullptr。
  • tail→data==nullptr。
  • head==tail说明队列为空。
  • 在单元素队列中,head→next==tail。
  • 对于每个节点x,只要x!=tail,则x→data指向一个T类型的实例,且x→next指向后续节点。
  • x→next==tail说明x是最后一个节点。从head指针指向的节点出发,我们沿着next指针反复访问后继节点,最终会到达tail指针指向的节点。

push()本身是清晰简单的操作,其仅有的数据结构的改动行为受到互斥tail_mutex保护,这些改动行为维持不变量成立。因为新的尾节点是空节点,而且旧的尾节点的data成员和next指针都设置正确,所以该尾节点现在成了队列中的最后一个真实节点。

try_pop()则稍微复杂。分析表明,在互斥tail_mutex上加锁,不仅对读取tail指针是必要的保护,当我们从头节点开始读取数据时,该加锁操作也必不可少,它保证了数据竞争不会出现。若缺少这个互斥,try_pop()和push()就很可能由不同线程并发调用,无法确定这两项操作的先后次序。虽然每个成员函数都在互斥上持锁,但是它们锁住的互斥各不相同,所以它们有可能访问相同的数据。毕竟队列里的全部数据都来自push()的调用,数据都由其增加。多个线程可能会访问同一项数据,而不服从一定的内存次序。根据第5章的分析,这有可能构成数据竞争,并出现未定义行为。万幸,在get_tail()函数中互斥tail_mutex的锁定解决了这一切。由于get_tail()和push()两个调用都会锁住该互斥,因此两个调用之间会服从确定的内存次序。get_tail()的调用或在push()开始前发生,或在其完成后发生。如果是前者,get_tail()只会见到tail指针的旧值;如果是后者,get_tail()就会见到tail指针已被赋予新值,还会见到原来的尾节点存入了新增的数据。

get_tail()的调用在head_mutex保护范围之内,这点也很重要。如若不然,pop_head()会在内部先调用get_tail(),再对互斥head_mutex加锁,代码如下。在这种情况中,可能其他线程已经调用了try_pop(),进而调用pop_head(),锁住了互斥head_mutex,令pop_head()受阻而停滞不前,导致更严重的问题。

    std::unique_ptr<node> pop_head()    ⇽---  ①这个实现有缺陷
    {
        node* const old_tail=get_tail();    ⇽---  ②在互斥head_mutex的保护范围以外取得tail指针的旧值
        std::lock_guard<std::mutex> head_lock(head_mutex); 

        if(head.get()==old_tail)    ⇽---  ③
        {
            return nullptr;
        }
        std::unique_ptr<node> old_head=std::move(head);
        head=std::move(old_head->next);    ⇽---  ④
        return old_head;
    }

上面的代码中,get_tail()的调用②在锁的作用域以外发生,导致暗藏隐患:等到当前线程可以在互斥head_mutex上加锁的时候,指针head和tail有可能都发生了更改,get_tail()返回的节点可能不再是尾节点,甚至可能不再是队列的组成节点。即便指针head确实指向了最后一个节点③,它和指针old_tail的比较也有可能不成立。结果,在更新head指针时④,可能令它外移,越过队列的尾节点,破坏整个数据结构。在代码清单6.6中,get_tail()的调用处于互斥head_mutex的保护范围之内,因而该实现方式正确、可行。这首先保证了其他线程都无法改变head指针,还保证了在调用push()加入新节点时,tail指针只能从队列末尾向外移动,该行为绝对安全。head指针不可能越过get_tail()所返回的位置,不变量遂保持成立。

一旦pop_head()将头节点从队列移除(方式是更新head指针),互斥随即解锁。接着,假如头结点是真实节点,try_pop()就提取出数据并销毁节点[4];假如是虚拟节点,则try_pop()返回一个含有NULL值的std::shared_ptr<>实例。我们清楚,执行的线程是头节点的唯一访问者,因此try_pop()是安全操作。

下一个设计是队列的对外接口,它们是代码清单6.2的一部分。所以这里的分析与前文相同,结论同样是接口中不存在固有的条件竞争。

异常的处理就更复杂了。因为我们改变了数据的内存分配模式,所以异常可能由不同的代码抛出。try_pop()中仅有一项操作会抛出异常,即互斥加锁,在获取锁之后,数据才会发生改动。因此,try_pop()是异常安全的函数。另一方面,push()在堆上分配内存以创建两个实例,它们分别属于T类型和node类型,两次内存分配都有可能抛出异常。但这两个新创建的对象都被赋予智能指针,万一有异常抛出,它们所占用的内存会被自动释放。在获取锁之后,push()余下的任何操作都不会抛出异常,所以任务圆满完成,push()是异常安全的函数。

我们没有改变接口的外在形式,所以死锁无法乘虚而入。成员函数内部同样无懈可击,唯一需要获取两个锁的操作位于pop_head()内,而它总是先锁住互斥head_mutex,然后对tail_mutex加锁,故死锁不会出现。

我们关注的终极问题是并发是否真正可行。相比代码清单6.2的实现,这份数据结构threadsafe_queue的并发潜能要大得多,因为这里采用的锁粒度更精细,更多的操作在锁保护以外完成。例如,push()函数在没有持锁的状态下,为新节点和新数据完成了内存分配。其意义是,多个线程能为新节点和新数据并发分配内存,而不产生任何问题。每次只有一个线程可将生成的新节点加入队列,只涉及几个简单的指针赋值操作,所以这里的代码持锁时长很短。相比之下,基于std::queue<>的实现则不然,因为它要为std::queue<>的一切内存分配操作加锁。

同样,try_pop()只在互斥tail_mutex上短暂持锁,以保护tail指针的读取。因此,try_pop()的整个调用过程几乎都可以与push()并发执行。队列节点通过unique_ptr<node>的析构函数删除,该操作开销高,所以在互斥head_mutex的保护范围以外执行,因而在其锁定期间执行的操作也被缩减至最少。这样就增加了try_pop()的并发调用数目,虽然每次只容许一个线程调用pop_head(),但多个线程可以并发执行try_pop()的其他部分,安全地删除各自旧有的头节点并返回数据。

2.等待数据弹出

代码清单6.6实现了线程安全的队列,其中运用了精细粒度的锁操作,但它只支持try_pop()(也只存在唯一一个重载)。然而代码清单 6.2 还提供了使用方便的wait_and_pop()函数,我们能否借助精细粒度的锁操作实现相同功能的函数?

当然能。问题是具体要怎么做?修改push()似乎并不困难:在函数末尾加上对data_cond.notify_one()的调用即可,与代码清单6.2一样。事情其实没那么简单,我们之所以采用精细粒度的锁,目的是尽可能提高并发操作的数量。如果在notify_one()调用期间,互斥依然被锁住,形式与代码清单6.2一样,而等待通知的线程却在互斥解锁前觉醒,它就需要继续等待互斥解锁。矛盾的是,若在解锁互斥之后调用notify_one(),那么互斥已经可以再次获取,并且超前一步,等着接受通知的线程对其加锁(前提是其他线程没有抢先将其重新锁住)。这点改进看似细微,但对某些情况却有重要作用。

wait_and_pop()就复杂得多,因为我们需要确定在哪里等待、根据什么断言唤醒等待、需要锁住什么互斥等。等待唤醒的条件是“队列非空”,用代码表示为head!=tail。按这种写法,要求两个互斥head_mutex和tail_mutex都被锁住,我们分析代码清单6.6的时候就已经确定,只有在读取tail指针时才有必要锁住互斥tail_mutex,而比较运算无须保护,本例同理。若我们将断言设定为head!=get_tail(),则只需持有互斥head_mutex,所以在调用data_cond.wait()时,就可以重新锁住head_mutex[5]。只要我们加入了等待的逻辑,这种实现就与try_pop()一样。

对于try_pop()的另一个重载和对应的wait_and_pop()的重载,我们也要谨慎思考和设计。在代码清单6.6中,try_pop()函数的结果通过共享指针std::shared_ptr<>的实例返回,其指向目标由old_head间接从pop_head()取得。如果模仿代码清单6.2,将以上方法改为try_pop()的第一个重载的模式,令函数接收名为value的引用参数,再由拷贝赋值操作赋予它old_head的值,就可能会出现与异常有关的问题。根据这种改动,在拷贝赋值操作执行时,数据已经从队列中移除,且互斥已经解锁,剩下的全部动作就是将数据返回给调用者。但是,如果拷贝赋值操作抛出了异常(完全有可能),则该项数据丢失,因为它无法回到队列本来的位置上。

若队列模板在具现化时,模板参数采用了实际类型T,而该类型支持不抛出异常的移动赋值操作,或不抛出异常的交换操作,我们即可使用类型T。然而,我们还是更希望实现通用的解决方法,对任何类型T都有效。在上述场景中,我们需要在队列移除节点以前,将可能抛出异常的操作移动到锁的保护范围之内。换言之,我们还需要pop_head()的另一个重功,在改动队列之前就获取其存储的值。

相比而言,empty()就很简单了:只需锁住互斥head_mutex,然后检查head==get_tail()(见代码清单6.10,该处head节点的指针由head.get()获得)。队列实现的最终代码由代码清单6.7~代码清单6.10给出。

代码清单6.7 采用锁操作并支持等待功能的线程安全的队列:内部数据和对外接口

template<typename T>
class threadsafe_queue
{
private:
    struct node
    {
        std::shared_ptr<T> data;
        std::unique_ptr<node> next;
    };
    std::mutex head_mutex;
    std::unique_ptr<node> head;
    std::mutex tail_mutex;
    node* tail;
    std::condition_variable data_cond;
public:
    threadsafe_queue():
        head(new node),tail(head.get())
    {}
    threadsafe_queue(const threadsafe_queue& other)=delete;
    threadsafe_queue& operator=(const threadsafe_queue& other)=delete;
    std::shared_ptr<T> try_pop();
    bool try_pop(T& value);
    std::shared_ptr<T> wait_and_pop();
    void wait_and_pop(T& value);
    void push(T new_value);
    bool empty();
};

代码清单6.8实现了向队列压入新节点的操作,其过程相当直观,这个实现与前文所示版本十分接近。

代码清单6.8 采用锁操作并支持等待功能的线程安全的队列:压入新数据

template<typename T>
void threadsafe_queue<T>::push(T new_value)
{
    std::shared_ptr<T> new_data(
        std::make_shared<T>(std::move(new_value)));
    std::unique_ptr<node> p(new node);
    {
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        tail->data=new_data;
        node* const new_tail=p.get();
        tail->next=std::move(p);
        tail=new_tail;
    }
    data_cond.notify_one();
}

我们曾经提过,复杂之处全在于pop()上,它运用几个辅助函数简化操作。代码清单6.9展示了wait_and_pop()及其辅助函数的实现。

代码清单6.9 采用锁操作并支持等待功能的线程安全的队列:wait_and_pop()

template<typename T>
class threadsafe_queue
{
private:
    node* get_tail()
    {
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        return tail;
    }
    std::unique_ptr<node> pop_head()    ⇽---  ①
    {
        std::unique_ptr<node> old_head=std::move(head);
        head=std::move(old_head->next);
        return old_head;
    }
    std::unique_lock<std::mutex> wait_for_data()    ⇽---  ②
    {
        std::unique_lock<std::mutex> head_lock(head_mutex);
        data_cond.wait(head_lock,[&]{return head.get()!=get_tail();});
        return std::move(head_lock);    ⇽---  ③
    }
    std::unique_ptr<node> wait_pop_head()
    {
        std::unique_lock<std::mutex> head_lock(wait_for_data());    ⇽---  ④
        return pop_head();
    }
    std::unique_ptr<node> wait_pop_head(T& value)
    {
        std::unique_lock<std::mutex> head_lock(wait_for_data());    ⇽---  ⑤
        value=std::move(*head->data);
        return pop_head();
    }
public:
    std::shared_ptr<T> wait_and_pop()
    {
        std::unique_ptr<node> const old_head=wait_pop_head();
        return old_head->data;
    }
    void wait_and_pop(T& value)
    {
        std::unique_ptr<node> const old_head=wait_pop_head(value);
    }
};

代码清单 6.9 展示出wait_and_pop()实现代码,它含有几个辅助函数,用以简化代码和减少重复,如pop_head()①和wait_for_data()②。前者移除头节点而改动队列,后者则等待数据加入空队列,以将其弹出。wait_for_data()特别值得注意,它在条件变量上等待,以lambda函数作为断言,并且向调用者返回锁的实例③。因为wait_pop_head()的两个重载都会改动队列数据,并且都依赖wait_for_data()函数,而后者将锁返回则保证了头节点弹出的全过程都持有同一个锁④⑤。这里的pop_head()也为try_pop()复用,如代码清单6.10所示。

代码清单6.10 采用锁操作并支持等待功能的线程安全的队列:try_pop()和empty()

template<typename T>
class threadsafe_queue
{
private:
    std::unique_ptr<node> try_pop_head()
    {
        std::lock_guard<std::mutex> head_lock(head_mutex);
        if(head.get()==get_tail())
        {
            return std::unique_ptr<node>();
        }
        return pop_head();
    }
    std::unique_ptr<node> try_pop_head(T& value)
    {
        std::lock_guard<std::mutex> head_lock(head_mutex);
        if(head.get()==get_tail())
        {
            return std::unique_ptr<node>();
        }
        value=std::move(*head->data);
        return pop_head();
    }
public:
    std::shared_ptr<T> try_pop()
    {
        std::unique_ptr<node> old_head=try_pop_head();
        return old_head?old_head->data:std::shared_ptr<T>();
    }
    bool try_pop(T& value)
    {
        std::unique_ptr<node> const old_head=try_pop_head(value);
        return old_head;
    }
    bool empty()
    {
        std::lock_guard<std::mutex> head_lock(head_mutex);
        return (head.get()==get_tail());
    }
};

第7章将讲解无锁队列,它以这个队列的实现作为蓝本。这个队列是无限队列。只要存在空闲内存,即便已存入的数据没有被移除,各个线程还是能持续往队列添加新数据。与之对应的是有限队列,其最大长度在创建之际就已固定。一旦有限队列容量已满,再试图向其压入数据就会失败,或者发生阻塞,直到有数据弹出而产生容纳空间为止。有限队列可用于多线程的工作分配,它能够依据待执行的任务的数量,确保工作在各线程中均匀分配。它能防止以下情形发生:某些线程向队列添加任务的速度过快,远超线程从队列领取任务的速度。

要实现这个功能,仅需简单地扩展本节讲解的无限队列代码:只需限制push()中的条件变量上的等待数量。我们需要等待队列中的数据被弹出(由pop()执行),所含数据数目小于其最大容量,而不是等着有数据被压入而使队列非空。关于有限队列的进一步讨论已经超出本书范围。现在,我们来研究更加复杂的数据结构。

本文摘自:C++并发编程实战(第2版)

这是一本介绍C++并发和多线程编程的深度指南。本书从C++标准程序库的各种工具讲起,介绍线程管控、在线程间共享数据、并发操作的同步、C++内存模型和原子操作等内容。同时,本书还介绍基于锁的并发数据结构、无锁数据结构、并发代码,以及高级线程管理、并行算法函数、多线程应用的测试和除错。本书还通过附录及线上资源提供丰富的补充资料,以帮助读者更完整、细致地掌握C++并发编程的知识脉络。

本书适合需要深入了解C++多线程开发的读者,以及使用C++进行各类软件开发的开发人员、测试人员,还可以作为C++线程库的参考工具书。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值