这些seqlock的实现哪些是正确的?

25

我正在研究Seqlock的实现。然而,我找到的所有来源都有不同的实现方法。

Linux内核

Linux内核是这样实现的

static inline unsigned __read_seqcount_begin(const seqcount_t *s)
{
    unsigned ret;

repeat:
    ret = READ_ONCE(s->sequence);
    if (unlikely(ret & 1)) {
        cpu_relax();
        goto repeat;
    }
    return ret;
}

static inline unsigned raw_read_seqcount_begin(const seqcount_t *s)
{
    unsigned ret = __read_seqcount_begin(s);
    smp_rmb();
    return ret;
}

基本上,它在读取器端使用了一次易失性读取和具有获取语义的读取屏障。使用时,后续读取是不受保护的。
struct Data {
    u64 a, b;
};

// ...
read_seqcount_begin(&seq);
int v1 = d.a, v2 = d.b;
// ...

rigtorp/Seqlock

RIGTORP_SEQLOCK_NOINLINE T load() const noexcept {
  T copy;
  std::size_t seq0, seq1;
  do {
    seq0 = seq_.load(std::memory_order_acquire);
    std::atomic_signal_fence(std::memory_order_acq_rel);
    copy = value_;
    std::atomic_signal_fence(std::memory_order_acq_rel);
    seq1 = seq_.load(std::memory_order_acquire);
  } while (seq0 != seq1 || seq0 & 1);
  return copy;
}

数据负载仍然没有进行原子操作或保护。但是,在读取之前添加了具有获取-释放语义的atomic_signal_fence,与内核中具有获取语义的rmb相反。

Amanieu/seqlock(Rust)

pub fn read(&self) -> T {
    loop {
        // Load the first sequence number. The acquire ordering ensures that
        // this is done before reading the data.
        let seq1 = self.seq.load(Ordering::Acquire);

        // If the sequence number is odd then it means a writer is currently
        // modifying the value.
        if seq1 & 1 != 0 {
            // Yield to give the writer a chance to finish. Writing is
            // expected to be relatively rare anyways so this isn't too
            // performance critical.
            thread::yield_now();
            continue;
        }

        // We need to use a volatile read here because the data may be
        // concurrently modified by a writer.
        let result = unsafe { ptr::read_volatile(self.data.get()) };

        // Make sure the seq2 read occurs after reading the data. What we
        // ideally want is a load(Release), but the Release ordering is not
        // available on loads.
        fence(Ordering::Acquire);

        // If the sequence number is the same then the data wasn't modified
        // while we were reading it, and can be returned.
        let seq2 = self.seq.load(Ordering::Relaxed);
        if seq1 == seq2 {
            return result;
        }
    }
}

在加载 seqdata 之间没有内存屏障,而是使用了一个 volatile 读取。

Seqlock 能否与编程语言内存模型兼容?(第三个变体)

T reader() {
  int r1, r2;
  unsigned seq0, seq1;
  do {
    seq0 = seq.load(m_o_acquire);
    r1 = data1.load(m_o_relaxed);
    r2 = data2.load(m_o_relaxed);
    atomic_thread_fence(m_o_acquire);
    seq1 = seq.load(m_o_relaxed);
  } while (seq0 != seq1 || seq0 & 1);
  // do something with r1 and r2;
}

与Rust实现类似,但是数据上使用原子操作而不是volatile_read

P1478R1: 字节级原子memcpy中的参数

该论文声称:

在一般情况下,有很好的语义理由要求在这样一个seqlock“关键段”内的所有数据访问都必须是原子的。如果我们读取指针p作为读取数据的一部分,然后也读取*p,如果p的读取恰好看到了半更新的指针值,则关键段内的代码可能会从错误的地址读取。在这种情况下,可能没有办法避免使用传统的原子加载读取指针,这正是所需的。

然而,在许多情况下,特别是在多进程情况下,seqlock数据由单个可以简单复制的对象组成,而seqlock“关键段”由一个简单的复制操作组成。在正常情况下,这可以使用memcpy来编写。但这在这里是不可接受的,因为memcpy不生成原子访问,并且(根据我们的规范)容易受到数据竞争的影响。

目前,要正确地编写这样的代码,我们需要将这些数据基本上分解成许多小型无锁原子子对象,并逐个复制它们。将数据视为单个大型原子对象将破坏seqlock的目的,因为原子复制操作将获取传统锁定。我们的提案基本上添加了一个方便的库设施来自动化这种分解成小对象的过程。

我的问题

  1. 上述实现中哪些是正确的?哪些是正确但效率低下的?
  2. volatile_read能否在seqlock的acquire-read之前重新排序?

5
根据哪个书面规范或非书面的隐含假设进行校正?技术上,数据竞争会导致未定义行为。使用 volatile 访问可以获得数据竞争的本机 CPU 行为(这是明确定义的)。但我认为混合使用 volatile、原子操作和内存屏障是被明确定义的;特别是内存屏障被定义为对原子操作而非 volatile 访问进行排序。有些东西可能“奏效”,也就是编译成代码,碰巧提供了可证明在生成的汇编级别上仍能提供正确保证的行为,但这种情况可能因为新的更好的编译器而破坏。 - curiousguy
1
我认为你的问题需要一本书来回答,所以我认为它太宽泛了,即使这是一个有趣的问题。 - Stargateur
@curiousguy,“新的、更好的编译器”是夸张的说法,因为编译器没有完全正式化和可靠的内存模型来定义它们如何应用优化。你甚至可能需要一个额外的合同(抽象/简化变量假设)来获得最佳性能和所有情况下的可靠性,因为边角情况的检测可能在技术上过于棘手。 - Jay-Pi
1
@Jay-Pi: ISO C++11 确实引入了正式的线程感知内存模型。我认为可以通过使用大小为 atomic<long> 的块(具有mo_relaxed)和一个恰当放置的 atomic_thread_fence(mo_acquire) 来编写符合该模型的 seqlock。但是,这可能会破坏一些优化,特别是如果数据足够大,值得使用 SIMD 向量来复制,因此人们通常会滥用事实上的正常行为。 - Peter Cordes
1
相关:使用32位原子实现64位原子计数器(我在C++中尝试实现SeqLock,并对其安全性进行了评论)/GCC重排序跨越带有memory_order_seq_cst的加载。这是允许的吗?(影响seqlocks的gcc错误)/将fetch_add(0,memory_order_relaxed/release)转换为mfence + mov是否合法?(“N4455没有理智的编译器会优化原子操作”SeqLock示例) - Peter Cordes
显示剩余5条评论
1个回答

2
你的Linux引用似乎不正确。
根据https://www.kernel.org/doc/html/latest/locking/seqlock.html,读取过程如下:
Read path:

do {
        seq = read_seqcount_begin(&foo_seqcount);

        /* ... [[read-side critical section]] ... */

} while (read_seqcount_retry(&foo_seqcount, seq));

如果你查看问题中发布的github链接,你会发现一个包含几乎相同过程的评论。
似乎你只关注了读取过程的一部分。链接的文件实现了你需要实现读写器的内容,但不是读写器本身。
还要注意文件顶部的这个评论:
* The seqlock seqcount_t interface does not prescribe a precise sequence of
* read begin/retry/end. For readers, typically there is a call to
* read_seqcount_begin() and read_seqcount_retry(), however, there are more
* esoteric cases which do not follow this pattern.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接