「这是我参与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个主要的方法:
- top()获取栈顶数据
- push()数据压入栈顶
- pop()栈顶数据丢弃
- empty()判断堆栈是否为空
- 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接口,