前言:

前面讨论了使用 ​​“全局加锁serilization数据结构” ​​​ 和 ​​“局部加锁的concurrency数据结构”​​ ,这二者都离不开锁的控制,只要有锁就会有时间损耗,最主要的是会引入锁的副作用,比如 死锁、活锁 以及 优先级翻转,那么无锁的 concurrency 数据结构就显得 既高效又安全。

 

注:这里说的无锁不是说整个流程中一个锁都没有,而是不进行显式加锁,不人为显式加锁就不会引入锁的副作用(所谓的lock-free可以理解为 free of lock disadvantages),我们在某种意义上认为它是无锁的,因为虽然(隐式地)用了锁,但是没有引入锁的副作用。比如 atomic 库中除了 atomic_flag 外,其他类型的内部都是通过锁来实现的,但是这里我们使用任何atomic类都不认为是加锁的,因为使用atomic不会引入锁的副作用。另外需要说明,无锁的concurrency 设计离不开 atomic类 和 memory order

 

简单的无锁栈:

简化版:

template<typename T>
class lock_free_stack
{
private:
struct node
{
T data;
node* next;
node(T const& data_) :
data(data_)
{}
};
std::atomic<node*> head;
public:
void push(T const& data)
{
node* const new_node = new node(data);
new_node->next = head.load();
while (!head.compare_exchange_weak(new_node->next, new_node));
}
void pop(T& result)
{
node* old_head = head.load();
while (!head.compare_exchange_weak(old_head, old_head->next));
result = old_head->data;
}
};

上述代码如何保证push的原子性?

栈的一个竞争条件在于“每次压栈时,栈顶元素替换是一定不能出现A-B-A的问题”,比如,线程A和B同时获得了栈顶指针,那么两个线程在压栈的时候谁先成功谁就会被替换掉,因为后压栈的线程会向旧的栈顶压栈,这会覆盖掉新的栈顶。那么我们便可以通过compare_exchange_weak 的循环判断来确保两个线程最终会顺序压栈。因为即便A和B同时获得了栈顶指针,当后压栈的线程试图压栈时会发现 内存地址内的值 和 expected的不一样,此时会把内存地址内的最新值(先压栈的线程给的值)读出来然后赋值给expected在进行下一次判断,如果还是不一样,那么再读出来赋值给expected,知道成功为止。由于是循环进行的,往往执行效率很高,一般情况下第二次都会成功。

上述代码如何保证pop的原子性?

同push。

缺陷一:每个节点中的data不存在智能内存管理,如果T不是指针类型,那么data将跟随节点一并释放;如果是指针类型(大部分情况下都是指针类型),那么需要手动释放。

缺陷二:pop动作之后,旧的 head node 需要手动释放。

 

节点内容自动释放版:

template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
node* next;
node(T const& data_) :
data(std::make_shared<T>(data_))
{}
};
std::atomic<node*> head;
public:
void push(T const& data)
{
node* const new_node = new node(data);
new_node->next = head.load();
while (!head.compare_exchange_weak(new_node->next,new_node));
}

std::shared_ptr<T> pop()
{
node* old_head = head.load();
while (old_head &&
!head.compare_exchange_weak(old_head, old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>();
}
};

上述代码保证了 node 中 data是可以被自动释放的,但是无法保证 pop之后的 旧 head node 会被正常释放,因为 std::atomic<node*> head 这里是 node* ,且也没办法弄成智能指针,因此需要人为delete,但是我们又无法保证在delete的时候其他所有线程都不再使用此节点,所以这个缺陷从上一个版本中延续了下来: “ 缺陷二:pop动作之后,旧的 head node 需要手动释放。”

 

节点内容和节点都自动释放:

template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
std::shared_ptr<node> next;
node(T const& data_) :
data(std::make_shared<T>(data_))
{}
};
std::shared_ptr<node> head;
public:
void push(T const& data)
{
std::shared_ptr<node> const new_node = std::make_shared<node>(data);
new_node->next = head.load();
while (!std::atomic_compare_exchange_weak(&head,
&new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
std::shared_ptr<node> old_head = std::atomic_load(&head);
while (old_head && !std::atomic_compare_exchange_weak(&head,
&old_head, old_head->next));
return old_head ? old_head->data : std::shared_ptr<T>();
}
};

这版不需要再关心废弃head节点的释放问题,且通过 atomic_load实现对 node结构体的原子读(读结构体是从内存地址依次取值,因此中途可能会被改变,如果被改变,则读到的数据一半是正确的一半是错误的)。

这个版本在大部分情况下是能正常工作的,唯一可能出问题的点是 shared_ptr 的计数可能不准,这个也不是我们改关心的。