繁体   English   中英

无锁 C++ 数据结构,不可能吗?

[英]Lock-free C++ data structures, impossible?

我真的不明白如何使某些数据结构无锁。 例如,如果您有一个链表,那么您可以使用互斥锁围绕操作,或者如果在您忙于将新节点重新链接在一起时执行另一个线程,您可能会遇到竞争条件。

“无锁”的概念(我理解它并不意味着“无锁”,而是意味着线程可以在不等待其他线程完成的情况下继续前进)只是没有意义。

有人可以向我展示一个使用堆栈、队列或链表等实现为“无锁”的简单示例,因为我无法理解如何在不干扰其他线程生产力的情况下防止竞争条件? 这两个目标肯定相互矛盾吗?

无锁数据结构使用原子操作,可能会产生额外的要求。 例如,数据结构可能仅对一个读取器和一个写入器线程或任何其他组合是安全的。 在简单链接列表的情况下,将使用原子读取和写入节点指针,以保证多个线程可以同时安全地读取和写入它。

你可能会或可能不会侥幸逃脱。 如果您需要有关数据结构和验证内容的额外保证,那么如果没有某种形式的高级锁定,您可能无法做到这一点。 此外,即使考虑到有关如何使用数据结构的其他要求,也不是每个数据结构都允许重写为无锁。 在这种情况下,不可变对象可能是一种解决方案,但由于复制,它们通常会带来性能损失,这并不总是比锁定对象然后改变它更理想。

有不同的原语可以让人们构建这种无锁数据结构。 例如,compare-and-swap(简称CAS)以原子方式执行以下代码:

CAS(x, o, n)
  if x == o:
    x = n
    return o
  else:
    return x

通过此操作,您可以执行原子更新。 例如,考虑一个非常简单的链表,它以排序顺序存储元素,允许您插入新元素并检查元素是否已存在。 find操作将像以前一样工作:它将遍历所有链接,直到它找到一个元素,或找到比查询更大的元素。 插入需要更加小心。 它可以如下工作:

insert(lst, x)
  xn = new-node(x)
  n = lst.head
  while True:
    n = find-before(n, x)
    xn.next = next = n.next
    if CAS(n.next, next, x) == next:
      break

find-before(n,x)只是在顺序中找到x之前的元素。 当然,这只是一个草图。 一旦你想支持删除,事情会变得更复杂。 我推荐Herlihy和Shavit的“多处理器编程艺术”。 我还应该指出,切换实现相同模型的数据结构通常是有利的,以使它们无锁。 例如,如果你想实现一个等效的std::map ,用红黑树做它会很痛苦,但跳过列表更容易管理。

我发现简单且可解释的是,首先您可以为基于锁(互斥)样式的数据结构编写伪代码,然后尝试查看您持有锁的变量如何通过 CAS 操作以无锁方式进行修改。尽管其他人已经给出了很好的答案,我想补充一点,只有当你自己实现时,你才能感受到它,当然是通过阅读一些发表在其中的研究论文中的一些伪代码。

这是我在 C++ 中实现的一个队列,用于多线程运行的验证测试:

#include<iostream>
#include<atomic>
#include<thread>
#include<vector>
#define N 1000
using namespace std;
class lf_queue
{
private:
    struct node
    {   int data;
        atomic<node*> next;
        node(int d):data(d)
        {}
    };
    atomic<node*> Head;
    atomic<node*> Tail;
public:
    lf_queue()
    {
        node *nnode= new node(-1);
        nnode->next=NULL;
        Head=nnode;
        Tail=nnode;
    }
    void enqueue(int data)
    {
        node *nnode= new node(data);
        nnode->next=NULL;
        node *tail,*next_p;
        while(true)
        {
            tail=Tail.load();
            next_p=tail->next;
            if(tail==Tail.load())
            {
                if(next_p==NULL)
                {
                    if((tail->next).compare_exchange_weak(next_p,nnode))
                    break;
                }
                else
                {
                    Tail.compare_exchange_weak(tail,next_p);
                }
            }
        }
        Tail.compare_exchange_weak(tail,nnode);
    }
    bool dequeue(int &res)
    {
        while(true)
        {
            node *head,*tail,*next_p;
            head=Head.load();
            tail=Tail.load();
            next_p=head->next;
            if(head==Head.load())
            {
                if(head==tail)
                {
                    if(next_p==NULL)
                        return false;
                    Tail.compare_exchange_weak(tail,next_p);
                }
                else
                {
                    res=next_p->data;
                    if(Head.compare_exchange_weak(head,next_p))
                        break;
                }
            }
        }//end loop
        return true;
    }
};
void producer(lf_queue &q)
{   //cout<<this_thread::get_id()<<"Inside producer\n";
    for(int i=0;i<N;i++)
    {
       q.enqueue(1);
     }
    //cout<<this_thread::get_id()<<" "<<"Finished producing\n";
}
void consumer(lf_queue &q,atomic<int>& sum)
{   //cout<<this_thread::get_id()<<" "<<"Inside consumer\n";
    for(int i=0;i<N;i++)
    {
        int res=0;
        while(!q.dequeue(res));
        sum+=res;
    }
    //cout<<this_thread::get_id()<<" "<<"Finished consuming\n";
}
int main()
{
    lf_queue Q;
    atomic<int> sum;
    sum.store(0);
    vector<thread> thread_pool;
    for(int i=0;i<10;i++)
    {   if(i%2==0)
        {   thread t(consumer,ref(Q),ref(sum));
            thread_pool.push_back(move(t));
        }
        else
        {
            thread t(producer,ref(Q));
            thread_pool.push_back(move(t));    
        }
    }
    for(int i=0;i<thread_pool.size();i++)
    thread_pool[i].join();
    cout<<"Final sum "<<sum.load()<<"\n";
    return 0;
}

我尝试使用 Harris 的论文实现无锁链表,但遇到了麻烦,你看 C++11 风格,你只能对 atomic<> 类型执行 CAS,而且这些 atomic<node*> 不能被强制转换为long 是为了进行位窃取,Harris 的实现使用它来逻辑地标记已删除的节点。但是,互联网上有可用的 C 代码实现使用低级 cas_ptr 操作,这为在地址和 long 之间进行转换提供了更大的灵活性。

无锁结构使用原子指令获取资源的所有权。 原子指令锁定它在CPU缓存级别工作的变量,确保另一个核心不会干扰操作。

假设你有这些原子指令:

  • 读(A) - > A.
  • compare_and_swap(A,B,C) - > oldA = A; if(A == B){A = C}; 返回oldA;

使用这些指令,您只需创建一个堆栈:

template<typename T, size_t SIZE>
struct LocklessStack
{
public:
  LocklessStack() : top(0)
  {
  }
  void push(const T& a)
  {
     int slot;
     do
     {
       do
       {
         slot = read(top);
         if (slot == SIZE)
         {
           throw StackOverflow();
         }
       }while(compare_and_swap(top, slot, slot+1) == slot);
       // NOTE: If this thread stop here. Another thread pop and push
       //       a value, this thread will overwrite that value [ABA Problem].
       //       This solution is for illustrative porpoise only
       data[slot] = a;
     }while( compare_and_swap(top, slot, slot+1) == slot );
  }
  T pop()
  {
     int slot;
     T temp;
     do
     {
       slot = read(top);
       if (slot == 0)
       {
         throw StackUnderflow();
       }
       temp = data[slot-1];
     }while(compare_and_swap(top, slot, slot-1) == slot);
     return temp;
  }
private:
  volatile int top;
  T data[SIZE];
};

volatile是必需的,因此编译器不会在优化期间弄乱操作顺序。 发生两次并发推送:

第一个进入while循环并读取插槽,然后第二个推入到达,读取顶部,比较和交换(CAS)成功并增加顶部。 另一个线程唤醒,CAS失败并读取另一个时间顶部..

两个并发弹出:

与之前的案例非常相似。 需要阅读该值。

一次弹出和推动同时发生:

pop读取顶部,读取temp ..按Enter键并修改top并推送一个新值。 Pop CAS失败,pop()将再次执行循环并读取新值

要么

推读顶部并获取一个插槽,弹出输入并修改顶部值。 推送CAS失败并且必须再次循环推送较低的索引。

显然,在并发环境中并非如此

stack.push(A);
B = stack.pop();
assert(A == B); // may fail

因为push是原子而pop是原子的,它们的组合不是原子的。

游戏编程宝石6的第一章是一个很好的参考。

注意代码没有被测试,原子可能真的很讨厌

你对锁自由的定义是错误的。

锁定自由度允许单个线程饿死但保证系统范围的吞吐量。 如果算法满足当程序线程运行足够长时至少有一个线程进展(对于某些明确的进度定义),则该算法是无锁的https://en.wikipedia.org/wiki/Non-blocking_algorithm

这意味着,多个线程访问数据结构只会被授予1; 其余的都会失败

锁定自由的重要一点是内存冲突的可能性。 使用锁保护的数据结构通常比具有原子变量的实现更快,但它不能很好地扩展,并且碰撞的可能性很小。

示例:多个线程不断地在列表中推送数据。 这将导致许多碰撞和经典的互斥体都很好。 但是,如果您有1个线程将数据推送到列表的末尾,并且1个线程在前面弹出数据,则情况会有所不同。 如果列表不为空,则push_back()和pop_front()不会发生冲突(取决于实现),因为它们不对同一个对象起作用。 但是仍然会更改空列表,因此您仍需要保护访问权限。 在这种情况下,锁定自由将是更好的解决方案,因为您可以同时调用这两个函数而无需等待。

简而言之:无锁是专为大型数据结构而设计的,其中多个编写器大多是分离的并且很少发生冲突。

我试着在我自己实现一个无锁列表容器... https://codereview.stackexchange.com/questions/123201/lock-free-list-in-c

给你 - 一个非常基本的(push_front)无锁列表:

template <class T>
class LockFreeList {    
public:
    struct Node {
        T value_;
        Node* next_;
        Node(const T& value) : value_(value), next_(nullptr) {
        }
    };
    
    void push_front(const T& value) {
        Node* new_node = new Node(value);
        Node* old_head = head_;
        do { new_node->next_ = old_head; }
        while (!head_.compare_exchange_weak(old_head, new_node));
    }
    
private:
    std::atomic<Node*> head_;
}; 

受 Fedor Pikus 的 CppCon 2017 演讲的启发,请参阅: https ://youtu.be/ZQFzMfHIxng?t=2432

稍作改动:push_back 使用 compare_exchange_weak。

假设一个简单的操作,将变量递增1。 如果你使用“从内存中读取变量到cpu,将1添加到cpu寄存器,将变量写回”,那么你必须在整个事情中加入某种互斥量,因为你要确保第二个线程直到第一次写回之后才会读取变量。

如果处理器具有原子“增量内存位置”汇编指令,则不需要锁定。

或者,假设您要将元素插入到链接列表中,这意味着您需要使开始指针指向新元素,然后使新元素指向前一个元素。 使用原子“交换两个存储单元”操作,您可以将当前的开始指针写入新元素的“下一个”指针,然后交换两个指针 - 现在,根据首先运行的线程,元素将处于不同的状态列表中的顺序,但列表数据结构保持不变。

基本上,它始终是“在一次原子操作中同时做几件事,所以你不能将操作分解成可能不会中断的单个部分”。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM