【问题标题】:Lock-Free Multiple Producer/Consumer Queue in C++11C++11 中的无锁多生产者/消费者队列
【发布时间】:2014-10-31 19:18:23
【问题描述】:

我正在尝试在 C++11 中实现无锁多生产者、多消费者队列。我这样做是为了学习,所以我很清楚我可以只使用现有的开源实现,但我真的很想找出为什么我的代码不起作用。数据存储在一个环形缓冲区中,显然它是一个“有界 MPMC 队列”。

我已经将它建模得非常接近我所读到的 Disruptor。我注意到的是,它在单个消费者和单个/多个生产者的情况下工作得非常好,只是多个消费者似乎破坏了它。

这是队列:

    template <typename T>
class Queue : public IQueue<T>
{
public:
    explicit Queue( int capacity );
    ~Queue();

    bool try_push( T value );
    bool try_pop( T& value );
private:
    typedef struct
    {
        bool readable;
        T value;
    } Item;

    std::atomic<int> m_head;
    std::atomic<int> m_tail;
    int m_capacity;
    Item* m_items;
};

template <typename T>
Queue<T>::Queue( int capacity ) :
m_head( 0 ),
m_tail( 0 ),
m_capacity(capacity),
m_items( new Item[capacity] )
{
    for( int i = 0; i < capacity; ++i )
    {
        m_items[i].readable = false;
    }
}

template <typename T>
Queue<T>::~Queue()
{
    delete[] m_items;
}

template <typename T>
bool Queue<T>::try_push( T value )
{
    while( true )
    {
        // See that there's room
        int tail = m_tail.load(std::memory_order_acquire);
        int new_tail = ( tail + 1 );
        int head = m_head.load(std::memory_order_acquire);

        if( ( new_tail - head ) >= m_capacity )
        {
            return false;
        }

        if( m_tail.compare_exchange_weak( tail, new_tail, std::memory_order_acq_rel ) )
        {
            // In try_pop, m_head is incremented before the reading of the value has completed,
            // so though we've acquired this slot, a consumer thread may be in the middle of reading
            tail %= m_capacity;

            std::atomic_thread_fence( std::memory_order_acquire );
            while( m_items[tail].readable )
            {
            }

            m_items[tail].value = value;
            std::atomic_thread_fence( std::memory_order_release );
            m_items[tail].readable = true;

            return true;
        }
    }
}

template <typename T>
bool Queue<T>::try_pop( T& value )
{
    while( true )
    {
        int head = m_head.load(std::memory_order_acquire);
        int tail = m_tail.load(std::memory_order_acquire);

        if( head == tail )
        {
            return false;
        }

        int new_head = ( head + 1 );

        if( m_head.compare_exchange_weak( head, new_head, std::memory_order_acq_rel ) )
        {
            head %= m_capacity;

            std::atomic_thread_fence( std::memory_order_acquire );
            while( !m_items[head].readable )
            {
            }

            value = m_items[head].value;
            std::atomic_thread_fence( std::memory_order_release );
            m_items[head].readable = false;

            return true;
        }
    }
}

这是我正在使用的测试:

void Test( std::string name, Queue<int>& queue )
{
    const int NUM_PRODUCERS = 64;
    const int NUM_CONSUMERS = 2;
    const int NUM_ITERATIONS = 512;
    bool table[NUM_PRODUCERS*NUM_ITERATIONS];
    memset(table, 0, NUM_PRODUCERS*NUM_ITERATIONS*sizeof(bool));

    std::vector<std::thread> threads(NUM_PRODUCERS+NUM_CONSUMERS);

    std::chrono::system_clock::time_point start, end;
    start = std::chrono::system_clock::now();

    std::atomic<int> pop_count (NUM_PRODUCERS * NUM_ITERATIONS);
    std::atomic<int> push_count (0);

    for( int thread_id = 0; thread_id < NUM_PRODUCERS; ++thread_id )
    {
        threads[thread_id] = std::thread([&queue,thread_id,&push_count]()
                                 {
                                     int base = thread_id * NUM_ITERATIONS;

                                     for( int i = 0; i < NUM_ITERATIONS; ++i )
                                     {
                                         while( !queue.try_push( base + i ) ){};
                                         push_count.fetch_add(1);
                                     }
                                 });
    }

    for( int thread_id = 0; thread_id < ( NUM_CONSUMERS ); ++thread_id )
    {
        threads[thread_id+NUM_PRODUCERS] = std::thread([&]()
                                         {
                                             int v;

                                             while( pop_count.load() > 0 )
                                             {
                                                 if( queue.try_pop( v ) )
                                                 {
                                                     if( table[v] )
                                                     {
                                                         std::cout << v << " already set" << std::endl;
                                                     }
                                                     table[v] = true;
                                                     pop_count.fetch_sub(1);
                                                 }
                                             }
                                         });

    }

    for( int i = 0; i < ( NUM_PRODUCERS + NUM_CONSUMERS ); ++i )
    {
        threads[i].join();
    }

    end = std::chrono::system_clock::now();
    std::chrono::duration<double> duration = end - start;

    std::cout << name << " " << duration.count() << std::endl;

    std::atomic_thread_fence( std::memory_order_acq_rel );

    bool result = true;
    for( int i = 0; i < NUM_PRODUCERS * NUM_ITERATIONS; ++i )
    {
        if( !table[i] )
        {
            std::cout << "failed at " << i << std::endl;
            result = false;
        }
    }
    std::cout << name << " " << ( result? "success" : "fail" ) << std::endl;
}

我们将不胜感激任何朝着正确方向前进的人。我对内存围栏很陌生,而不仅仅是对所有东西都使用互斥锁,所以我可能只是从根本上误解了一些东西。

干杯 J

【问题讨论】:

  • 您应该在描述中添加您正在构建一个有界 MPMC 队列。这是一个非常重要的方面。
  • 我以前从未听说过这个词,谢谢 =)
  • 我不喜欢线程栅栏获取/释放中的不对称。你确定这是正确的?
  • @LumpN 你指的是哪个不对称?
  • To quote Yakk: “处理无锁数据结构的正确方法是编写一个半正式的证明,证明你的设计在伪代码中有效。你不应该问“这是无锁代码线程安全”,而是“我证明这个无锁代码是线程安全的有任何错误吗?”” 众所周知,这些事情很难做到。我建议查看现有实现以了解其实际难度如何(注意事项等),然后开始校对。

标签: c++ multithreading c++11 queue lock-free


【解决方案1】:

我会看看Moody Camel 的实现。

它是一个完全用 C++11 编写的用于 C++ 的快速通用无锁队列。文档似乎相当不错,还有一些性能测试。

在所有其他有趣的东西中(无论如何它们都值得一读),它们都包含在一个标题中,并且可以在简化的 BSD 许可下使用。只需将其放入您的项目中即可享受!

【讨论】:

  • 第二个头文件是阻塞版本的。
【解决方案2】:

最简单的方法是使用循环缓冲区。这就像一个由 256 个元素组成的数组,您使用 uint8_t 作为索引,因此当您溢出它时它会环绕并从头开始。

您可以构建的最简单的原语是当您拥有单个生产者、单个消费者线程时。

缓冲区有两个头:

  • 写头:指向接下来要写的元素。
  • 读取头:指向下一个要读取的元素。

生产者的操作:

  1. 如果写 Head + 1 == read head,则缓冲区已满,返回缓冲区已满错误。
  2. 将内容写入元素。
  3. 插入内存屏障以同步 CPU 内核。
  4. 向前移动写入头。

在缓冲区满的情况下,还剩下 1 个房间,但我们保留它,以区别于缓冲区空的情况。

消费者的操作:

  1. 如果read head == write head,则缓冲区为空,返回缓冲区空错误。
  2. 读取元素的内容。
  3. 插入内存屏障以同步 CPU 内核。
  4. 向前移动读取头。

生产者拥有写头,消费者拥有读头,它们之间没有并发。当操作完成时,heads 也会更新,这样可以确保消费者留下完成的元素,而消费者留下完全消费的空单元格。

每当你分叉一个新线程时,在两个方向上创建两个这样的管道,你可以与你的线程进行双向通信。

鉴于我们谈论的是无锁,这也意味着没有线程被阻塞,当没有任何事情可做时,线程正在空转,您可能希望检测到这一点并在它发生时添加一些睡眠。

【讨论】:

  • 确实,在需要通信的每对线程之间创建一对 spsc 队列是最简单的,并且在高争用情况下通常比单个 mpmc 队列性能更高。您至少需要多消费者是有原因的,例如偷工作。如果您有兴趣,这是我对整个兔子洞的记录:codersblock.org/blog/2016/6/02/ditching-the-mutex
【解决方案3】:

这个lock free queue怎么样

它是内存排序无锁队列,但是这个需要在初始化队列时预先设置当前线程的数量。

例如:-

int* ret;
int max_concurrent_thread = 16;
lfqueue_t my_queue;

lfqueue_init(&my_queue, max_concurrent_thread );

/** Wrap This scope in other threads **/
int_data = (int*) malloc(sizeof(int));
assert(int_data != NULL);
*int_data = i++;
/*Enqueue*/
 while (lfqueue_enq(&my_queue, int_data) == -1) {
    printf("ENQ Full ?\n");
}

/** Wrap This scope in other threads **/
/*Dequeue*/
while  ( (int_data = lfqueue_deq(&my_queue)) == NULL) {
    printf("DEQ EMPTY ..\n");
}

// printf("%d\n", *(int*) ret );
free(ret);
/** End **/

lfqueue_destroy(&my_queue);

【讨论】:

    【解决方案4】:

    在另一个类似的问题上,我向这个问题提出了a solution。我相信它是迄今为止发现的最小的。

    我不会在这里给出相同的答案,但the repository 具有您想要的无锁队列的全功能 C++ 实现。

    编辑:感谢@PeterCordes 的代码审查,我在使用 64 位模板时发现了解决方案的一个错误,但现在它运行良好。

    这是我在运行测试时收到的输出

    Creating 4 producers & 4 consumers
    to flow 10.000.000 items trough the queue.
    
    Produced: 10.743.668.245.000.000
    Consumed: 5.554.289.678.184.004
    Produced: 10.743.668.245.000.000
    Consumed: 15.217.833.969.059.643
    Produced: 10.743.668.245.000.000
    Consumed: 7.380.542.769.600.801
    Produced: 10.743.668.245.000.000
    Consumed: 14.822.006.563.155.552
    
    Checksum: 0 (it must be zero)
    

    【讨论】:

    • 很像我在这里的实现codersblock.org/blog/2016/6/02/ditching-the-mutex 你的解决方案并不是真正的无锁。如果您在获取它们的插槽和写入项目之间暂停生产者线程,或者在读取他们的项目和清除它们的插槽之间暂停消费者线程,那么它将锁定数据结构。
    • 我认为从技术上讲,将并发写入者写入 std::vector 是不安全的,即使您切换到 std::array 或 T[],如果 T 不能以原子方式写入那么你有一场数据竞赛。如果 T 可以原子地写入,如果项目没有自然对齐,您仍然可能会得到一个撕裂的读取(数据竞争)。除此之外,它看起来还不错,我认为因为您使用的是 seq_cst 内存排序,所以您可以通过观察正确的副作用为自己省去一些麻烦。我建议您在认为安全时使用 Relacy 对其进行测试。
    • @Joe:大多数无锁队列在技术上并不是无锁的;但在实践中表现良好。 Lock-free Progress Guarantees。您可以使用原子让一个线程“声明”std::vector 中的一个插槽。但你是对的,这个队列不这样做,这是一个错误。它不区分已声明和已完成的写作状态,因此您可以让作家与读者竞争。在github.com/bittnkr/uniq/blob/master/cpp/uniq.hbuffer[t &amp; mask] = item 之后没有原子操作。使用sizeof(T)=128 或其他东西进行测试应该会显示撕裂。
    • @PeterCordes 确实,我同意,大多数都不是,在现实世界的用例中,真正的无锁不一定更好。除了 std::vector - 我知道实际上你肯定只是在写一个数组,但我认为标准说你不应该有并发写入 std::vector。任何人,自从我写这篇文章以来,我个人已经走了很长一段路,并且不提倡使用多生产者多消费者队列(尽管这是一个有趣的练习)。这些天来,我发现线程对之间的单生产者单消费者队列的性能最好。
    • @Joe:是的。但不幸的是,这个队列似乎确实在作者和读者之间对单个元素进行了竞争。 (反之亦然,pop 执行 buffer[t] = 0; 可能会踩到来自 push 的数据。)并且缺乏同步将使 pop 读取已声明但尚未写入的条目,如果它去的话甚至比你指出的撕裂还要早。
    猜你喜欢
    • 1970-01-01
    • 2011-02-11
    • 1970-01-01
    • 2012-01-12
    • 2011-08-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多