Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<WaitEntry>` keyed by `key`. The tree holds exactly one entry per held key, and that entry is the holder; each holder owns an intrusive `List<WaitEntry>` 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`.
Expand Down
86 changes: 86 additions & 0 deletions include/silk/fibers/multi-lock.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#pragma once

#include <silk/fibers/future.h>
#include <silk/util/assert.h>
#include <silk/util/list.h>
#include <silk/util/spinlock.h>
#include <silk/util/tree.h>

#include <cstdint>

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<WaitEntry, &WaitEntry::listNode> 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<WaitEntry, &WaitEntry::treeNode, Compare> tree;
};

} // namespace silk
80 changes: 80 additions & 0 deletions src/fibers/multi-lock.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#include <silk/fibers/multi-lock.h>

#include <mutex>

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
159 changes: 159 additions & 0 deletions src/fibers/tests/multi-lock-test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#include <silk/fibers/multi-lock.h>

#include <silk/fibers/fiber.h>
#include <silk/fibers/future.h>

#include <gtest/gtest.h>

#include <cstdint>

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
Loading