From 684b37b491a919cc78364725c09590f0f5dc4713 Mon Sep 17 00:00:00 2001 From: Vadim Skipin Date: Fri, 19 Jun 2026 18:03:57 +0000 Subject: [PATCH] Introduce FiberMultiLock A keyed, fiber-aware lock over a uint64_t key space: the same key gives mutual exclusion, distinct keys proceed independently, and contenders for a held key are granted it in FIFO order. Non-reentrant. Allocation-free like FiberCondVar - each acquisition's wait state lives in the caller's ScopedLock on the stack, so lock cannot fail and returns void. A SpinLock guards a Tree keyed by key; unlock promotes the front waiter and swaps it into the tree, preserving arrival order across handoffs. --- docs/sync.md | 26 +++++ include/silk/fibers/multi-lock.h | 86 +++++++++++++++ src/fibers/multi-lock.cpp | 80 ++++++++++++++ src/fibers/tests/multi-lock-test.cpp | 159 +++++++++++++++++++++++++++ 4 files changed, 351 insertions(+) create mode 100644 include/silk/fibers/multi-lock.h create mode 100644 src/fibers/multi-lock.cpp create mode 100644 src/fibers/tests/multi-lock-test.cpp diff --git a/docs/sync.md b/docs/sync.md index 2d61150..5fd8601 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -13,6 +13,7 @@ FiberFuture -- single-producer/single-consumer result handle FiberFutex -- counter-based wakeup (Linux futex pattern) FiberMutex -- mutual exclusion, unfair FiberCondVar -- condition variable (mirrors std::condition_variable) +FiberMultiLock -- keyed lock; per-key FIFO mutual exclusion FiberSequencer -- monotone counter with ordered, cancellable waiters FiberEvent -- manual-reset event (built on FiberSequencer) @@ -139,6 +140,31 @@ cv.notify_one(); // or cv.notify_all() --- +## FiberMultiLock + +Keyed lock over a `uint64_t` key space: the same key gives mutual exclusion while distinct keys proceed independently, and contenders for a held key are granted it in FIFO order. Non-reentrant. Useful when a fiber must serialize work per logical resource (a row, a partition, a shard) without standing up a lock object per resource. + +```cpp +FiberMultiLock multiLock; +{ + FiberMultiLock::ScopedLock scopedLock; + multiLock.lock(key, &scopedLock); // suspends until the key is free + /* critical section */ +} // destructor releases the key to the next waiter +``` + +`try_lock(key, &scopedLock)` is the non-suspending variant: it returns true and populates the handle when the key is free, or returns false and leaves the handle empty when the key is held. + +**No allocation** -- Each acquisition's `WaitEntry` (which inherits `FiberFuture`) lives inside the caller's `ScopedLock` on the calling fiber's stack. A holder's entry stays alive for the whole hold and a waiter's stays alive while it is parked inside `lock`, which is what lets a releaser safely wake a successor that still owns its entry on another fiber's stack. + +**State** -- a `SpinLock` protects a `Tree` keyed by `key`. The tree holds exactly one entry per held key, and that entry is the holder; each holder owns an intrusive `List` of the fibers queued behind it in arrival order. + +**lock** -- under the spinlock, look the key up in the tree. If absent, insert the caller's entry as the holder and return without suspending. If present, push the caller's entry onto the holder's waiter list, drop the spinlock, and park on the entry's own `FiberFuture`. + +**unlock** -- under the spinlock, pop the front of the holder's waiter list. A popped successor splices the remaining queue onto itself and replaces the holder in the tree; with an empty queue the holder's entry is just removed. The successor's future is set outside the spinlock, waking it to return from its own `lock` already holding the key. Because the queue moves intact across each handoff, arrival order is preserved end to end, giving per-key FIFO fairness. + +--- + ## FiberSequencer Monotone counter with fiber-aware ordered waiters. The foundation for `FiberEvent` and `FairFiberMutex`. diff --git a/include/silk/fibers/multi-lock.h b/include/silk/fibers/multi-lock.h new file mode 100644 index 0000000..4f22687 --- /dev/null +++ b/include/silk/fibers/multi-lock.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace silk +{ + +/** + * A keyed, fiber-aware lock over a uint64_t key space. The same key gives mutual exclusion, + * distinct keys proceed independently. Contenders for a held key are granted it in FIFO order. + */ +class FiberMultiLock +{ + /** One lock acquisition. The base future is set to grant the key to a waiting contender. */ + struct WaitEntry : FiberFuture + { + uint64_t key; + TreeEntry treeNode; + ListEntry listNode; + List waiters; + }; + +public: + FiberMultiLock() noexcept = default; + ~FiberMultiLock() noexcept { SILK_ASSERT(tree.empty()); } + + /** + * Scoped per-key lock handle. Default-construct one and pass it to FiberMultiLock::lock to acquire the key; + * the destructor releases the key and hands it to the next waiter. The handle must outlive the hold, + * and a default-constructed handle never passed to lock destructs as a no-op. + */ + class ScopedLock + { + public: + ScopedLock() noexcept = default; + ~ScopedLock() noexcept + { + if (multiLock) + { + multiLock->unlock(this); + } + } + + // non-copyable + ScopedLock(const ScopedLock &) = delete; + ScopedLock & operator=(const ScopedLock &) = delete; + + private: + friend class FiberMultiLock; + FiberMultiLock * multiLock = nullptr; + WaitEntry entry; + }; + + /** + * Try to acquire key without suspending. On success populates scopedLock and returns true; if the key + * is already held returns false and leaves scopedLock empty, so its destructor is a no-op. + */ + [[nodiscard]] bool try_lock(uint64_t key, ScopedLock * scopedLock) noexcept; + + /** Acquire key, suspending the calling fiber until it is free, and populate scopedLock. */ + void lock(uint64_t key, ScopedLock * scopedLock) noexcept; + +private: + struct Compare + { + bool operator()(const WaitEntry & left, const WaitEntry & right) const noexcept { return left.key < right.key; } + }; + + /** Release the lock held via scopedLock, handing it to the next queued waiter if any. */ + void unlock(ScopedLock * scopedLock) noexcept; + + // + // State. + // + + SpinLock spinLock; + Tree tree; +}; + +} // namespace silk diff --git a/src/fibers/multi-lock.cpp b/src/fibers/multi-lock.cpp new file mode 100644 index 0000000..918e78c --- /dev/null +++ b/src/fibers/multi-lock.cpp @@ -0,0 +1,80 @@ +#include + +#include + +namespace silk +{ + +bool FiberMultiLock::try_lock(uint64_t key, ScopedLock * scopedLock) noexcept +{ + WaitEntry * entry = &scopedLock->entry; + entry->key = key; + + { + std::lock_guard guard(spinLock); + + WaitEntry * prev = tree.find(entry); + if (prev) + { + return false; + } + + tree.insert(entry); + } + + scopedLock->multiLock = this; + return true; +} + +void FiberMultiLock::lock(uint64_t key, ScopedLock * scopedLock) noexcept +{ + WaitEntry * entry = &scopedLock->entry; + entry->key = key; + scopedLock->multiLock = this; + + { + std::lock_guard guard(spinLock); + + WaitEntry * prev = tree.find(entry); + if (!prev) + { + tree.insert(entry); + return; + } + + // Held: queue behind the holder and park on our own future below. + prev->waiters.push_back(entry); + } + + entry->wait(); +} + +void FiberMultiLock::unlock(ScopedLock * scopedLock) noexcept +{ + WaitEntry * entry = &scopedLock->entry; + + WaitEntry * successor; + { + std::lock_guard guard(spinLock); + + successor = entry->waiters.pop_front(); + if (successor) + { + // The promoted waiter inherits the rest of the queue and takes the holder's place in the tree. + successor->waiters.splice(&entry->waiters); + tree.remove(entry); + tree.insert(successor); + } + else + { + tree.remove(entry); + } + } + + if (successor) + { + successor->set(0); + } +} + +} // namespace silk diff --git a/src/fibers/tests/multi-lock-test.cpp b/src/fibers/tests/multi-lock-test.cpp new file mode 100644 index 0000000..d064743 --- /dev/null +++ b/src/fibers/tests/multi-lock-test.cpp @@ -0,0 +1,159 @@ +#include + +#include +#include + +#include + +#include + +namespace silk +{ + +// Holds three distinct keys at once from the calling thread. Distinct keys never contend, so each lock +// returns without parking; were they not independent the second lock would suspend forever and the test +// would hang. +TEST(FiberMultiLock, distinctKeysProceedIndependently) +{ + FiberMultiLock multiLock; + + FiberMultiLock::ScopedLock scopedLock1; + multiLock.lock(1, &scopedLock1); + + FiberMultiLock::ScopedLock scopedLock2; + multiLock.lock(2, &scopedLock2); + + FiberMultiLock::ScopedLock scopedLock3; + multiLock.lock(3, &scopedLock3); +} + +// Acquires a key, releases it via scope exit, then acquires it again. The second lock can only succeed if +// the first release handed the key back; otherwise it parks forever and the test hangs. +TEST(FiberMultiLock, sameKeyReacquiredAfterRelease) +{ + FiberMultiLock multiLock; + + { + FiberMultiLock::ScopedLock scopedLock; + multiLock.lock(7, &scopedLock); + } + + FiberMultiLock::ScopedLock scopedLock; + multiLock.lock(7, &scopedLock); +} + +// try_lock grants a free key without suspending, rejects a key already held (non-reentrant, so even the +// same caller is refused), and a rejected handle releases nothing - so the key can be taken again once +// the holder's scope exits. +TEST(FiberMultiLock, tryLock) +{ + FiberMultiLock multiLock; + + bool freeAcquired; + bool heldRejected; + { + FiberMultiLock::ScopedLock held; + freeAcquired = multiLock.try_lock(5, &held); + + FiberMultiLock::ScopedLock again; + bool secondAttempt = multiLock.try_lock(5, &again); + heldRejected = !secondAttempt; + } + + FiberMultiLock::ScopedLock reacquire; + bool reacquired = multiLock.try_lock(5, &reacquire); + + EXPECT_TRUE(freeAcquired); + EXPECT_TRUE(heldRejected); + EXPECT_TRUE(reacquired); +} + +// Each iteration takes the key, reads the shared counter, yields to force interleaving, then writes back +// the incremented value. The read-modify-write is unsynchronized, so the final count equals the total +// iteration count only if the lock serializes the critical section; a broken lock loses updates. +TEST(FiberMultiLock, sameKeySerializesContendingFibers) +{ + static constexpr uint32_t FIBER_COUNT = 8; + static constexpr uint32_t ITERATIONS = 50; + static constexpr uint64_t KEY = 42; + + struct Params + { + FiberMultiLock * multiLock; + uint64_t * counter; + + static int fiberMain(Params * params) noexcept + { + for (uint32_t i = 0; i < ITERATIONS; ++i) + { + FiberMultiLock::ScopedLock scopedLock; + params->multiLock->lock(KEY, &scopedLock); + + uint64_t value = *params->counter; + FiberScheduler::yield(); + *params->counter = value + 1; + } + return 0; + } + }; + + FiberMultiLock multiLock; + uint64_t counter = 0; + FiberFuture futures[FIBER_COUNT]; + + for (uint32_t i = 0; i < FIBER_COUNT; ++i) + { + int r = FiberScheduler::run(Params::fiberMain, {&multiLock, &counter}, &futures[i]); + ASSERT_FALSE(r); + } + + for (uint32_t i = 0; i < FIBER_COUNT; ++i) + { + futures[i].wait(); + } + + ASSERT_EQ(counter, uint64_t{FIBER_COUNT} * ITERATIONS); +} + +// Many fibers contend on distinct keys at once: every lock should grant immediately and every fiber should +// run to completion. Guards against a key collision wrongly serializing them. +TEST(FiberMultiLock, distinctKeysRunConcurrently) +{ + static constexpr uint32_t FIBER_COUNT = 8; + static constexpr uint32_t ITERATIONS = 50; + + struct Params + { + FiberMultiLock * multiLock; + uint64_t key; + + static int fiberMain(Params * params) noexcept + { + for (uint32_t i = 0; i < ITERATIONS; ++i) + { + FiberMultiLock::ScopedLock scopedLock; + params->multiLock->lock(params->key, &scopedLock); + + FiberScheduler::yield(); + } + return 0; + } + }; + + FiberMultiLock multiLock; + FiberFuture futures[FIBER_COUNT]; + + for (uint32_t i = 0; i < FIBER_COUNT; ++i) + { + int r = FiberScheduler::run(Params::fiberMain, {&multiLock, i}, &futures[i]); + ASSERT_FALSE(r); + } + + for (uint32_t i = 0; i < FIBER_COUNT; ++i) + { + int result = futures[i].wait(); + ASSERT_EQ(result, 0); + } +} + +} // namespace silk