C++-接口设计中的竞争条件

529 阅读5分钟

「这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战」 @[toc]

接口设计中的竞争条件

使用互斥锁的方式避免竞争条件时,要注意划分出真正需要保护的数据。此外即使保证了在数据上的操作是互斥的,接口设计本身也可能存在竞争条件。 std::stack定义部分如下:

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 &&);
    template <class... Args> void emplace(Args &&...args);//c++14
};

有5个主要的方法:

  1. top()获取栈顶数据
  2. push()数据压入栈顶
  3. pop()栈顶数据丢弃
  4. empty()判断堆栈是否为空
  5. size()返回堆栈大小

并发环境下引发的问题

在并发环境下,上面的接口问题在于,empty和size返回的信息不可靠,因为在调用它们之后,其他线程可以对堆栈执行push等操作:

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

当堆栈是共享的,存在的第一个问题是,1和2之间可能有其他线程对堆栈进行push或pop等操作,从而出现竞争条件(线程执行顺序的不同导致结果不同)。例如s原本不为空,在1和2之间另一个线程pop之后为空,此时2调用将引发未定义的行为。 第二个问题是,如果两个线程同时执行这段代码,我们假设栈中已经有两个元素,有可能出现如下执行序列: 在这里插入图片描述

可以看到两个线程top得到的值是一样的,此外栈底的那个元素还没被处理就被两次连续的pop调用丢弃掉了。这种竞争条件更为隐蔽,且不会像刚刚那种竞争条件会引发未定义的行为!

问题分析

对第一个问题,我们可以通过在top内部再次检查s是否为空的方法解决,如果为空,则抛出一个固定的异常,由外部处理。

而造成第二个问题的原因在于,接口将粒度划分得太细了,获取数据top和数据出栈操作pop分开使得在多线程环境下操作难以正确的执行,因此我们应该把这两个操作合并,即:

T pop();

但是在内存很少,系统负载很高情况下,元素首先出栈,然后元素值通过返回值返回并拷贝给外部变量,但在元素拷贝时可能失败从而引发std::bad_alloc异常,此时元素获取失败但是堆栈已经被改变了,也就意味着元素丢失了。std::stack设计了top和pop接口其实就能解决元素丢失问题:先获取元素,如果能成功将值拷贝到外部变量,那么再pop。 现在,我们必须坚持合并接口,因为如果不合并,问题2无法解决,而对于元素丢失问题,我们可以花一点额外的代价去解决:

  • 参数传入引用 引用的好处在于,我们可以先构造一个T变量,如果能构造成功,再作为参数传入pop进行赋值;如果失败,也不会丢失元素:
void pop(T& v)
{
    v=栈顶元素;
}

缺点在于,我们需要先构造一个T变量,但对有些元素,在当前位置无法获取到正确的参数来构造T变量,对有些类型来说,构造T变量开销很高。此外由于需要赋值,T类型必须是可赋值的——这是一个很大的限制,因为用户定义的数据可能不支持赋值(但支持move和拷贝构造)。

  • 要求T类型具有不会引发异常的拷贝构造函数或这移动构造函数 这意味着元素一定不会获取失败,不会存在元素丢失问题,但满足条件的T类型可能并不多

  • 返回一个指针 指针对元素类型没有要求,不过如果我们返回指针类型意味着需要管理这个指针指向的内存,对int这种简单的类型来说,管理内存的维护开销比直接返回值的方式的维护开销要大得多。一个好方法是使用std::shared_ptr,它可以避免内存泄露,因为在最后一个指针销毁时,数据会被删除;而且我们不需要使用new和delete,内存完全由标准库管理,

最终方案

下面我们定义一个线程安全的堆栈:

#include <exception>
#include <memory>
struct empty_stack : std::exception
{
    const char *what() const noexcept;
};
template <typename T>
class threadsafe_stack
{
public:
    threadsafe_stack();
    threadsafe_stack(const threadsafe_stack &);
    threadsafe_stack &operator=(const threadsafe_stack &) = delete;
    void push(T new_value);
    std::shared_ptr<T> pop();
    void pop(T &value);
    bool empty() const;
};

加上方法实现:

#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;
    }
    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>(data.top()));
        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();
    }
};

我们的pop有两个版本,由用户根据实际情况选择究竟使用那个版本更为方便。针对问题1我们抛出了empty_stack异常,针对问题2我们合并了原top和pop接口,