前言
这里主要对之前学习的一些服务器组件和框架进行一些总结和复习,最近开始找秋招了,就通过写一些CSDN对之前的技术进行总结吧
为什么要使用无锁队列
C/C++服务器编程的两个重要方向就是并行与异步化,于是我在学习服务器的时候便在一些博客上见到了无锁队列的优化,无锁队列顾名思义其实就是无锁的队列,既然是队列为什么又要分有锁和无锁了,这里我先根据我的理解解释一下(只是大三学生,能力有限,并不是大佬,只想写博客记录一下自己的学过的技术点,有错误请指出,谢谢!)。
为什么需要锁
其实对于锁的提出大家并不陌生,锁与多线程编程是息息相关的,多线程编程也是服务器并行优化的一个非常重要的方面,既然使用了多线程,就不得不考虑临界资源管理的问题,常规的我们会采用 mutex+condition+notify进行处理,我们会利用加锁和条件变量机制来管理临界资源,利用notify来通知相应的资源情况,我们知道这些机制会为系统带来等待唤醒开销、竞争开销、条件变量开销等一系列开销,所以我们提出了无锁队列的队列结构。
无锁队列真的无锁吗
无锁队列知识避开了mutex的加锁机制,利用cas 原子操作来替代了加锁解锁的操作,对于什么是CAS(compare and swap)原子操作网上有很多技术大佬的博客,后续会更新相关博客,那么这个时候我又思考了另一个问题,原子操作真的就比加锁快吗,我在思考这个问题是因为其实无锁队列在进行CAS原子操作的时候,同样涉及一个问题,CAS操作会在用户态下不断的循环检测,这个过程对CPU消耗也是不小的。
带着疑问,结合网上zmq队列的开源代码我进行了一些测试。
zmq无锁队列的整体设计
zmq无锁队列的数据结构
对象数据成员
class yqueue_t {
private:
struct chunk_t {
T values[N];
chunk_t *prev;
chunk_t *next;
};
chunk_t *begin_chunk; // 链表头结点
int beign_pos; // 起始点
chunk_t *end_chunk; // 拿来扩容的,总是指向链表的最后一个结点
int end_pos;
chunk_t *back_chunk; // 队列中最后一个元素所在的链表结点
int back_pos; // 尾部
atomic_ptr_t<chunk_t> spare_chunk;
// 私有化拷贝构造函数
yqueue_t(const yqueue_t &);
// 私有化赋值构造函数
const yqueue_t &operator=(const yqueue_t &);
};
其数据成员类似于vector中的三个封装迭代器begin、back、end,这里的chunk_t的数结构其实是对队列空间的申请分配进行了一个优化,队列的基本单元是一个chunk_t而一个chunk_t的数据结构里面有封装了N个真正的数据,这样我们在为队列开辟空间的时候不至于频繁开辟空间,节省了空间开辟申请的时间。
方法
其实对于方法的说明和队列大同小异都是push,pop加上构造函数与析构函数这里采用了inline内联的方式,对于较为复杂的函数方法不建议采用内联函数的方式,同时这里将构造函数设置为 inline,需要注意,如果在以后设计构造函数时如果体现了动态多态还想使用 inline的话,需要再子类的构造函数初始化列表中加上父类的构造函数,否在体现动态多态时会出现基类无法构造的问题。
对于无锁队列的各个操作函数处理看源代码注释已经很好理解了,这里不做过多说明。直接上代码和开源的代码一样。
// 创建队列
inline yqueue_t() {
// 为chunk分配内存空间
begin_chunk = (chunk_t*) malloc(sizeof(chunk));
alloc_assert(begin_chunk);
begin_pos = 0;
back_chunk = NULL;
back_pos = 0;
end_chunk = begin_chunk;
end_pos = 0;
}
// 销毁队列
inline ~yqueue_t() {
while(true) {
if(begin_chunk == end_chunk) { // 如果队列为空
free(beign_chunk);
break;
}
// 释放当前chunk_t
chunk_t *o = begin_chunk;
begin_chunk = begin_chunk->next;
free(o);
}
// 将原子变量指针设置为NULL
chunk_t *sc = spare_chunk.xchg(NULL);
free(sc);
}
inline T &front() {
return begin_chunk->values[begin_pos];
}
inline T &back() {
return back_chunk.values[back_pos];
}
inline void push() {
back_chunk = end_chunk;
back_pos = end_pos;
if(++end_pos != N) { // 当前chunk结点未满
return ;
}
// 设置NULL,如果把之前的值取出来了则没有spare_chunk了
chunk_t *sc = spare_chunk.xchg(NULL);
if(sc) {
end_chunk->next = sc;
sc->prev = end_chunk;
}
else { // 没有则重新分配
end_chunk->next = (chunk_t *) malloc(sizeof(chunk_t));
alloc_assert(end_chunk->next);
end_chunk->next->prev = end_chunk;
}
end_chunk = end_chunk->next;
end_pos = 0;
}
// 删除队列尾部的元素
inline void unpush() {
if(back_pos) // 从尾部删除元素
--back_pos;
else {
back_pos = N-1; // 回退到前一个chunk
back_chunk = back_chunk->prev;
}
if(end_pos) // 以为这当前的chunk还有其他元素占有
--end_pos;
else {
end_pos = N-1; // 当前chunk 没有元素占用
end_chunk = end_chunk->prev;
free(end_chunk->next);
end_chunk->next = NULL;
}
}
// 删除队列头部元素
inline void pop() {
if(++begin_pos == N) { //删除满一个chunk才回收一个chunk
chunk_t *o = begin_chunk;
beign_chunk = begin_chunk->next;
begin_chunk->prev = NULL;
beign_pos = 0;
chunk_t *cs = spare_chunk.xchg(o);
free(cs);
}
}
对于ypipe.hpp文件中的内容腾讯的这边博客写的十分详细,这里附上链接因为个人画图能力有限且远没有别人讲解讲得好所以附上链接
无锁队列的几种实现及其性能对比-腾讯云开发者社区-腾讯云 (tencent.com)
总结
对于上述zmq无锁队列,适用于但消费者,单生产者,对于多消费者多生产者模型这里学习了一种循环数组无锁队列,同时针对上述zmq无锁队列其实对于数据量小于十万级别的数据提升并不大,一般适用于对于高吞吐量的时候才考虑使用无锁队列,同时对于zmq无锁队列几个关键技术点这里做如下总结。
1. zmq无锁队列封装了chunk_t 的数据结构,很好的解决了每次动态分配内存锁带来的开销。这里其实我们也可以自己实现一个内存池吧完全释放掉的chunk_t 放入内存池方便下次利用,避免重复申请,这里很好的解决了局部稳态性原理。
2. 利用CAS原子操作解决了无锁操作,同时无锁队列解决了cache 失效问题,不使用无锁队列时,线程被切换的时候,如果导致cache 不足出现失效的问题。而无锁队列采用原子操作实现无锁并发方访问,保证了对共享数据访问的原子性,避免出现竞态条件或者数据不一致的问题。
3. ypipe_t支持调用者自己去实现 notify + condition wait的通知:写入的时候,如果之前为空,可以提示我们调用者发送notify,读取的时候,如果没有读取到数据,我们可以提示condition wait。
无锁队列还有一个比较难理解的问题:没有数据读取时,我们应该怎么办
这里提供三点思路,当然这三点不一定都是好的,仅仅是一个思路:
a. usleep,这个可能带来很多的问题,休眠了1 ms还没数据怎么办,如何设置合理的休眠时间,我们使用无锁队列时一般都是因为高吞吐量,那么休眠时间设置不合理,可能导致数据丢失的错误等等这种思路会出现很多问题。
b. mutex+notify+condition,什么时候发notify呢,如果每次都插入元素都发notify 会不会消耗很多时间呢,有时间会详细探索。
c. 提交数据之前判断是不是为空,在向无锁队列提交数据时,通常会先判断队列是否为空。如果队列为空,则直接将数据插入队列中。如果队列非空,则需要使用原子操作来插入数据,以避免多个线程同时插入数据的竞争问题。也可以使用条件变量唤醒阻塞线程,当队列为空的时候,需要等阿迪数据到来才能继续执行。在ZMQ的无锁对了实现汇总,通常会使用条件变量来唤醒线程。当队列为空时,阻塞线程会通过条件变量进入睡眠状态,等待新数据的到带来。一旦有了新数据就会被唤醒。