目录
设计基于锁的并发数据结构关键是要确保存取数据时要锁住正确的互斥元,并且将锁的时间最小化。只用一个互斥元保护一个数据结构是很困难的,你要保证在互斥锁保护区域之外不会被存取,并且不会发生接口所固有的竞争。但是如果使用独立的互斥元来保护数据结构的独立部分,问题会变得更复杂,可能会产生死锁。
1、使用锁的线程安全栈
//cpp17_1
#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&)
{
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(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();
return data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
}
因为使用了锁,每次只有一个线程对栈数据结构进行操作,所以同时调用是安全的。但是当stack上存在显著竞争时,线程序列化可能会限制应用的性能。当一个线程等待锁的时候它做不了其他任何别的工作,并且,栈没有为等待数据项被插入的线程提供任何的准备,因此如果一个线程需要等待,它就会反复调用empty(),或者只是调用pop(),并且捕捉empty_stack异常。如果这种情况发生的话,这种栈实现就成为一个比较糟糕的选择,因为一个等待中的线程要么消耗宝贵的资源在检查数据上,要么用户必须写外部的等待和通知代码(比如条件变量),而这就让内部锁变得无效且变得浪费。
2、使用锁和条件变量的线程安全队列
//cpp17_2
#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>
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.front())));
data_queue.pop();
return res;
}
bool empty() const
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
新的wait_and_pop()函数与旧的相比较,不再反复调用empty(),不会增加任何新的竞争条件和死锁的可能性,并且维持了不变量。
当一个元素进入队列时,如果不止一个线程处于等待状态,关于异常安全就会有点崎岖,只有一个线程会被data_cond.notify_one()的调用唤醒。然而,如果被唤醒的线程在wait_and_pop()中引发异常,例如构造std::shared_ptr的时候,那么就没有线程将被唤醒。还有种简单粗暴的方法,就是使用data_cond.notify_all()来唤醒全部等待中的线程,但是代价就是当它们发现队列为空的时候,其中的大部分线程都要重新进入睡眠状态。第二种替代方式就是当引发异常的时候,让wait_and_pop()调用notify_one(),这样就可以唤醒新的线程,新线程去获取队列中的值。第三种替代方法,在push阶段直接不插入值,而是插入std::shared_ptr<T>实例对象,这样就会避免掉在wait_and_pop()时构造std::shared_ptr出现异常的可能。
下面展示一下第三种方法对应的代码
//cpp17_3
#include <queue>
#include <memory>
#include <mutex>
#include <condition_variable>
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 = 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::move(data.front());
data_queue.pop();
return res;
}
bool empty() const
{
std::lock_guard<std::mutex> lk(mut);
return data_queue.empty();
}
};
上面的例子,数据由std::shared_ptr<>持有,好处是:可以在push()的锁外面完成实例分配,然而在cpp17_2中就必须在pop()持有锁的时候才能这样做。因为分配内存是很昂贵的操作,这种方式就有助于提高性能,就是持有互斥元的时间减少了,允许其他线程可以同时在队列上执行操作。
3、使用细颗粒度锁和条件变量的线程安全队列
3.1、通过分离数据允许并发
简而言之就是用带傀儡结点的队列,将头尾的两个访问分开。
对于一个空队列,head和tail都指向这个傀儡结点,而不是null。这样就没问题了,因为如果当队列为空,try_pop()不会访问head->next。
当你将一个结点加入队列(于是就有一个真正的结点),head和tail就指向不同的结点,因此在head->next和tail->next上就不存在竞争。
唯一的缺点就是需要多开销一个结点的空间,并在逻辑上进行管理。
//cpp17-4
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); //res指向需要弹出的队头数据
std::unique_ptr<node> old_head = std::move(head); //old_head指向队头
head = std::move(old_head->next); //head指向下一个结点
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;
}
}
push()现在只访问tail,不访问head。
try_pop()即访问head又访问tail,但只要在最初的比较中需要tail,因此这个锁是很短暂的。
最大的收获是,后置的傀儡结点意味着try_pop()和push()不会再同一个结点上进行操作,因为不再需要一个包含一切的互斥元。因此,你可以为head和tail各设置一个互斥元。那么问题来了,锁应该放在哪?
我们的目标是实现最大程度的并发,因此希望持有锁的时间尽可能的短,push函数中,互斥元再访问tail的全程都需要被锁定,这意味着应该在新结点分配好之后(std::unique_ptr<T> p(new node))指导函数结束都需要保持锁。而try_pop()就没那么简单了,首先你要锁定head上的互斥元,并持有它直到head使用完毕。本质上,正是这个互斥元决定了哪个线程进行pop()操作。一旦head改变(head=std::move(old_head->next)),你就可以解锁互斥元。剩下对于tail的访问,需要锁定尾互斥元。因为只需要访问tail一次,所以可以只在进行读取的时候获取该互斥元。最好将其封装在函数内部来实现。实际上,因为需要锁定head互斥元的代码只是该成员的子集,所以将其封装在函数里会更清晰。
//cpp17-5
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_detail()
{
std::lock_guard<std::mutex> tail_lock(tail_mutex);
return detail;
}
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(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;
}
}
我们一起来回顾一下前面博客说到的并发准则。在寻找被被破坏的不变量之前,我们先来确定一下它们到底是什么。
- 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_mute上的锁对于保护tail本身的读取是必要的,而且确保从头结点读取数据不会产生数据竞争也是很必要的。如果没有这个互斥元,就有可能出现一个线程调用try_pop()的同时,另一个线程调用push(),而且它们的操作没有确定的顺序。尽管每个成员函数都持有一个锁,但它们锁定的是不同的互斥元,并且可能访问相同的数据。幸好在get_detail()中对于tail_mutex的锁定解决了一切问题。因为调用get_detail()与调用push()都锁定了head_mutex,在这两次调用之间就有了明确的顺序,要么调用get_detail()发生在调用push()之前,这种强狂下get_detail()看到的是tail的旧值。要么调用get_detail发生在push()之后,这种情况下get_detail()看到的是tail的新值,并且新的数据附在tail之前的值上。
在锁定head_mutex的情况下调用get_tail()也是很重要的,如果不这么做,对pop_head()的调用可能会被阻塞在调用get_detail()和锁定head_mutex之间,因为可能有别的线程调用try_pop(),并且先获得了锁。
//这是一个有缺陷的实现
std::unique_ptr<node> pop_head()
{
node* const old_tail = get_tail(); //在锁外部获取旧的tail值
std::lock_guard<std::mutex> head_lock(head_mutex);
if(head.get() == old_detail)
{
return nullptr;
}
std::unique_ptr<node> old_head = std::move(head);
head = std::move(head->next);
return old_head;
}
上面实现对get_tail()的调用是在锁之外做出的,这样就会出现一个很大的问题,原来的实现是先锁定head_mutex,然后再去获取tail(而此时tail的作用就是用来判断链表是否为空),而其余形式的pop()也进不来(锁定了head_mutex),这样整个实现就没有问题。而上面的这个实现,在获取tail之后,就释放掉锁了,但是你又不能保证当前线程继续执行,如果这时候切换到别的线程进行了pop()操作,如果栈已经空了,代码就出现不可控异常了了,即使栈未空,那从严格的逻辑上来说,这也不是一次正确的操作了。所以始终确保在head_mutex()上的锁的范围内调用get_detail()。这就确保了没有别的线程可以改变head,并且tail也能是更新一些的数据,实现也是百分百安全的。
还有一点需要考虑,异常。因为改变了数据分配模式,因此很多地方都可能产生异常。try_pop()的操作中只有锁定互斥元才能引发异常,并且直到它获取锁之后才会修改数据。因此try_pop()是异常安全的。另一方面,push()在堆上分配一个T的实例以及一个新的结点实例,而这两者都可能会引发异常。不管怎样,这两个新分配的对象都赋值给智能指针,因此当引发异常时他们就会被释放。一旦获取了锁,push()中的其他操作都不会引发异常,所以push()也是异常安全的。
3.2、等待一个数据项pop
清单cpp17-5提供了一个使用细粒度锁的线程安全队列,但它只支持try_pop()(并且只有一种重载)。前面清单cpp17-2中的有用的wait_and_pop()函数呢? 是否能用细粒度锁实现相同的接口呢?
当然是可以的鸭,修改push()是很简单的,只需要在函数的尾部添加data_cond.notify_one()调用即可?实际上,并非这么简单,使用细粒度锁是为了实现并发量的最大化。如果在对notify_one()的调用中保留互斥元被锁定,那么如果被通知的线程在互斥元解锁之前被唤醒,它就得等互斥元。另一方面,假设在调用notify_one()之前就解锁互斥元,那么当等待中的互斥元被唤醒时,此互斥元就可以被它使用(假设没有别的线程去锁定它)。这是一个小小的改进。
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_detail = p.get();
tail->next = std::move(p);
tail = new_tail;
}
data_cond.notify_one();
}
wait_and_pop()更复杂一些,因为得决定在哪里等待,断言是什么,以及需要锁定哪个互斥元。我们所等待的条件是“队列非空”,它是用tail != head表示的。如上所示,这有可能要求head_mutex和tail_mutex都被锁定,但是在cpp17-5中,我们已经决定只需要在读取tail的时候锁定tail_mutex,比较操作本身是不需要的,因此可以将相同的逻辑应用到此处。如果将断言设定为head != get_detail(),就只需要持有head_mutex,因为在调用data_cond.wait()时可以使用head_mutex上的锁。一旦增加了等待逻辑,这种实现就跟try_pop()一样了。
template<typename T>
class threadsafe_queue
{
private:
std::unique_lock<std::mutex> wait_for_data()
{//等待队列有数据后返回锁定head_mutex的锁实例
std::unique_lock<std::mutex> head_lock(head_mutex);
data_cond.wait(head_lock,[&]{return head.get() != get_detail();})
return std::move(head_lock);
}
std::unique_ptr<node> pop_head()
{
std::unique_ptr<node> old_head = std::move(head);
head = std(old_head->next);
return old_head;
}
std::unique_ptr<node> wait_pop_head()
{//先锁住head_mutex,然后调用pop_head()去获取头指针
std::unique_lock<std::mutex> head_lock(wait_for_data());
return pop_head();
}
node* get_tail()
{
std::lock_guard<std::mutex> tail_lock(tail_mutex);
return tail;
}
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);
}
}
wait_for_data()不仅简化了代码,而且使用lambda表达式来等待条件变量,并向调用者返回锁的实例.。这是为了确保当数据被相关的wait_pop_head()重载、修改时,持有相同的锁。
template<tyname T>
class threadsafe_queue
{
private:
std::unique_ptr<node> try_pop_head()
{
std::lock_gyard<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(*heas->data);
return pop_head();
}
public:
std::shared_ptr<T> try_pop()
{
std::unique_ptr<node> old_head = try_pop_head();
}
bool try_pop(T& value)
{
std::unique_ptr<node> const old_head = try_pop_head(value);
return old_head;
}
void empty()
{
std::lock_guard<std::mutex> head_lock(head_mutex);
return (head.get() == get_tail());
}
}