RwLock
RwLock admits any number of shared holders, or exactly one exclusive holder, never both at once. It is the primitive for state that many threads read and few threads change: readers proceed together instead of queueing behind each other.
A shared acquire is a read-modify-write on one word that every reader shares. Readers do not exclude each other, but they do contend on that word, so the lock earns its place only where they hold it long enough to overlap.
Two modes, two guards
Section titled “Two modes, two guards”struct Palette{ RwLock lock; Color entries[256];};
Color Sample(Palette& palette, const u8 index){ RwLockSharedScope guard(palette.lock); // other readers may be inside too return palette.entries[index];}
void Reload(Palette& palette, const Span<const Color> colors){ RwLockExclusiveScope guard(palette.lock); // nobody else is inside CopyColors(palette.entries, colors);}The mode is a property of the guard type, not an argument, so releasing a shared hold as an exclusive one is not expressible. Both guards are [[nodiscard]] for the same reason MutexScope is: written as a bare RwLockSharedScope{palette.lock}; the guard is a temporary that dies at the semicolon and leaves the body unprotected. Name it.
The blocking and non-blocking operations exist under the guards — LockShared, TryLockShared, UnlockShared, and the three exclusive equivalents — for the cases a scope cannot express. Each release must match its acquire, on the same thread that took it.
Like the other primitives, an RwLock needs no initialisation and no destruction, and is neither copyable nor movable.
Neither mode is recursive
Section titled “Neither mode is recursive”Taking the lock twice from one thread is a bug in both modes, and the shared case is the surprising one.
An exclusive re-acquire deadlocks the same way a Mutex does.
TryLockShared can fail on a lock nobody holds
Section titled “TryLockShared can fail on a lock nobody holds”false from either Try* means not acquired now. It does not mean an incompatible holder exists.
The Linux implementation prefers writers: while any thread is waiting, a new reader is refused, even though the lock is unheld at that instant. Windows promises nothing either way. Code that reads false as “a writer is inside” will be wrong on both platforms.
There is no upgrade and no downgrade
Section titled “There is no upgrade and no downgrade”A shared holder cannot become an exclusive holder, and an exclusive holder cannot become a shared one. Windows offers neither operation, so neither is offered here.
What you do instead is release and re-acquire. Between the two scopes the lock is held by nobody, and every other thread is free to run:
{ RwLockSharedScope guard(palette.lock); if (!IsStale(palette)) // decided under the shared lock { return; }} // the lock is not held here — anything may happen
{ RwLockExclusiveScope guard(palette.lock); if (IsStale(palette)) // re-decided: another thread may have refreshed it { RefreshEntries(palette); // takes no lock of its own; this scope holds it }}No fairness, in either direction
Section titled “No fairness, in either direction”Neither platform gives an ordering guarantee. Microsoft’s documentation states that SRW locks are “neither fair nor FIFO”; the Linux implementation is writer-preferring by construction.
The concrete consequence is asymmetric: on Windows a saturating stream of readers can starve a writer indefinitely, and on Linux it cannot. A workload with a bound on how long a writer may wait needs a different primitive; this one does not provide it on both platforms.
The Linux implementation also caps concurrent shared holders, at 1 073 741 822, where Windows documents no cap. The reader count is bounded on one platform and not on the other, so it is not a portable resource to build on.
When a mutex wins
Section titled “When a mutex wins”Three cases send you back to a plain mutex.
Short critical sections. A shared acquire and its release are both read-modify-writes on the reader count, so two threads reading different parts of the protected state still write the same word. When the section itself is one field read, that word is the only thing the threads really share.
Writer-heavy state. The lock buys concurrency among readers and nothing else. When writes are frequent, readers rarely overlap, and the shared mode you paid for goes unused — on Windows, an exclusive acquire is the same operation Mutex::Lock performs.
Many cores reading at once. Each shared acquire writes the same cache line, so readers on different cores contend on that line even though they never conflict logically. Sharding the state, or giving each thread its own copy, removes that contention where the lock cannot.
| Situation | Use |
|---|---|
| One word | Atomics |
| A short critical section, or a writer-heavy one | Mutex |
| A long read section, with genuinely overlapping readers | RwLock |
| A thread that must wait for the state to become something | ConditionVariable with a Mutex |
| State that can be built per thread and merged once | An arena per thread — see Choosing a primitive |
API summary
Section titled “API summary”Pairing rules, the undefined-behaviour cases and the ordering each operation establishes are documented in basalt/core/concurrency/RwLock.h.
| Signature | Purpose |
|---|---|
RwLock() |
An unlocked lock. Cannot fail, allocates nothing. |
LockShared() |
Acquires in shared mode, blocking. |
TryLockShared() |
Acquires in shared mode if it can. Returns whether it did. Never blocks. |
UnlockShared() |
Releases a shared hold, on the thread that took it. |
LockExclusive() |
Acquires in exclusive mode, blocking. |
TryLockExclusive() |
Acquires in exclusive mode if it can. Returns whether it did. Never blocks. |
UnlockExclusive() |
Releases an exclusive hold, on the thread that took it. |
RwLockSharedScope(lock) |
Holds the lock in shared mode for the scope. Name the guard. |
RwLockExclusiveScope(lock) |
Holds the lock in exclusive mode for the scope. Name the guard. |