无锁队列原理和实现

最近在研究无锁编程相关的东西,特别是无锁数据结构,看了不少人的文章和博客,深受启发,于是决定动手基于数组实现一个C++的无锁队列。

基本数据结构

无锁队列的底层数据结构特别简单:

template 
class NolockQueue
{
public:
  NolockQueue(int size);
  ~NolockQueue();
public:
  int push(T *ptr);
  int pop(T *&ptr);
  inline int get_total() const;
  inline int get_free() const;
  int capacity() const {return size;}
private:
  struct Item
  {
    T *data;
  };
private:
  int size;
  Item *array;
  uint64_t consumer;
  uint64_t producer;
};

用一个array数组放置指向数据的指针们,consumer表示pop操作弹出(获取)元素的位置,每次弹出consumer都会加1,producer表示push操作插入元素的位置,每次弹出producer都会加1,初始化时二者均为0:

template <typename T>
NolockQueue::NolockQueue(int size)
{
  array = reinterpret_cast(new char[sizeof(Item) * size]);
  memset(array, 0, sizeof(Item) * size); 
  this->size = size;
  consumer = 0;
  producer = 0;
}

关于队列大小的计算:

template <typename T>
inline int NolockQueue::get_total() const
{
  return (producer - consumer);
}

由于producer和consumer都只加不减,所以很容易超过array的大小,通过对size取模的方式来定位到对应的位置上,这也是环形缓冲区(Ring buffer)的基本思想。那么producer是否一定大于consumer呢?producer - consumer是否一定非负呢?
producer在超过无符号常整型的最大值时会变为0,而这时consumer很可能还没跨过边界,所以就会导致producer小于consumer,但是不用担心,producer - consumer仍然可以得到正确的值,这里是利用了无符号数字的溢出特性:
0 - 0xffff = 1
写段程序验证一下:

#include 
#include 
int main()
{
  uint64_t z = 0xffffffffffffffff;
  uint64_t zplus1 = z + 1;
  printf("zplus1 - z: %llu\n", zplus1 - z);
  return 0;
}

输出是1
BTW, 要等到一个64位整数越界要多久呢?假设1ns写入一个数据,那么producer离越界也还需要584年。

push与pop操作

push操作每次都是先判断当前队列是否为满(producer < consumer + size),如果队列不满的话,就将producer原子性地加1,表示producer旧值(加1前)对应的位置被预留了,当前线程可以将数据写入到这个位置

pop操作每次都是先判断当前队列是否为空(consumer < producer),如果队列不为空的话,就将consumer原子性地加1,表示consumer旧值对应的位置被预留了,当前线程可以安全地从这个位置读取元素

由此,我们需要一个原子操作:
FAA_Bounded(uint64_t *addr, uint64_t delta, uint64_t up_bound)
表示:
当addr的值小于up_bound时,将addr的值加上delta,存储在addr上,同时返回旧值
否则对addr上的值不做修改,直接返回addr上的值

uint64_t FAA_BOUNDED(uint64_t* addr, uint64_t delta, uint64_t up_bound)
{
  uint64_t old_value; 
  while((old_value = ATOMIC_LOAD(addr)) < up_bound &&
        old_value != ATOMIC_CAS(addr, old_value, old_value + delta)  
       ) { 
    PAUSE(); //asm("pause\n")
  }
  return old_value;
}

有了这样一个原子操作,下面来理一下两个操作的逻辑:
push操作的逻辑:
1. 计算producer的上边界,push_limit = consumer + size
2. 执行push_idx = FAA_BOUNDED(&producer, 1, push_limit)
3. 判断push_idx 是否合法,即是否小于push_limit,如果不合法,那么重复执行push操作(直到有consumer从队列中取走元素),如果合法,执行4
4. 为了防止push位置的值不为NULL(这将导致非法插入),需要使用CAS操作将push_idx位置的指针从NULL变为非NULL

template <typename T>
int NolockQueue::push(T *ptr) {
  uint64_t push_limit = ATOMIC_LOAD(&consumer) + size;
  uint64_t push_idx = FAA_BOUNDED(&producer, 1, push_limit);
  if(push_idx >= push_limit) {
    push(ptr);
  } else {
    void **pdata = reinterpret_cast<void **>(&array[push_idx % size].data);
    while (NULL != ATOMIC_CAS(pdata, NULL, ptr)) {
      PAUSE();
    } 
  }
  return 1;
}

pop操作的逻辑:
1. 计算consumer的上边界,pop_limit = producer
2. 执行pop_idx = FAA_BOUNDED(&consumer, 1, push_limit)
3. 判断pop_idx 是否合法,即是否小于pop_limit,如果不合法,那么重复执行pop操作(直到有producer在队列中放入了元素),如果合法,执行4
4. 为了防止pop位置的值为NULL(这将导致非法读取),需要使用原子操作将push_idx位置的指针从非NULL变为NULL

template <typename T>
int NolockQueue::pop(T *&ptr) {
  uint64_t pop_limit = ATOMIC_LOAD(&producer);
  uint64_t pop_idx = FAA_BOUNDED(&consumer, 1, pop_limit);
  if(pop_idx >= pop_limit) {
    pop(ptr);
  } else {
    void **pdata = reinterpret_cast<void **>(&array[pop_idx % size].data);
    while (NULL == (ptr = static_cast (ATOMIC_SET(pdata, NULL)))) {
      PAUSE();
    }
  }
  return 1;
}

对于本文中用到的ATOMIC原语,统一说明下:

#define __COMPILER_BARRIER() asm volatile("" ::: "memory")
#define ATOMIC_LOAD(x) ({__COMPILER_BARRIER(); *(x);})
#define ATOMIC_SET(val, newv) ({__sync_lock_test_and_set((val), (newv));})
#define ATOMIC_CAS(val, cmpv, newv) ({__sync_val_compare_and_swap((val), (cmpv), (newv)); }) 
#define PAUSE() asm("pause\n")

其他说明:
1. 当队列为空时(队列满),pop(push)操作会循环请求直到成功,也可以不重复执行push和pop,而返回一个错误码以供异步调用。
2. 计算producer上边界时,push_limit = consumer + size,可能导致溢出,此时push_idx将会一直大于push_limit从而导致程序死循环,鉴于需要500多年才会发生这种情况且本文仅供参考,故不对此进行debug。

你可能感兴趣的:(无锁编程)