Skip to main content

subetha_cxc/
shared_rw_lock.rs

1//! `SharedRWLock` - cross-process reader-writer lock with writer
2//! priority.
3//!
4//! Multiple concurrent readers OR exactly one writer. When a writer
5//! is waiting, new readers block to prevent writer starvation.
6//!
7//! # State encoding
8//!
9//! ONE AtomicU64 packed:
10//! - bit 63: writer active (1 if a writer holds the lock)
11//! - bits 32-62: writers waiting count (31 bits)
12//! - bits 0-31: reader count (32 bits)
13//!
14//! All transitions are single CAS so observers never see torn state.
15
16use std::fs::{File, OpenOptions};
17use std::mem::size_of;
18use std::path::Path;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21use memmap2::{MmapMut, MmapOptions};
22
23pub const RWLOCK_MAGIC: u64 = 0x4150_5257_4C4F_434B;
24
25/// How long a caller that lost the `create_new` election waits for the winner
26/// to publish the magic before giving up. Bounded so a creator that dies
27/// mid-initialisation surfaces as an error rather than an unbounded spin.
28const CREATE_RACE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
29
30const WRITER_BIT: u64 = 1u64 << 63;
31const WAITING_SHIFT: u64 = 32;
32const WAITING_MASK: u64 = 0x7FFF_FFFF << WAITING_SHIFT;
33const READERS_MASK: u64 = 0xFFFF_FFFF;
34
35#[repr(C, align(64))]
36pub struct RWLockHeader {
37    pub magic: u64,
38    pub state: AtomicU64,
39    _pad: [u8; 48],
40}
41
42const _: () = {
43    assert!(size_of::<RWLockHeader>() == 64);
44};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum RWLockError {
48    WouldBlock,
49    LayoutMismatch,
50    IoError(std::io::ErrorKind),
51}
52
53impl From<std::io::Error> for RWLockError {
54    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
55}
56
57pub struct SharedRWLock {
58    _file: File,
59    mmap: MmapMut,
60    header_sidecar: subetha_core::HandshakeHeader,
61    ring_sidecar: Box<subetha_core::ObservationRing>,
62}
63
64unsafe impl Send for SharedRWLock {}
65unsafe impl Sync for SharedRWLock {}
66
67impl subetha_sidecar::AdaptiveInstance for SharedRWLock {
68    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
69    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
70    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
71        Box::new(subetha_sidecar::NoMigrationPolicy)
72    }
73}
74
75impl SharedRWLock {
76    /// Obtain the lock at `path`, initializing it if it does not yet exist and
77    /// attaching to it if it does.
78    ///
79    /// Attaching rather than truncating is what makes this safe to call from
80    /// several processes at once: a truncating create run against a live lock
81    /// clears a writer flag another holder owns and mutual exclusion is lost
82    /// with nothing raised. Use [`reset`](Self::reset) to deliberately
83    /// reinitialise a lock, which is the only case truncation was ever right
84    /// for.
85    pub fn create(path: impl AsRef<Path>) -> Result<Self, RWLockError> {
86        Self::create_or_open(path)
87    }
88
89    /// Reinitialise the lock at `path`, discarding any state a live holder
90    /// owns: the header is truncated and zeroed. For a caller that knows it
91    /// owns the path and wants a clean instance.
92    pub fn reset(path: impl AsRef<Path>) -> Result<Self, RWLockError> {
93        let total = size_of::<RWLockHeader>();
94        let file = OpenOptions::new()
95            .read(true).write(true).create(true).truncate(true)
96            .open(path.as_ref())?;
97        file.set_len(total as u64)?;
98        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
99        let hdr = mmap.as_mut_ptr() as *mut RWLockHeader;
100        unsafe {
101            std::ptr::write_bytes(hdr as *mut u8, 0, total);
102            (*hdr).magic = RWLOCK_MAGIC;
103        }
104        Ok(Self {
105            _file: file, mmap,
106            header_sidecar: subetha_core::HandshakeHeader::new(),
107            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
108        })
109    }
110
111    /// Open the lock at `path`, creating it if it does not exist, without a
112    /// window in which two callers can both create it.
113    ///
114    /// [`create`](Self::create) truncates and zeroes the header, so a second
115    /// caller running it against a live lock clears a writer flag another
116    /// holder owns and mutual exclusion is silently lost. An exists-then-create
117    /// check does not close that: the check and the create are separate steps.
118    /// Here exactly one caller wins an exclusive `create_new` and initialises;
119    /// the rest open and wait for the magic to appear.
120    ///
121    /// Use this for any lock a peer may reach first. [`create`](Self::create)
122    /// stays the right call only when the caller knows it owns the path.
123    pub fn create_or_open(path: impl AsRef<Path>) -> Result<Self, RWLockError> {
124        let total = size_of::<RWLockHeader>();
125        match OpenOptions::new()
126            .read(true)
127            .write(true)
128            .create_new(true)
129            .open(path.as_ref())
130        {
131            Ok(file) => {
132                file.set_len(total as u64)?;
133                let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
134                let hdr = mmap.as_mut_ptr() as *mut RWLockHeader;
135                unsafe {
136                    std::ptr::write_bytes(hdr as *mut u8, 0, total);
137                    // Published last: a peer that opened the file early spins
138                    // on this, so it must not be visible before the zeroing.
139                    (*hdr).magic = RWLOCK_MAGIC;
140                }
141                Ok(Self {
142                    _file: file,
143                    mmap,
144                    header_sidecar: subetha_core::HandshakeHeader::new(),
145                    ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
146                })
147            }
148            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
149                // The winner may not have written the magic yet, and on
150                // Windows the file can be observed at zero length first.
151                let deadline = std::time::Instant::now() + CREATE_RACE_TIMEOUT;
152                loop {
153                    match Self::open(path.as_ref()) {
154                        Ok(l) => return Ok(l),
155                        Err(RWLockError::LayoutMismatch) | Err(RWLockError::IoError(_))
156                            if std::time::Instant::now() < deadline =>
157                        {
158                            std::thread::yield_now();
159                        }
160                        Err(other) => return Err(other),
161                    }
162                }
163            }
164            Err(e) => Err(e.into()),
165        }
166    }
167
168    pub fn open(path: impl AsRef<Path>) -> Result<Self, RWLockError> {
169        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
170        if file.metadata()?.len() < size_of::<RWLockHeader>() as u64 {
171            return Err(RWLockError::LayoutMismatch);
172        }
173        let mmap = unsafe {
174            MmapOptions::new().len(size_of::<RWLockHeader>()).map_mut(&file)?
175        };
176        let hdr = unsafe { &*(mmap.as_ptr() as *const RWLockHeader) };
177        if hdr.magic != RWLOCK_MAGIC {
178            return Err(RWLockError::LayoutMismatch);
179        }
180        Ok(Self {
181            _file: file, mmap,
182            header_sidecar: subetha_core::HandshakeHeader::new(),
183            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
184        })
185    }
186
187    fn state(&self) -> &AtomicU64 {
188        unsafe { &(*(self.mmap.as_ptr() as *const RWLockHeader)).state }
189    }
190
191    /// Try to acquire a read lock without blocking.
192    pub fn try_read_lock(&self) -> Result<ReadGuard<'_>, RWLockError> {
193        let r = self.try_read_lock_inner();
194        self.ring_sidecar.push_op(
195            crate::sidecar_ops::rw_lock::OP_TRY_READ,
196            if r.is_err() { 1 } else { 0 },
197        );
198        r
199    }
200
201    fn try_read_lock_inner(&self) -> Result<ReadGuard<'_>, RWLockError> {
202        loop {
203            let s = self.state().load(Ordering::Acquire);
204            let writer_active = (s & WRITER_BIT) != 0;
205            let writers_waiting = (s & WAITING_MASK) >> WAITING_SHIFT;
206            if writer_active || writers_waiting > 0 {
207                return Err(RWLockError::WouldBlock);
208            }
209            let readers = s & READERS_MASK;
210            let new = (s & !READERS_MASK) | (readers + 1);
211            if self.state().compare_exchange(
212                s, new, Ordering::AcqRel, Ordering::Acquire,
213            ).is_ok() {
214                return Ok(ReadGuard { lock: self });
215            }
216        }
217    }
218
219    /// Acquire a read lock, blocking with backoff until available.
220    /// Writer-priority: blocks if any writer is active OR waiting.
221    pub fn read_lock(&self) -> ReadGuard<'_> {
222        let mut spins = 0u32;
223        loop {
224            if let Ok(g) = self.try_read_lock_inner() {
225                self.ring_sidecar.push_op(
226                    crate::sidecar_ops::rw_lock::OP_READ,
227                    if spins > 0 { 1 } else { 0 }, // contention
228                );
229                return g;
230            }
231            spins += 1;
232            if spins < 32 {
233                std::hint::spin_loop();
234            } else if spins < 256 {
235                std::thread::yield_now();
236            } else {
237                std::thread::sleep(std::time::Duration::from_micros(50));
238            }
239        }
240    }
241
242    /// Try to acquire a write lock without blocking.
243    pub fn try_write_lock(&self) -> Result<WriteGuard<'_>, RWLockError> {
244        let r = self.try_write_lock_inner();
245        self.ring_sidecar.push_op(
246            crate::sidecar_ops::rw_lock::OP_TRY_WRITE,
247            if r.is_err() { 1 } else { 0 },
248        );
249        r
250    }
251
252    fn try_write_lock_inner(&self) -> Result<WriteGuard<'_>, RWLockError> {
253        loop {
254            let s = self.state().load(Ordering::Acquire);
255            let writer_active = (s & WRITER_BIT) != 0;
256            let readers = s & READERS_MASK;
257            if writer_active || readers > 0 {
258                return Err(RWLockError::WouldBlock);
259            }
260            let new = (s & !WRITER_BIT) | WRITER_BIT;
261            if self.state().compare_exchange(
262                s, new, Ordering::AcqRel, Ordering::Acquire,
263            ).is_ok() {
264                return Ok(WriteGuard { lock: self });
265            }
266        }
267    }
268
269    /// Acquire a write lock, blocking until available. Registers
270    /// as "waiting" so new readers will block.
271    pub fn write_lock(&self) -> WriteGuard<'_> {
272        // Register as waiting.
273        self.state().fetch_add(1u64 << WAITING_SHIFT, Ordering::AcqRel);
274        let mut spins = 0u32;
275        loop {
276            let s = self.state().load(Ordering::Acquire);
277            let writer_active = (s & WRITER_BIT) != 0;
278            let readers = s & READERS_MASK;
279            if !writer_active && readers == 0 {
280                // Try to claim: set writer bit + decrement waiting.
281                let new = (s & READERS_MASK) | WRITER_BIT
282                    | ((((s & WAITING_MASK) >> WAITING_SHIFT) - 1) << WAITING_SHIFT);
283                if self.state().compare_exchange(
284                    s, new, Ordering::AcqRel, Ordering::Acquire,
285                ).is_ok() {
286                    self.ring_sidecar.push_op(
287                        crate::sidecar_ops::rw_lock::OP_WRITE,
288                        if spins > 0 { 1 } else { 0 }, // contention
289                    );
290                    return WriteGuard { lock: self };
291                }
292            }
293            spins += 1;
294            if spins < 32 {
295                std::hint::spin_loop();
296            } else if spins < 256 {
297                std::thread::yield_now();
298            } else {
299                std::thread::sleep(std::time::Duration::from_micros(50));
300            }
301        }
302    }
303
304    /// Number of active readers (observational; may race).
305    pub fn reader_count(&self) -> u32 {
306        (self.state().load(Ordering::Acquire) & READERS_MASK) as u32
307    }
308
309    /// True if a writer currently holds the lock.
310    pub fn has_writer(&self) -> bool {
311        (self.state().load(Ordering::Acquire) & WRITER_BIT) != 0
312    }
313
314    /// Number of writers currently waiting for the lock.
315    pub fn waiting_writers(&self) -> u32 {
316        ((self.state().load(Ordering::Acquire) & WAITING_MASK) >> WAITING_SHIFT) as u32
317    }
318
319    /// Release one reader. Internal; called by ReadGuard::drop.
320    /// Defensive: checks that the reader count is positive before
321    /// decrementing. In debug builds this panics on protocol
322    /// violation (release without acquire); in release builds it
323    /// silently no-ops to avoid underflow corruption.
324    fn release_read(&self) {
325        loop {
326            let s = self.state().load(Ordering::Acquire);
327            let readers = s & READERS_MASK;
328            debug_assert!(
329                readers > 0,
330                "SharedRWLock::release_read called when reader count is 0 - \
331                 indicates a protocol violation (double-release or release \
332                 without acquire). The lock counter will not be decremented.",
333            );
334            if readers == 0 { return; }
335            let new = (s & !READERS_MASK) | (readers - 1);
336            if self.state().compare_exchange(
337                s, new, Ordering::AcqRel, Ordering::Acquire,
338            ).is_ok() {
339                return;
340            }
341        }
342    }
343
344    /// Release the writer. Internal; called by WriteGuard::drop.
345    /// Defensive: checks that a writer is actually active before
346    /// clearing. In debug builds this panics on protocol violation.
347    fn release_write(&self) {
348        let prev = self.state().fetch_and(!WRITER_BIT, Ordering::AcqRel);
349        debug_assert!(
350            (prev & WRITER_BIT) != 0,
351            "SharedRWLock::release_write called when no writer holds the lock - \
352             indicates a protocol violation (double-release or release without \
353             acquire).",
354        );
355    }
356
357    /// Public hook for the `BlockingRWLock` wrapper to mirror the
358    /// inner `ReadGuard::drop` semantics after the wrapper's own
359    /// guard runs (the wrapper `mem::forget`s the inner guard so it
360    /// can interleave a wake call between the state release and the
361    /// guard's destructor).
362    pub fn release_read_for_blocking(&self) { self.release_read(); }
363
364    /// Public hook for the `BlockingRWLock` wrapper; mirror of
365    /// `WriteGuard::drop`.
366    pub fn release_write_for_blocking(&self) { self.release_write(); }
367
368    pub fn flush(&self) -> Result<(), RWLockError> {
369        self.mmap.flush()?;
370        Ok(())
371    }
372    pub fn flush_async(&self) -> Result<(), RWLockError> {
373        self.mmap.flush_async()?;
374        Ok(())
375    }
376}
377
378pub struct ReadGuard<'a> { lock: &'a SharedRWLock }
379impl Drop for ReadGuard<'_> {
380    fn drop(&mut self) { self.lock.release_read(); }
381}
382
383pub struct WriteGuard<'a> { lock: &'a SharedRWLock }
384impl Drop for WriteGuard<'_> {
385    fn drop(&mut self) { self.lock.release_write(); }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::sync::atomic::{AtomicU32, Ordering as O};
392    use std::sync::Arc;
393    use std::thread;
394
395    fn tmp(name: &str) -> std::path::PathBuf {
396        let mut p = std::env::temp_dir();
397        let pid = std::process::id();
398        p.push(format!("subetha-rwlock-{name}-{pid}.bin"));
399        p
400    }
401
402    /// Racing callers on one path must all reach the same lock, and a caller
403    /// arriving while another holds the write lock must not clear it.
404    ///
405    /// `create` truncates and zeroes the header, so a second caller running it
406    /// against a live lock drops a held writer flag and mutual exclusion is
407    /// gone with no error anywhere. `create_or_open` elects one creator.
408    #[test]
409    fn create_or_open_racing_callers_do_not_clear_a_held_writer() {
410        let p = tmp("race");
411        std::fs::remove_file(&p).ok();
412
413        let holder = SharedRWLock::create_or_open(&p).unwrap();
414        let guard = holder.try_write_lock().expect("uncontended write lock");
415        assert!(holder.has_writer());
416
417        // Eight peers arrive on the same path while the write lock is held.
418        let path = Arc::new(p.clone());
419        let cleared = Arc::new(AtomicU32::new(0));
420        let mut hs = Vec::new();
421        for _ in 0..8 {
422            let path = Arc::clone(&path);
423            let cleared = Arc::clone(&cleared);
424            hs.push(thread::spawn(move || {
425                let l = SharedRWLock::create_or_open(&*path).expect("open existing");
426                if !l.has_writer() {
427                    cleared.fetch_add(1, O::Relaxed);
428                }
429            }));
430        }
431        for h in hs {
432            h.join().unwrap();
433        }
434
435        assert_eq!(
436            cleared.load(O::Relaxed),
437            0,
438            "a concurrent create_or_open zeroed a writer flag another holder owned",
439        );
440        assert!(holder.has_writer(), "the holder lost its own write lock");
441        drop(guard);
442        std::fs::remove_file(&p).ok();
443    }
444
445    #[test]
446    fn create_initial_state_is_idle() {
447        let p = tmp("init");
448        let l = SharedRWLock::create(&p).unwrap();
449        assert_eq!(l.reader_count(), 0);
450        assert!(!l.has_writer());
451        assert_eq!(l.waiting_writers(), 0);
452        std::fs::remove_file(&p).ok();
453    }
454
455    #[test]
456    fn try_read_succeeds_when_idle() {
457        let p = tmp("try-read");
458        let l = SharedRWLock::create(&p).unwrap();
459        let g = l.try_read_lock().unwrap();
460        assert_eq!(l.reader_count(), 1);
461        drop(g);
462        assert_eq!(l.reader_count(), 0);
463        std::fs::remove_file(&p).ok();
464    }
465
466    #[test]
467    fn try_write_succeeds_when_idle() {
468        let p = tmp("try-write");
469        let l = SharedRWLock::create(&p).unwrap();
470        let g = l.try_write_lock().unwrap();
471        assert!(l.has_writer());
472        drop(g);
473        assert!(!l.has_writer());
474        std::fs::remove_file(&p).ok();
475    }
476
477    #[test]
478    fn multiple_readers_coexist() {
479        let p = tmp("multi-read");
480        let l = SharedRWLock::create(&p).unwrap();
481        let g1 = l.try_read_lock().unwrap();
482        let g2 = l.try_read_lock().unwrap();
483        let g3 = l.try_read_lock().unwrap();
484        assert_eq!(l.reader_count(), 3);
485        drop(g1); drop(g2); drop(g3);
486        assert_eq!(l.reader_count(), 0);
487        std::fs::remove_file(&p).ok();
488    }
489
490    #[test]
491    fn writer_excludes_readers() {
492        let p = tmp("w-excl-r");
493        let l = SharedRWLock::create(&p).unwrap();
494        let _w = l.try_write_lock().unwrap();
495        assert_eq!(l.try_read_lock().err(), Some(RWLockError::WouldBlock));
496        std::fs::remove_file(&p).ok();
497    }
498
499    #[test]
500    fn reader_excludes_writer() {
501        let p = tmp("r-excl-w");
502        let l = SharedRWLock::create(&p).unwrap();
503        let _r = l.try_read_lock().unwrap();
504        assert_eq!(l.try_write_lock().err(), Some(RWLockError::WouldBlock));
505        std::fs::remove_file(&p).ok();
506    }
507
508    #[test]
509    fn writer_excludes_writer() {
510        let p = tmp("w-excl-w");
511        let l = SharedRWLock::create(&p).unwrap();
512        let _w = l.try_write_lock().unwrap();
513        assert_eq!(l.try_write_lock().err(), Some(RWLockError::WouldBlock));
514        std::fs::remove_file(&p).ok();
515    }
516
517    #[test]
518    fn write_lock_blocks_until_readers_drop() {
519        // Clean pattern: spawn a reader thread that holds its guard
520        // for a known duration. Main thread spawns a writer that
521        // must block until the reader's guard drops. No unsafe
522        // ptr::read; the reader's guard lifetime is tied to its
523        // thread's scope.
524        let p = tmp("w-blocks");
525        let l = Arc::new(SharedRWLock::create(&p).unwrap());
526        let l_reader = l.clone();
527        let reader_done = Arc::new(AtomicU32::new(0));
528        let reader_done_clone = reader_done.clone();
529        let reader = thread::spawn(move || {
530            let _g = l_reader.read_lock();
531            std::thread::sleep(std::time::Duration::from_millis(30));
532            reader_done_clone.store(1, O::Release);
533            // Guard drops here, releasing the lock.
534        });
535        // Wait (bounded) for the reader thread to acquire; a fixed
536        // sleep races the scheduler under full-suite load.
537        let acquire_deadline = std::time::Instant::now()
538            + std::time::Duration::from_secs(5);
539        while l.reader_count() != 1
540            && std::time::Instant::now() < acquire_deadline
541        {
542            std::thread::yield_now();
543        }
544        assert_eq!(l.reader_count(), 1);
545
546        let l_writer = l.clone();
547        let writer_started = std::time::Instant::now();
548        let writer = thread::spawn(move || {
549            let _g = l_writer.write_lock();
550            writer_started.elapsed()
551        });
552
553        let elapsed = writer.join().unwrap();
554        reader.join().unwrap();
555        // Writer should have blocked at least until reader finished
556        // (which is ~30ms - 5ms from when writer was spawned = ~25ms).
557        assert!(
558            elapsed >= std::time::Duration::from_millis(15),
559            "writer should have blocked for the reader's hold time, got {elapsed:?}",
560        );
561        assert_eq!(reader_done.load(O::Acquire), 1);
562        std::fs::remove_file(&p).ok();
563    }
564
565    #[test]
566    fn writer_priority_blocks_new_readers() {
567        // When a writer is waiting, new try_read should fail
568        // (writer priority).
569        let p = tmp("w-priority");
570        let l = SharedRWLock::create(&p).unwrap();
571        // Simulate a waiting writer by bumping the waiting count
572        // directly (real writers do this in write_lock).
573        l.state().fetch_add(1u64 << WAITING_SHIFT, Ordering::AcqRel);
574        assert_eq!(l.try_read_lock().err(), Some(RWLockError::WouldBlock));
575        // Clean up the bumped count for the file teardown.
576        l.state().fetch_sub(1u64 << WAITING_SHIFT, Ordering::AcqRel);
577        std::fs::remove_file(&p).ok();
578    }
579
580    #[test]
581    fn concurrent_readers_all_succeed() {
582        let p = tmp("concurrent-r");
583        let l = Arc::new(SharedRWLock::create(&p).unwrap());
584        let n = 8;
585        let count = Arc::new(AtomicU32::new(0));
586        let mut handles = vec![];
587        for _ in 0..n {
588            let l = l.clone();
589            let count = count.clone();
590            handles.push(thread::spawn(move || {
591                let _g = l.read_lock();
592                count.fetch_add(1, O::AcqRel);
593                std::thread::sleep(std::time::Duration::from_millis(5));
594            }));
595        }
596        for h in handles { h.join().unwrap(); }
597        assert_eq!(count.load(O::Acquire), n);
598        std::fs::remove_file(&p).ok();
599    }
600
601    #[test]
602    fn cross_handle_visibility() {
603        let p = tmp("cross-handle");
604        let w = SharedRWLock::create(&p).unwrap();
605        let r = SharedRWLock::open(&p).unwrap();
606        let _g = w.try_read_lock().unwrap();
607        // Reader handle sees the same state.
608        assert_eq!(r.reader_count(), 1);
609        assert_eq!(r.try_write_lock().err(), Some(RWLockError::WouldBlock));
610        std::fs::remove_file(&p).ok();
611    }
612
613    #[test]
614    fn writer_then_reader_serialized() {
615        let p = tmp("w-then-r");
616        let l = SharedRWLock::create(&p).unwrap();
617        {
618            let _w = l.try_write_lock().unwrap();
619        }
620        // After writer drops, reader can acquire.
621        let _r = l.try_read_lock().unwrap();
622        std::fs::remove_file(&p).ok();
623    }
624}