【问题标题】:Avoiding false sharing of SPSC queue indices避免 SPSC 队列索引的错误共享
【发布时间】:2020-08-13 21:20:17
【问题描述】:

让我们想象一个无锁并发 SPSC(单生产者/单消费者)队列。

  • 生产者线程 读取 headtailcached_tail写入 headcached_tail
  • 消费者线程 读取 headtailcached_head写入 tailcached head

注意,cached_tail 只能被生产者线程访问,就像 cached_head 只能被消费者线程访问一样。它们可以被认为是私有线程局部变量,因此它们是不同步的,因此没有定义为原子的。

队列的数据布局如下:

#include <atomic>
#include <cstddef>
#include <thread>

struct spsc_queue
{
    /// ...

    // Producer variables
    alignas(std::hardware_destructive_interference_size) std::atomic<size_t> head; // shared
    size_t cached_tail; // non-shared

    // Consumer variables
    alignas(std::hardware_destructive_interference_size) std::atomic<size_t> tail; // shared
    size_t cached_head; // non-shared

    std::byte padding[std::hardware_destructive_interference_size - sizeof(tail) - sizeof(cached_head)];
};

由于我想避免错误共享,我将headtail 与L1 缓存行大小对齐。

push/pop 操作的伪代码实现如下:

bool push(const void* elems, size_t n)
{
    size_t h = atomic_load(head, relaxed);

    if (num_remaining_storage(h, cached_tail) < n)
    {
        cached_tail = atomic_load(tail, acquire);

        if (num_remaining_storage(h, cached_tail) < n)
            return false;
    }

    // write from elems

    atomic_store(head, h + n, release);
    return true;
}

bool pop(void* elems, size_t n)
{
    size_t t = atomic_load(tail, relaxed);

    if (num_stored_elements(cached_head, t) < n)
    {
        cached_head = atomic_load(head, acquire);

        if (num_stored_elements(cached_head, t) < n)
            return false;
    }

    // read to elems

    atomic_store(tail, t + n, release);
    return true;
}

void wait_and_push(const void* elems, size_t n)
{
    size_t h = atomic_load(head, relaxed);

    while (num_remaining_storage(h, cached_tail) < n)
        cached_tail = atomic_load(tail, acquire);

    // write from elems

    atomic_store(head, h + n, release);
}

void wait_and_pop(void* elems, size_t n)
{
    size_t t = atomic_load(tail, relaxed);

    while (num_stored_elements(cached_head, t) < n)
        cached_head = atomic_load(head, acquire);

    // write to elems

    atomic_store(tail, t + n, release);
}

在初始化时(此处未列出),所有索引都设置为0。 函数 num_remaining_storagenum_stored_elementsconst 函数,它们根据传递的参数和不可变的队列容量执行简单的计算 - 它们不执行任何原子读取或写入。

现在的问题是:我是否还需要对齐 cached_tailcached_head 以完全避免错误共享任何索引,或者它是可以的。由于cached_tail 是生产者私有的,cached_head 是消费者私有的,我认为cached_tail 可以与head(生产者缓存行)在同一缓存行中,就像cached_head 与@ 在同一缓存行中一样987654350@(消费者缓存行)不会发生错误共享。

我错过了什么吗?

【问题讨论】:

  • FWIW,我很确定您不需要对齐缓存的值,但还没有足够的信心。
  • 您可能需要 3 个缓存行:仅写入器、仅读取器以及两者都可以访问的内容。理想情况下,您可以避免让两个线程频繁写入共享行。甚至一个写入和另一个读取也不断将其从 Modified 翻转为 Shared,并要求执行存储的线程执行 RFO 以重新获得独占所有权。但是将非共享变量放在单独的缓存行中可能会在等待这些共享请求或 RFO 时取得更多进展/进行中。
  • @PeterCordes 我不完全理解你的评论。您是否建议将cached_tailcached_head 放入一个公共的但第三个缓存行?即使它们不是跨线程共享的,它们也由不同的线程处理(cached_tail 由生产者处理,cached_head 由消费者处理)。在访问模式中查看我的问题的开头。
  • 我应该说“仅限生产者”/“仅限消费者”/两者都可以访问的东西。显然,仅由一个线程访问的变量应该放在另一个线程永远不会触及的缓存行中。 IDK,如果您认为我是说按只写数据和只读数据来分解它?不,我的意思是作家=制片人。无论如何,困难的问题是如何拆分实际共享的其他变量,以及是否有任何从拥有更多行中获得或失去的东西,因此您可以在生产者的行中拥有只有生产者写入(由消费者读取)的变量至少可以阅读。
  • @PeterCordes 啊,好吧,现在很清楚了。好吧,headcached_tail 总是在 push() 中一起更新——就像 pop() 中的 tailcache_head 一样。但是headtail 被两个线程访问。在最坏的情况下,我可以使用四个缓存行,每个缓存行一个。根据您的建议,headtail 应该转到同一个缓存行,因为两个线程都可以访问它们。那肯定会导致虚假分享。我认为这不是一个好主意,而且我见过的所有无锁实现都将它们(headtail)放入单独的缓存行中。

标签: c++ concurrency atomic lock-free false-sharing


【解决方案1】:

感谢您提供伪代码 - 它仍然缺少一些细节,但我想我明白了基本概念。您有一个有界 SPSC 队列,其中索引可以环绕,并且您使用 push 中的 cached_tail 变量来检查是否有空闲插槽,因此您可以避免从可能无效的缓存行加载 tail(反之亦然pop 反之亦然。

我建议将headcached_tail 放在一起(即在同一缓存行上),将tailcached_head 放在不同的缓存行上。 push always 读取 both 变量 - headcached_tail,因此将它们靠近在一起是有意义的。 cached_tail 仅在没有更多空闲插槽时更新,我们必须重新加载 tail

您的代码在细节上有点薄,但似乎还有一些优化空间:

bool push(const void* elems, size_t n)
{
    size_t h = atomic_load(head);

    if (num_remaining_storage(h, cached_tail) < n)
    {
        auto t = atomic_load(tail);
        if (t == cached_tail)
          return false;

       // we only have to update our cached_tail if the reloaded value
       // is different - and in this case it is guaranteed that there
       // is a free slot, so we don't have to perform a recheck.
        cached_tail = t;
    }

    // write from elems

    atomic_store(head, h + n);
    return true;
}

这样cached_tail 只有在head 也更新时才会更新,所以这是它们位于同一缓存行的另一个原因。当然,同样的优化也可以应用于pop

这正是我想看一些代码的原因,因为访问模式对于确定哪些变量应该共享一个缓存行和哪些不应该是至关重要的。

【讨论】:

  • 延迟更新cached_tailcached_head 的好主意。这消除了拥有 4 个单独的高速缓存行的任何可能的好处。 (如果没有,我想知道将它们分开是否有意义,因此在重试 push 或 pop 时旋转并没有写入其他线程需要写入的行。在队列满或空的情况下。)即使那样我'我不确定这是否重要。如果该行处于共享状态,则其他线程可以更轻松地读取它,但无论如何我们仍然必须在存储上进行 RFO,无论我们在读取后是否再次使其无效。
  • (我认为同样的优化可以适用于pop,或者至少是类似的。)
  • @PeterCordes 是的,当然同样的优化也可以应用于pop。我确实想提到这一点,但显然忘记了。感谢您的注意,我已经更新了我的答案。
  • 关于进一步优化的好答案和想法!我用更多细节更新了我的答案,包括pushpopwait_and_pushwait_and_pop)的内存排序和等待版本。如果您对它们有更多建议,请随时与我分享。 ;]
  • 啊,是的,您当然可以避免重复写入缓存值。事实上,我会使用一个局部变量来跟踪索引,并且只在有足够空间进行时才写入缓存的变量一次,彼得代码已经建议过。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-04-02
  • 1970-01-01
  • 2016-05-20
  • 2016-06-23
  • 2023-04-07
相关资源
最近更新 更多