目录
注意:这里的单互斥队列是我自己取的名字,原因是在此之前我并没有发现相关的文章讨论过它,我不确定这么称呼它是否正确,有可能这只是我知识的盲点,而不是一个新玩意。
在本文中,我将介绍一种允许同时存取的队列,且不会因此产生线程安全问题。
模型以及问题引入
在多线程模型中,最常见的模型之一消费者和生产者中,消费者和生产者共同维护着一个资源队列,无所谓数组还是链表,其都要求生产者和消费者在生产或者是消费时一定要互斥访问,无论是加锁或是其他互斥操作。我们以简单的加锁链表为例子:
这个模型的是这个样子的
看门狗作用就是盯着让他们排队,如果不看着,他们就会抢。容易发现,虽然我们采用了多线程,但是资源的存取是互斥的,当然,多线程模型里这样的存取消耗是比起执行任务消耗应该是较小的并且可接受的。但是,这也是存在优化空间的,我们发现,这个模型为什么不可分开呢?为什么不能消费者只管消费,生产者只管生产呢?就像下面这样
这里我们只假设了一个生产者,实际上还可能存在多个生产者,所以如果多个生产者就会产生争抢生产(原因是包裹只能挂在前一个包裹后面或者挂在头钩上,头钩有东西时不能挂且每个包裹后只有一个钩子,一个钩子只能挂一个包裹,重复挂就会覆盖)。其次,由于锁只管住了消费者本身,消费者和生产者还可能发生争抢问题(只有一个包裹时生产者要挂包裹,消费者要拿走)。
为了解决这些问题可以如下思考:使用三把锁,一把管消费者排队,一把管生产者排队,一把不准消费者和生产者争抢。所以问题是如何设计第三把锁?
问题分析
为了解决这些问题,我们可以简化这个模型,我们可以假设只有一个生产者和一个消费者。那么此时我们就只剩下一个问题需要解决,如何避免消费者和生产者挣抢?这个问题其实可以抽象为设计一只看门狗,当资源只剩下一个时,看门狗就要求生产者和消费者排队,也就是只准一个人拿走或者放下。也就是这样:
为了实现这个目的,其实就是一个问题——如何保证自己读取到正确的队列数量。
显然如果使用一个整数标注数量的方案不可行,消费者要count++,生产者要count--(更正:消费者--,生产者++),如果把数字加锁,那么每次读取都要锁,该方案和原来方案没有任何区别。
这两个问题个根本原因在于队列存取的单端性,假设我们把队列改成双端,生产者从一端生产,消费者从一端消费,那么当队列长度很长时,同时存取就不会发生安全问题,也正是这个假设的存在,使得我们这个方案肯定有一线生机。为了实现这个假设,我们需要两个指针,一个指向队列尾巴,一个指向队列头。问题来了:当队列很短甚至只有一个时,生产者和消费者如何准确判断队列数量?
那怎么解决?其实可以完全回归我们的假设,我们利用两个指针形成双端队列,生产者读尾巴,消费者读头(可以反向),由于双方读取非同一变量,于是不会导致脏读,同时由于双方同时写非同一变量,所以不会导致覆盖写。所以问题化简为当队列仅剩一个节点时,也就是head=last时,双方开始竞争锁。有了这个设想后我们开始实现。
解决方案——单互斥队列
定义节点:
template<typename T>
struct Node
{
bool empty;
T value;
Node* next;
Node() :next(nullptr), value(),empty(false) {};
Node(const T& t) :next(nullptr), value(t), empty(false) {};
};
这里定义链表时,我们利用一个bool的标志表示节点是否存在。 后续解释其作用
定义链表
template<typename T>
class SingleMetuxQueue {
public:
SingleMetuxQueue();
~SingleMetuxQueue();
Node<T> get();
void push(const T&);
/// <summary>
/// speed flag:
/// it just represents speed,but the accurate count.
/// when abs(count1-count2) begin more biggger within a period of time,It indicates
/// that consumer is lower than conductor
/// 它仅代表速度,而不是精确数量,当其值越来越大时,说明生产者速度远低于消费者
/// </summary>
inline int count()const{return lcount-hcount};
private:
std::mutex _mutex;
Node<T>* head;
Node<T>* last;
unsigned long long hcount;
unsigned long long lcount;
};
注意到我们利用两个标志来表示队列数量,为什么是两个,在上面的方案我们已经提示过,单变量会出现覆盖写问题,非但不能表征数量,相反容易导致逻辑故障。但是,双变量也存在一定局限,当任意一个消费者或者生产者调用getcount时,由于读取了不属于自己的变量,就可能存在脏读,所以任何一个消费者或者生产者不能利用其作为指导决定是否生产或者消费,仅能感知当前双方的速度如何。
为了使得加锁方案简单,我们可以将锁交由生产者持有,当且仅当消费者发现队列仅剩一个时,消费者加生产锁与生产者竞争。所以我们可以这样实现消费
template<typename T>
Node<T> SingleMetuxQueue<T>::get()
{
if (head == nullptr) {
Node<T> _null;
_null.empty = true;
return _null;
}
if (head->next == nullptr) {
std::lock_guard<std::mutex> lock(_mutex);
if (head == last) {
Node<T> _new = *head;
delete head;
head = nullptr;
last = nullptr;
hcount++;
return _new;
}
else {
Node<T> _new = *head;
Node<T>* nh = head->next;
delete head;
head = nh;
//list security
_new.next = nullptr;
hcount++;
return _new;
}
}
else
{
Node<T> _new = *head;
Node<T>* nh = head->next;
delete head;
head = nh;
//list security
_new.next = nullptr;
hcount++;
return _new;
}
}
前面我们解释了不能获得准确的队列数量,但是我们需要消费者在队列为空时停止消费行为,所以我们利用节点标志空返回一个空节点,代表队列为空,指导消费线程等待。
然后,我们可以这样实现生产
template<typename T>
void SingleMetuxQueue<T>::push(const T& v)
{
std::lock_guard<std::mutex> lock(_mutex);
if (last==nullptr) {
Node<T>* nlast = new Node<T>(v);
last = nlast;
head = last;
lcount++;
}
else
{
Node<T>* nlast = new Node<T>(v);
last->next = nlast;
//考虑此时刚好生产完成且head=last,消费者发现头指针存在后续节点,不进行互斥直接取
//头指针,此时将头指针指向下一节点,为nlast,生产线程操作last为nlast,双线程操作不同变量,线程安全
last = nlast;
lcount++;
}
}
注意在注释中我们提到了一种非常特殊的情况,是最可能导致线程不安全问题出现情形。
最后,我们实现析构函数使得未使用资源的释放避免内存泄露。
template<typename T>
inline SingleMetuxQueue<T>::SingleMetuxQueue() :head(nullptr), last(nullptr), hcount(0), lcount(0)
{
}
template<typename T>
inline SingleMetuxQueue<T>::~SingleMetuxQueue()
{
std::lock_guard<std::mutex> lock(_mutex);
if (head != nullptr) {
Node<T>* nh = head->next;
while (nh != nullptr) {
nh = head->next;
delete head;
head = nh;
}
delete head;
head = nullptr;
last = nullptr;
}
}
使用代码测试一下
#include<thread>
#include"SingleMetuxQueue.h"
SingleMetuxQueue<int> queue;
void fun1() {
int i = 0;
while (i++ < 10) {
queue.push(i);
std::cout << "conduct: " << i << "\t";
}
}
void fun2() {
int i = 0;
Node<int> res;
while (i++ < 10) {
res.empty = true;
while (res.empty) {
res = queue.get();
}
std::cout << "consume: "<<res.value<<"\t";
}
}
int main(int argc, char *argv[])
{
system("chcp 65001");
std::thread th1(fun1);
std::thread th2(fun2);
th1.join();
th2.join();
return 0;
}
结果
可以发现程序安全退出且逻辑正确。由于这模型只适合单消费者与单生产者,所以我称它为单互斥队列。
提升
有了上面的方案后,我们将其拓展以满足更广泛的需求,即多消费者。
由于生产者自己持有锁,所有上述方案无需更改,多生产者也适用该方案,为了多消费者也可以适用该方案,所以我们可以再加一把消费者锁。以下,我简单对单互斥进行了封装:
template<typename T>
class MutexQueue {
public:
Node<T> get();
void push(const T&);
inline int count()const { return squeue.count() };
private:
std::mutex consumer_mutex;
SingleMetuxQueue<T> squeue;
};
template<typename T>
inline Node<T> MutexQueue<T>::get()
{
std::lock_guard<std::mutex> lock(consumer_mutex);
return squeue.get();
}
template<typename T>
inline void MutexQueue<T>::push(const T& v)
{
squeue.push(v);
}
这样,单互斥队列就可以适用到多消费者,多生产者模型。总结下来便是,如果要实现消费者和生产者可以同时存取数据,可以让消费者加生产者锁或者生产者加消费者锁。
结语
本来这篇文章在去年就应该发布了,由于一些原因并没有补上,今天将其记录下来,也算一个笔记了。其实这篇文章或许也没那么有用,毕竟优化在存取上不如多优化在任务处理上,此处的资源消耗远不及任务处理带来的消耗多。之前,我还思考过,有没有一种可能,实现一个矩阵式的阵列资源表,使得多线程只在获取到同一资源时发生互斥,并尽可能使用少量的锁。不过这个想法的方案被我否决了好几次了,不过按前文相同的假设,这种方案存在一线可能,留待以后思考把!
最后是这种方案不会局限于具体是语言,在同样存在线程与互斥的方案里同样也可以这样去进行优化,当然,或许这点优化也没那么重要。