C ++ 11 lockfree单一生产者单一消费者:如何避免忙等待

时间:2014-06-09 12:06:38

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

我试图实现一个使用两个线程的类:一个用于生产者,一个用于消费者。当前实现不使用锁:

#include <boost/lockfree/spsc_queue.hpp>
#include <atomic>
#include <thread>

using Queue =
        boost::lockfree::spsc_queue<
            int,
            boost::lockfree::capacity<1024>>;

class Worker
{
public:
    Worker() : working_(false), done_(false) {}
    ~Worker() {
        done_ = true;    // exit even if the work has not been completed
        worker_.join();
    }

    void enqueue(int value) {
        queue_.push(value);
        if (!working_) {
            working_ = true;
            worker_ = std::thread([this]{ work(); });
        }
    }

    void work() {
        int value;
        while (!done_ && queue_.pop(value)) {
            std::cout << value << std::endl;
        }
        working_ = false;
    }

private:
    std::atomic<bool> working_;
    std::atomic<bool> done_;
    Queue queue_;
    std::thread worker_;
};

应用程序需要将工作项排入一定时间,然后等待事件休眠。这是模拟行为的最小主要部分:

int main()
{
    Worker w;
    for (int i = 0; i < 1000; ++i)
        w.enqueue(i);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    for (int i = 0; i < 1000; ++i)
        w.enqueue(i);
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

我非常确定我的实现存在错误:如果工作线程完成并且在执行working_ = false之前,另一个enqueue会出现什么?是否可以在不使用锁的情况下使我的代码线程安全?

解决方案需要:

  • 快速入队
  • 即使队列不为空,析构函数也必须退出
  • 没有忙等待,因为工作线程闲置很长一段时间
  • 如果可能,没有锁

修改

我根据你的建议做了Worker课程的另一个实现。这是我的第二次尝试:

class Worker
{
public:
    Worker()
        : working_(ATOMIC_FLAG_INIT), done_(false) { } 

    ~Worker() {
        // exit even if the work has not been completed
        done_ = true;
        if (worker_.joinable())
            worker_.join();
    }

    bool enqueue(int value) {
        bool enqueued = queue_.push(value);
        if (!working_.test_and_set()) {
            if (worker_.joinable())
                worker_.join();
            worker_ = std::thread([this]{ work(); });
        }
        return enqueued;
    }

    void work() {
        int value;
        while (!done_ && queue_.pop(value)) {
            std::cout << value << std::endl;
        }
        working_.clear();
        while (!done_ && queue_.pop(value)) {
            std::cout << value << std::endl;
        }
    }

private:
    std::atomic_flag working_;
    std::atomic<bool> done_;
    Queue queue_;
    std::thread worker_;
};

我在worker_.join()方法中引入了enqueue。这可能会影响性能,但在极少数情况下(当队列变空并且在线程退出之前,另一个enqueue会出现)。 working_变量现在是atomic_flag,在enqueue中设置并在work中清除。需要while之后的其他working_.clear(),因为如果在clear之前推送了另一个值,但在while之后,则不会处理该值。

此实施是否正确?

我做了一些测试,实施似乎也有效。

OT:将它作为编辑或答案更好吗?

4 个答案:

答案 0 :(得分:2)

  

如果工作线程完成并且在执行working_ = false之前,又会出现另一个队列?

然后,该值将被推送到队列,但在设置标志后将另一个值排队后才会处理。您(或您的用户)可以决定是否可以接受。使用锁可以避免这种情况,但它们不符合您的要求。

如果正在运行的线程即将完成并设置working_ = false;但在下一个值排队之前尚未停止运行,则代码可能会失败。在这种情况下,您的代码将在正在运行的线程上调用operator=,这会导致根据链接的文档调用std::terminate

在将worker分配给新线程之前添加worker_.join()应该可以防止这种情况。

另一个问题是,如果队列已满,queue_.push可能会失败,因为它具有固定大小。目前您只是忽略该情况,并且该值不会添加到完整队列中。如果等待队列有空间,则不会快速入队(在边缘情况下)。您可以接受push返回的bool(告诉它是否成功)并从enqueue返回。这样,调用者可以决定是否要等待或丢弃该值。

或使用非固定大小的队列。 Boost对此有这样的选择:

  

可用于在推送期间完全禁用动态内存分配,以确保无锁行为。   如果数据结构配置为固定大小,则内部节点存储在数组中并对其进行寻址   通过数组索引。这将队列的可能大小限制为索引可以处理的元素数   类型(通常为2 ** 16-2),但在缺少双宽度比较和交换指令的平台上,这是最好的方法   实现锁定自由。

答案 1 :(得分:1)

您的工作线程需要超过2个状态。

  • 未开始
  • 执行任务
  • 空闲关机
  • 关闭

如果强制关机,它会跳过空闲关机。如果任务用完,它将转换为空闲关闭。在空闲关闭时,它会清空任务队列,然后进入关闭状态。

关闭已设置,然后您将离开工作任务的末尾。

生产者首先把东西放在队列中。然后它检查工人状态。如果关闭或空闲关闭,首先join(并将其转换为未运行),然后启动新工作线程。如果没有运行,只需启动一个新工作人员。

如果生产者想要启动一个新工作者,它首先要确保我们处于未运行状态(否则,逻辑错误)。然后我们转换到Doing tasks状态,然后启动工作线程。

如果生产者想要关闭辅助任务,它会设置完成标志。然后它检查工人状态。如果它除了没有运行之外什么都会加入它。

这可能导致工作线程无故启动。

有一些情况下,上面可以阻止,但也有一些之前。

然后,我们写了一份正式或半正式的证据,证明上述内容不会丢失信息,因为在编写无锁代码时,除非您有证据,否则您无法完成。

答案 2 :(得分:0)

这是我对问题的解决方案。我不喜欢回答自己,但我认为展示实际代码可能对其他人有所帮助。

#include <boost/lockfree/spsc_queue.hpp>
#include <atomic>
#include <thread>
// I used this semaphore class: https://gist.github.com/yohhoy/2156481
#include "binsem.hpp"

using Queue =
    boost::lockfree::spsc_queue<
        int,
        boost::lockfree::capacity<1024>>;

class Worker
{
public:
    // the worker thread starts in the constructor
    Worker()
        : working_(ATOMIC_FLAG_INIT), done_(false), semaphore_(0)
        , worker_([this]{ work(); })
    { } 

    ~Worker() {
        // exit even if the work has not been completed
        done_ = true;
        semaphore_.signal();
        worker_.join();
    }

    bool enqueue(int value) {
        bool enqueued = queue_.push(value);
        if (!working_.test_and_set())
            // signal to the worker thread to wake up
            semaphore_.signal();
        return enqueued;
    }

    void work() {
        int value;
        // the worker thread continue to live
        while (!done_) {
            // wait the start signal, sleeping
            semaphore_.wait();
            while (!done_ && queue_.pop(value)) {
                // perform actual work
                std::cout << value << std::endl;
            }
            working_.clear();
            while (!done_ && queue_.pop(value)) {
                // perform actual work
                std::cout << value << std::endl;
            }
        }
    }

private:
    std::atomic_flag working_;
    std::atomic<bool> done_;
    binsem semaphore_;
    Queue queue_;
    std::thread worker_;
};

我尝试了@Cameron的建议,不关闭线程并添加信号量。这实际上仅在第一个enqueue和最后一个work中使用。这不是无锁的,只是在这两种情况下。

我在之前的版本(请参阅我编辑的问题)和此版本之间进行了一些性能比较。当没有多少开始和停止时,没有显着差异。但是,当enqueue必须signal工作线程而不是启动新线程时,enqueue要快10倍。这是一种罕见的情况,所以它不是很重要,但无论如何它都是一种改进。

此实现满足:

  • 在常见情况下无锁(当workenqueue忙时);
  • 没有忙碌等待很长时间没有{{1}}
  • 析构函数尽快退出
  • 正确性?? :)

答案 3 :(得分:0)

非常局部的答案:我认为所有这些原子,信号量和状态都是从“线程”到“工人”的反向沟通渠道。为什么不为此使用另一个队列?至少考虑一下会帮助您解决问题。