One word, one CAS, one queue: postgres’s production rwlock
lwlock.c is the latch under every buffer, WAL insert, and proc-array scan you met in topics 5–8. One u32 of state, a CAS fast path, and an intrusive wait queue — read it as the reference answer to “how do I build a fair rwlock that doesn’t melt at 128 cores”.
1. The packed state word (:49, :96–118)
u32 state:
┌─────────────┬──────────────┬──────────────────────────────┐
│ FLAG bits │ LW_VAL_EXCLUSIVE = MAX_BACKENDS+1 │
│ HAS_WAITERS │ LW_VAL_SHARED = 1 │
│ RELEASE_OK │ → shared holders are a COUNT in the low bits│
└─────────────┴──────────────────────────────────────────────┘
exclusive = add LW_VAL_EXCLUSIVE; shared = add 1.
"is it free for X?" = (state & LW_LOCK_MASK) == 0 — one load.
Same trick as postgres’s buffer state (topic 6) and Hekaton’s end_ts-as-lock (topic 8): pack refcount + flags into one atomic word so every protocol step is a single CAS. The static assert at :117 is the kind of test bit-packing demands.
2. Fast path — LWLockAttemptLock (:764)
CAS loop: load state, compute desired (:788 exclusive add, :792 free check for shared), compare-exchange, retry on spurious/contended failure. No syscall, no queue touch. THE hot path — every buffer pin in a scan goes through here.
3. Slow path — queue then sleep
LWLockQueueSelf:1018 — add me toproclist(:680 — an intrusive list of PGPROC entries, no allocation: the waiter structure lives in the proc array, same idea as intrusive skiplist nodes).-
- The wait-list itself is protected by a SPINLOCK with backoff:
- 860–880
perform_spin_delay— spin, then sleep escalation; stats countspin_delay_count(:246) so contention is observable.
- The double-check dance in
LWLockAcquire:1150: attempt → queue self → attempt AGAIN → only then sleep. Without the second attempt, a release between attempt and enqueue leaves you sleeping forever (lost wakeup).LWLockDequeueSelf:1061 handles the “won on the recheck” undo. This pattern (test, enqueue, re-test) is THE lesson of the file.
#![allow(unused)]
fn main() {
fn acquire(lock: &LwLock, mode: Mode) {
loop {
if try_cas(lock, mode) { return; } // fast path: one CAS, no queue
queue_self(lock); // slow: enqueue FIRST...
if try_cas(lock, mode) { // ...then attempt AGAIN —
dequeue_self(lock); // a release may have slipped in
return; // between attempt and enqueue
}
sleep_until_woken(); // safe now: our queue entry is
} // visible, releaser must wake us
}
}
LWLockRelease:1767 →LWLockWakeup:904: wakes the queue head; a released shared lock wakes waiting readers as a batch, and RELEASE_OK prevents wakeup storms.
4. What to steal for M9
- one-word state + CAS fast path for your HybridLatch-style version latch
- intrusive wait queues (no allocation on the slow path)
- observable contention counters from day one
Questions for notes.md
- Why must the shared count live in the SAME word as the exclusive bit? Sketch the race if they were two atomics.
- The recheck-after-enqueue: write the lost-wakeup interleaving it prevents, as a 2-thread timeline.
- LWLocks are non-recursive and panic on double-acquire in assert builds. Why is recursion banned for latches but fine for locks?
- Compare with
std::sync::RwLockon macOS (pthread rwlock): what does postgres gain by rolling its own? (Think: fairness policy, no syscall on fast path, stats, and the queue living in shared memory.)
Done when
You can draw the full acquire path — fast CAS, queue, recheck, sleep, wakeup — from memory, and name the race each step exists to close.
References
Code
- postgres
src/backend/storage/lmgr/lwlock.c— ~1.5 h; start at the state-word definitions (:49, :96–118), thenLWLockAttemptLockandLWLockAcquire