【问题标题】:Adding blocking functions to lock-free queue向无锁队列添加阻塞函数
【发布时间】:2015-09-21 09:52:37
【问题描述】:

我有一个基于循环缓冲区的无锁多生产者、单消费者队列。到目前为止,它只有非阻塞的push_back()pop_front() 调用。现在我想添加这些调用的阻塞版本,但我想尽量减少这对使用非阻塞版本的代码性能的影响——也就是说,它不应该把它们变成“lock-by-default ”调用。

例如阻塞 push_back() 的最简单版本如下所示:

void push_back_Blocking(const T& pkg) {
    if (!push_back(pkg)) {
        unique_lock<mutex> ul(mux);
        while (!push_back(pkg)) {
            cv_notFull.wait(ul);
        }
    }
}

但不幸的是,这也需要将以下块放在“非阻塞”pop_front() 的末尾:

{
    std::lock_guard<mutex> lg(mux);
    cv_notFull.notify_all();
}

虽然 notify 单独对性能几乎没有任何影响(如果没有线程在等待),但锁有。

所以我的问题是:
我怎样才能(如果可能,使用标准 c++14)将阻塞 push_backpop_front 成员函数添加到我的队列中而不严重阻碍 non_blocking 对应项的性能(阅读:最小化系统调用)?至少只要实际上没有线程被阻塞 - 但理想情况下即使如此。


作为参考,我当前的版本与此类似(我省略了调试检查、数据对齐和显式内存排序):

template<class T, size_t N>
class MPSC_queue {
    using INDEX_TYPE = unsigned long;
    struct Idx {
        INDEX_TYPE idx;
        INDEX_TYPE version_cnt;
    };
    enum class SlotState {
        EMPTY,
        FILLED
    };
    struct Slot {
        Slot() = default;               
        std::atomic<SlotState> state= SlotState::EMPTY;
        T data{};
    };
    struct Buffer_t {
        std::array<Slot, N> data{}; 
        Buffer_t() {
            data.fill(Slot{ SlotState::EMPTY, T{} });
        }
        Slot& operator[](Idx idx) {
            return this->operator[](idx.idx);
        }
        Slot& operator[](INDEX_TYPE idx) {
            return data[idx];                   
        }
    };

    Buffer_t buffer;
    std::atomic<Idx> head{};
    std::atomic<INDEX_TYPE> tail=0;

    INDEX_TYPE next(INDEX_TYPE old) { return (old + 1) % N; }

    Idx next(Idx old) {
        old.idx = next(old.idx);
        old.version_cnt++;
        return old;
    }
public:     
    bool push_back(const T& val) {
        auto tHead = head.load();
        Idx wrtIdx;
        do {
            wrtIdx = next(tHead);
            if (wrtIdx.idx == tail) {
                return false;
            }
        } while (!head.compare_exchange_strong(tHead, wrtIdx));

        buffer[wrtIdx].data = val;
        buffer[wrtIdx].state = SlotState::FILLED;
        return true;
    }

    bool pop_front(T& val) {                
        auto rIdx = next(tail);
        if (buffer[rIdx].state != SlotState::FILLED) {
            return false;
        }
        val = buffer[rIdx].data;
        buffer[rIdx].state = SlotState::EMPTY;
        tail = rIdx;
        return true;
    }
};

相关问题:

我问了一个类似的问题,专门关于优化condition_variable::notify here 的使用,但这个问题被关闭了,因为据说是this question 的重复。
我不同意,因为这个问题是关于为什么条件变量通常需要互斥锁(或者说它是 pthread 等价的)——关注condition_variable::wait——而不是notify 部分是否/如何避免它。但显然我没有说得足够清楚(或者人们只是不同意我的观点)。

无论如何,链接问题中的答案对我没有帮助,因为无论如何这有点像XY-problem,我决定就我遇到的实际问题提出另一个问题,从而允许更广泛的可能解决方案(也许有一种方法可以完全避免条件变量)。

This question也很相似,但是

  1. 它是关于 linux 上的 C 并且答案使用特定于平台的 构造(pthreads 和 futexes)
  2. 那里的作者要求有效的阻塞调用,但根本没有非阻塞调用。另一方面,我不太关心阻塞的效率,但希望尽可能快地保持非阻塞的效率。

【问题讨论】:

  • This question 链接无效。你能修好吗?此外,head 成员上的原子操作实际上并不是无锁,因为它的大小(2 * unsigned long)。
  • @Tsyvarev:谢谢,我修复了链接。 std::atomic&lt;Idx&gt; 在 VS2015 (x64) 上无锁(只需检查 std::atomic&lt;Idx&gt;{}.is_lock_free()),据我所知,gcc 和 clang 甚至可以对 128 位大小的数据类型(两个 size_t 变量)执行此操作
  • 哦,刚刚发现现代x86_64支持双CAS。没关系。

标签: c++ multithreading blocking lock-free condition-variable


【解决方案1】:

如果条件变量上有潜在服务员,您必须notify_all调用锁定互斥锁。

问题是条件检查 (!push_back(pkg)) 在之前 等待条件变量 (C++11 没有提供其他方式)。因此,互斥锁是唯一可以保证这些操作之间一致性的方法。

但在没有潜在服务员参与的情况下,可以省略锁定(和通知)。只需使用附加标志:

class MPSC_queue {
    ... // Original definitions
    std::atomic<bool> has_waiters;

public:
    void push_back_Blocking(const T& pkg) {
        if (!push_back(pkg)) {
            unique_lock<mutex> ul(mux);
            has_waiters.store(true, std::memory_order_relaxed); // #1
            while (!push_back(pkg)) { // #2 inside push_back() method
                cv_notFull.wait(ul);
                // Other waiter may clean flag while we wait. Set it again. Same as #1.
                has_waiters.store(true, std::memory_order_relaxed);
            }
            has_waiters.store(false, std::memory_order_relaxed);
        }
    }

    // Method is same as original, exposed only for #2 mark.
    bool push_back(const T& val) {
        auto tHead = head.load();
        Idx wrtIdx;
        do {
            wrtIdx = next(tHead);
            if (wrtIdx.idx == tail) { // #2
                return false;
            }
        } while (!head.compare_exchange_strong(tHead, wrtIdx));

        buffer[wrtIdx].data = val;
        buffer[wrtIdx].state = SlotState::FILLED;
        return true;
    }

    bool pop_front(T& val) {
        // Main work, same as original pop_front, exposed only for #3 mark.
        auto rIdx = next(tail);
        if (buffer[rIdx].state != SlotState::FILLED) {
            return false;
        }
        val = buffer[rIdx].data;
        buffer[rIdx].state = SlotState::EMPTY;
        tail = rIdx; // #3

        // Notification part
        if(has_waiters.load(std::memory_order_relaxed)) // #4
        {
            // There are potential waiters. Need to lock.
            std::lock_guard<mutex> lg(mux);
            cv_notFull.notify_all();
        }

        return true;
    }
};

这里的关键关系是:

  1. #1 设置标志并在#2 读取tail 以检查条件。
  2. tail 存储在#3 并检查标志在#4

这两种关系都应该暴露出某种普遍秩序。也就是说#1 应该在#2 之前被观察到,即使是其他线程。 #3#4 相同。

在这种情况下,可以保证,如果检查标志#4 发现它没有设置,那么可能的进一步条件检查#2 将发现条件更改的影响#3。所以不锁定(和通知)是安全的,因为没有服务员是可能的。

在您当前的实现中,#1#2 之间的通用顺序是通过使用隐式 memory_order_seq_cst 加载 tail 来提供的。通过使用隐式 memory_order_seq_cst 存储 tail 来提供 #3#4 之间的相同顺序。

在这种方法中,“如果没有服务员就不要锁定”,通用顺序是最棘手的部分。在这两种关系中,都是Read After Write顺序,不能通过memory_order_acquirememory_order_release的任意组合来实现。所以应该使用memory_order_seq_cst

【讨论】:

  • 我没有意识到,你可以将内存屏障的数量降到最低——非常感谢!但是,我认为,has_waiters 必须是计数器而不是布尔值,因为目前 - 如果我没有遗漏某些内容 - 您正在无条件地将标志设置为 false,即使可能有其他线程可能在等待。
  • 可能,使用计数器而不是标志会更自然。但是这里notify_all 会唤醒所有 服务员。被唤醒后,服务员重新设置标志并再次检查条件(参见.wait()调用后的行描述)。
  • 对,我没看到——虽然我可能更喜欢使用计数器和notify_one——我必须做一些测量。顺便说一句:我刚刚意识到,我的队列中有一个严重的错误:Slot::state 不是原子的! :(
  • pop_front()方法中,如果has_waiters为真,则获取mux互斥量,然后通知cv_notFull.notify_all();这里真的需要获取互斥量吗?根据cppreference,不必为了通知而持有锁。这将(可能)导致 wait() 内部再次阻塞,等待互斥锁可用。但是在这种情况下,我可能错过了一些微妙的原因,即不锁定会使其不安全。 SPSC 会有什么不同吗?
  • 标志has_waiters 在添加实际服务员之前cv_notFull 条件变量设置(参见push_back_Blocking() 方法)。如果没有受保护的.notify_all() 调用,.wait() 可能会错过通知。至于 SPSC,您需要重新访问它所需的一组方法。可能单个生产者不需要阻塞和非阻塞方法。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-10-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多