文章目录
适用于并发的数据结构的要求
数据结构的线程安全
- 多线程执行的操作无论异同,每个线程所见的数据结构都是自洽的。
- 数据不会丢失或损坏,所有不变量始终成立,恶性条件竞争不会出现。
并发的目的
- 并发设计意义在于提供更高的并发程度,让各个线程有更多机会按照并发的方式访问数据结构。但是,互斥保护数据结构的方式是阻止真正的并发访问,使多个访问相互排斥,一个互斥每次只有一个线程可以获得锁。这个行为成为串行化,每个线程轮流持有锁来访问数据结构。
如何提高并发程度
- 在设计数据结构时需要深思熟虑,力求实现真正的并发,相对而言有一个统一的宗旨:保护的范围越小,需要串行化的操作越少, 并发程度的可能就越高。
1. 线程安全的栈容器
struct threadsafe_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);
// -------------------- tag1
data.push(std::move(new_value));
}
std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m);
// -------------------- tag2
if (data.empty())
throw empty_stack();
// -------------------- tag3
std::shared_ptr<T> const res(std::make_shared<T>(std::move(data.top())));
// -------------------- tag4
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()
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
数据竞争避免
- 首先所有成员函数内加锁,保证基本的线程安全。
- pop没有使用原有std::stack的设计,区分top和pop两个函数,避免了接口可能存在的数据竞争
可能的异常处理
- 给互斥加锁可能出现异常,很罕见,一般出现在系统资源耗尽,同时成员函数的第一项操作就是加锁,所以在存储内容发生改变前抛出异常也是安全的。解锁互斥不会失败,是安全的,同时通过 std::lock_guard 保证不会遗漏。
- tag1 data.push() 可能抛出异常, 可能是数据复制拷贝时导致的,也可能是底层扩展容量时碰到内存分配不足,std::stack 内部可以保证自身安全。
- tag2 可能抛出 empty_stack 异常,此时无改动发生,所以安全。
- tag3 共享指针分配也可能因为内存不足或者数据移动过程中复制构造函数或者移动构造函数导致异常,但C++运行库和标准库能保证不会出现内存泄漏,残留的对象也能正常销毁。此时栈容器还没有实际修改,所以也安全。
- tag4 data.pop() 实际上修改栈容器,不会抛出异常。
- pop 的另一个重载与第一个类似,不同的是不需要创建共享指针,同理也是异常安全。
- 最后的 empty() 没有数据修改,也安全。
可能存在的死锁
栈容器所包含的数据中,有用户自定义的复制构造函数,移动构造函数,用户也可能重载new操作符。假如在栈容器插入或移除过程中,再次调用了栈容器的成员函数,会造成死锁,因为需要的互斥已经锁住了。因此需要对用户做出要求,栈容器保存数据的构造函数中不能再调用栈容器的成员函数。
并发性能分析
- 锁的排他性导致仅容许每次只有一个线程访问数据,多线程访问被迫串行化。线程一但为了获得锁而等待,就变得无所事事。
- 没有通知机制,线程需要定期调用 empty() ,或者在 pop() 时判断 empty_stack 异常来校验容器是否为空。
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<T> try_pop()
{
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty())
{
return std::make_shared<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();
}
};
优化
- 两个 try_pop 实现,没有抛出异常。一个通过布尔值表示是否成功,通过引用返回值。另一个则是用空指针表示容器为空,栈容器也可以用同样的模式, 之前的分析同样成立。
- 两个 wait_pop() 函数支持了等待-通知功能,等待数据弹出的线程不必连续调用 empty() 判断是否有元素。data_cond.wait() 会阻塞,直到至少有一个数据才返回。因为已经加锁等待期间数据也受保护。
问题
数据在压入队列的过程中,有多个线程同时再等待,那么 data_cond.notify_one() 只会唤醒其中一个。但是,如果唤醒的线程在执行 wait_and_pop() 过程中抛出异常,就不会有其他线程再被唤醒,比如在共享指针构造的过程中出现异常。
相对的有两个方案可以解决,其一就是改为 notify_all() ,这样所有线程会被唤醒,但是大部分线程会发现队列依然为空,这样开销很大。另一个办法就是在异常出现后再次调用 notify_one() ,再唤醒另一条线程去处理。但是这两个办法都不尽如人意。
3. 储存共享指针实例的队列
为了解决上述问题,我们可以把 shared_ptr 的初始化放在 push() 调用中,指针的复制是不会抛出异常,所以也能保证异常安全。
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 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();
}
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 = 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<T> try_pop()
{
std::lock_guard<std::mutex> lk(mut);
if (data_queue.empty())
{
return std::make_shared<T>();
}
std::shared_ptr<T> res = data_queue.front();
data_queue.pop();
return res;
}
bool empty() const
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
优化
- 队列存储对象改为存储共享指针,在 push() 中分配内存,可以脱离锁的保护,在构造成功后再持有锁,内存分配往往是耗时的操作,这样可以减少持有锁的时长。
- 避免了抛出数据时的拷贝导致的异常。
问题
- 相较于最初版本,改进了一部分问题。但是并发程度仍然收到限制,究其原因,锁的粒度太大了。锁保护的是整个 std::queue<> 容器,如果能掌握数据结构的实现细节,就能提供颗粒度更精细的锁来提高并发。
总结
为了设计合适的支持并发的数据结构,首先要确保锁定合适的互斥,其次要尽可能缩短锁持有的时间。
- 确保锁定合适的互斥,我们需要检查成员函数是否正确加锁,分析是否存在接口导致的数据竞争,是否会有数据修改后抛出异常的问题。
- 为了缩短锁的持有时间,我们使用等待-通知的机制,减少轮训导致的消耗。通过保存指针把内存分配操作放到了锁外, 同时避免了加锁之后的异常。