无锁消息队列的实现
消息队列(Message Queue),是分布式系统中重要的组件,其通用的使用场景可以简单地描述为:
当不需要立即获得结果,但是并发量又需要进行控制的时候,差不多就是需要使用消息队列的时候。
消息队列主要解决了应用耦合、异步处理、流量削锋等问题。
这里主要介绍下无锁队列
一 为什么需要无锁队列
股票行情之类的
(1)不要乱用,一秒处理几百、几千个元素
(2)每秒处理十几万个元素的时候再考虑
有锁队列中的锁,会引起一下的问题:
- Cache损坏(Cache trashing)。在保存和恢复上下文的过程中还隐藏了额外的开销:Cache中的数据会失效,因为它缓存的是将被换出任务的数据,这些数据对于新换进的任务是没用的。
- 在同步机制上的争抢队列。阻塞不是微不足道的操作。它导致操作系统暂停当前的任务或使其进入睡眠状态(等待,不占用任何的处理器)。直到资源(例如互斥锁)可用,被阻塞的任务才可以解除阻塞状态(唤醒)。在一个负载较重的应用程序中使用这样的阻塞队列来在线程之间传递消息会导致严重的争用问题。也就是说,任务将大量的时间(睡眠,等待,唤醒)浪费在获得保护队列数据的互斥锁,而不是处理队列中的数据上。无锁队列在插入或者获取消息时保持对资源的独占靠的是原子操作。
- 动态内存分配。在多线程系统中,需要仔细的考虑动态内存分配。当一个任务从堆中分配内存时,标准的内存分配机制会阻塞所有与这个任务共享地址空间的其它任务(进程中的所有线程)。这样做的原因是让处理更简单,且它工作得很好。两个线程不会被分配到一块相同的地址的内存,因为它们没办法同时执行分配请求。
显然线程频繁分配内存会导致应用程序性能下降(必须注意,向标准队列或map插入数据的时候都会导致堆上的动态内存分配)。
无锁队列则在插入或者获取消息时保持对资源的独占靠的是原子操作,而不是互斥锁,这样,就避免了应用程序在使用消息队列时频繁的切换线程。在分配内存中,此无锁队列使用了chunk的机制,避免了频繁的分配和释放内存。
二 无锁队列的实现
本无锁队列有以下的特点。
- 不支持多读多写,只支持一写一读。
- 链表的方式分配节点,采用chunk的机制,减少节点分配的时间。
chunk机制是一种分配内存时一次分配较大的内存块,并利用局部性原理,在需要回收内存块时,不去直接销毁它,而是将它放到一个指针(spare_chunk)上,当以后有需要时,直接使用它。其中,这个空闲指针只能指向一个空闲内存块。由于局部性原理,总是保存最新的空闲块而释放先前的空闲快 - 批量写入。写端往往会连续写好多内容,然后再通过flush更新到读端。这样能提高吞吐量。
- flush
- 无锁队列
- (1)读端没有数据可读,这个时候应该怎么办?sleep?还是mute+condition wait
- (2)写端怎么唤醒读端去读取数据?mute+condition notify?怎么知道读端是休眠的状态?
此无锁队列有两个重要部分,一是yqueue_t和ypipe_t的数据结构,2是chunk设计。
2.1 原子操作类
原子操作类如下:
template <typename T> class atomic_ptr_t {
public:
inline voi set(T *ptr_); //非原子操作
inline T *xchg(T *val_);//原子操作,设置一个新的值,然后返回旧的值
inline T *cas(T *cmp_, T *val_);//原子操作
private:
volatile T *ptr;
}
- set函数,把私有成员ptr指针设置成参数ptr_的值,不是一个原子操作,需要使用者确保执行set过程没有其他线程使用ptr的值。
- xchg函数,把私有成员ptr指针设置成参数val_的值,并返回ptr设置之前的值。原子操作,线程安全。
- cas函数,原子操作,线程安全,把私有成员ptr指针与参数cmp_指针比较:
- 如果相等返回ptr设置之前的值,并把ptr更新为参数val_的值;
- 如果不相等直接返回ptr值。
2.2 yqueue_t
yqueue_t类主要使用来管理元素和chunk的。
// T is the type of the object in the queue.队列中元素的类型
// N is granularity(粒度) of the queue,简单来说就是yqueue_t一个结点可以装载N个T类型的元素
template <typename T, int N>
class yqueue_t
{
public:
inline yqueue_t(); // 创建队列.
inline ~yqueue_t(); // 销毁队列e.
inline T& front(); // Returns reference to the front element of the queue. If the queue is empty, behaviour is undefined.
inline T& back(); // Returns reference to the back element of the queue.If the queue is empty, behaviour is undefined.
inline void push(); // Adds an element to the back end of the queue.
inline void pop(); // Removes an element from the front of the queue.
inline void unpush();// Removes element from the back end of the queue。 回滚时使用
private:
// Individual memory chunk to hold N elements.
struct chunk_t {
T values[N];
chunk_t *prev;
chunk_t *next;
};
chunk_t *begin_chunk;
int begin_pos;
chunk_t *back_chunk;
int back_pos;
chunk_t *end_chunk;
int end_pos;
atomic_ptr_t<chunk_t> spare_chunk; //空闲块(我把所有元素都已经出队的块称为空闲块),读写线程的共享变量
}
chunk块机制:每次批量分配一批元素,减少内存的分配和释放(解决不间断动态内存分配的问题)。
yqueue_t的内部由一个一个chunk组成,每个chunk保存N个元素。当队列空间不足时每次分配一个chunk_t,每个chunk_t能存储N个元素。在数据出队列后,队列有多余空间的时候,回收的chunk也不是马上释放,而是根据局部性原理先回收到spare_chunk里面,当再次需要分配chunk_t的时候从spare_chunk中获取。
yqueue_t内部有三个chunk_t类型指针以及对应的索引位置:
- begin_chunk/begin_pos:begin_chunk用于指向队列头的chunk,begin_pos用于指向队列第一个元素在当前chunk中的位置。
- back_chunk/back_pos:back_chunk用于指向队列尾的chunk,back_po用于指向队列最后一个元素在当前chunk的位置。
- end_chunk/end_pos:由于chunk是批量分配的,所以end_chunk用于指向分配的最后一个chunk位置。
这里特别需要注意区分back_chunk/back_pos和end_chunk/end_pos的作用:
- back_chunk/back_pos:对应的是元素存储位置;
- end_chunk/end_pos:决定是否要分配chunk或者回收chunk。
spare_chunk:
另外还有一个spare_chunk指针,用于保存释放的chunk指针,当需要再次分配chunk的时候,会首先查看这里,从这里分配chunk。这里使用了原子的cas操作来完成,利用了操作系统的局部性原理,意思是短暂时间内队列的数据量是一个水平波动的过程。
3.3 ypipe_t
ypipe_t类的用处是在yqueue_t的基础上构建一个单写单读的无锁队列。
它的类声明是:
template <typename T, int N> class ypipe_t
{
public:
// Initialises the pipe.
inline ypipe_t();
// The destructor doesn't have to be virtual. It is mad virtual
// just to keep ICC and code checking tools from complaining.
inline virtual ~ypipe_t();
// Write an item to the pipe. Don't flush it yet. If incomplete is
// set to true the item is assumed to be continued by items
// subsequently written to the pipe. Incomplete items are neverflushed down the stream.
// 写入数据,incomplete参数表示写入是否还没完成,在没完成的时候不会修改flush指针,即这部分数据不会让读线程看到。
inline void write(const T& value_, bool incomplete_);
// Pop an incomplete item from the pipe. Returns true is such
// item exists, false otherwise.
inline bool unwrite(T *value_);
// Flush all the completed items into the pipe. Returns false if
// the reader thread is sleeping. In that case, caller is obliged to
// wake the reader up before using the pipe again.
// 刷新所有已经完成的数据到管道,返回false意味着读线程在休眠,在这种情况下调用者需要唤醒读线程。
inline bool flush();
// Check whether item is available for reading.
// 这里面有两个点,一个是检查是否有数据可读,一个是预取
inline bool check_read();
// Reads an item from the pipe. Returns false if there is no value.
// available.
inline bool read(T *value_);
// Applies the function fn to the first elemenent in the pipe
// and returns the value returned by the fn.
// The pipe mustn't be empty or the function crashes.
inline bool probe(bool(*fn)(T&));
protected:
// Allocation-efficient queue to store pipe items.
// Front of the queue points to the first prefetched item, back of
yqueue_t<T, N> queue;
T *w;
T *r;
T *f;
atomic_ptr_t<T> c;
ypipe_t(const ypipe_t &);
const ypipe_t &operator=(const ypipe_t &);
}
主要变量:
- // Points to the first un-flushed item. This variable is used exclusively by writer thread.
T *w;//指向第一个未刷新的元素,只被写线程使用 - // Points to the first un-prefetched item. This variable is used exclusively by reader thread.
T *r;//指向第一个还没预提取的元素,只被读线程使用 - // Points to the first item to be flushed in the future.
T *f;//指向下一轮要被刷新的一批元素中的第一个 - // The single point of contention between writer and reader thread.
// Points past the last flushed item. If it is NULL,reader is asleep.
// This pointer should be always accessed using atomic operations.
atomic_ptr_t c;//读写线程共享的指针,指向每一轮刷新的起点(看代码的时候会详细说)。当c为空时,表示读线程睡眠(只会在读线程中被设置为空)
主要接口:
- void write(const T &value_, bool incomplete_);// 写入数据,incomplete参数表示写入是否还没完成,在没完成的时候不会修改flush指针,即这部分数据不会让读线程看到。
- bool unwrite(T *value_); //在数据没有flush之前可以运行反悔 Pop an incomplete item from the pipe. Returns true is such item exists, false otherwise.
- bool flush(); // 将write的元素真正刷新到队列,使读端可以访问对应的数据。返回false意味着读线程在休眠,在这种情况下调用者需要唤醒读线程。
- bool check_read(); 检测是否有数据可读
- bool read (T *value_):读数据,将读出的数据写入value指针中,返回false意味着没有数据可读。
这个类的重点在于:
- 插入数据
- 更新插入数据的位置
- 判断队列为空
- 读取数据
三 基于循环数组的无锁队列
这个队列是一个一写多读的队列。它要解决一个关键问题:如何在多读的时候保持同步。
template <typename ELEM_T, QUEUE_INT Q_SIZE = ARRAY_LOCK_FREE_Q_DEFAULT_SIZE>
class ArrayLockFreeQueue
{
public:
ArrayLockFreeQueue();
virtual ~ArrayLockFreeQueue();
QUEUE_INT size();
bool enqueue(const ELEM_T& a_data);
// 入队列
bool dequeue(ELEM_T &a_data);
// 出队列
bool try_dequeue(ELEM_T &a_data);
// 尝试入队列
private:
ELEM_T m_thequeue[Q_SIZE];
volatile QUEUE_INT m_count;
// 队列的元素格式
volatile QUEUE_INT m_writeIndex;
//新元素入列时存放位置在数组中的下标
volatile QUEUE_INT m_readIndex;
// 下一个出列元素在数组中的下标
volatile QUEUE_INT m_maximumReadIndex;
//最后一个已经完成入列操作的元素在数组中的下标
inline QUEUE_INT countToIndex(QUEUE_INT a_count);
}
关于阻塞和唤醒:利用CAS原子操作解决。
四 一读一写的无锁队列和一读多写的无锁队列的对比
1.无锁队列,使用数组的方式性能更高
2.看实际需求,比如1写1读能不能解决问题,如pipe,否则就用1写多读,如ArrayLockFreeQueue