#pragma once // seqlock.h — the lock-free publication every live block rides from its off-thread writer to // the audio thread: a single-writer seqlock over one plain value. Generic over that value so the // live-parameter block and the drawn-contour block share one implementation of the protocol. #include #include #include namespace reasampler::instrument::engine { // Single-writer seqlock. The writer publishes a whole block between an odd and an even // generation; a reader copies the block and re-checks the generation, retrying a bounded number // of times, so it can never act on a half-applied edit. Wait-free for the reader: after the // retry budget it reports "nothing new" and the caller keeps its last good snapshot rather than // spinning on the audio thread. Reading does not mutate, so any number of readers may share one // block (the live and the draining engine both do). // // SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the // generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the // block, and a reader then accepts a torn block as coherent. Every publisher must serialize. // // The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock: // the fences give correct ordering, but the concurrent read of a non-atomic object is a data // race under the C++ object model, so TSan/UBSan will report it. That report is expected, not // a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain // value the audio thread can copy in one shot. // // The writer interface deliberately assumes NO particular thread beyond single-writer, so a // host's own parameter-change queue (delivered on the audio thread with sample offsets) can // drive it later without a redesign. template class Seqlock { static_assert(std::is_trivially_copyable_v, "the block is copied under a seqlock — it must stay a plain value"); public: // A generation of 0 means "never published"; the first publish lands on 2. void publish(const T& values) { const std::uint32_t gen = seq_.load(std::memory_order_relaxed); seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress std::atomic_thread_fence(std::memory_order_release); values_ = values; std::atomic_thread_fence(std::memory_order_release); // Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and // stall every reader until the NEXT publish — a silent mode, unlike a loud one. const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2; seq_.store(next, std::memory_order_release); // even: complete and coherent } // The last generation published, without copying the block — one relaxed load, so a reader // that only needs "has anything moved" pays nothing for asking on a block where nothing has. std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); } // Copies the block into `out` and returns the generation actually observed, or 0 when // nothing has been published yet or the retry budget ran out (in which case `out` may hold // a torn copy and MUST be discarded — compare the return against 0 before using it). std::uint32_t read(T& out, int maxAttempts = 4) const { for (int attempt = 0; attempt < maxAttempts; ++attempt) { const std::uint32_t before = seq_.load(std::memory_order_acquire); if (before == 0) return 0; // never published if ((before & 1u) != 0u) continue; // writer mid-update std::atomic_thread_fence(std::memory_order_acquire); out = values_; std::atomic_thread_fence(std::memory_order_acquire); if (seq_.load(std::memory_order_relaxed) == before) return before; } return 0; } private: std::atomic seq_{0}; T values_{}; }; } // namespace reasampler::instrument::engine