Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Resync Guidebook

Welcome to the guide for Resync. This document covers the library’s philosophy, core concepts, advanced usage patterns, design decisions, and inherent limitations.

Resync is not just another mutex crate. It is a LEGO-like toolkit for synchronization. Whether you are building a high-throughput user-space server, a bare-metal embedded kernel, or a complex distributed system, Resync gives you the raw materials to build exactly the synchronization primitive you need, while enforcing safe, race-free API boundaries.

In this book, you will learn how to decouple acquisition from waiting, how to prevent deadlocks at compile time, and how to leverage Resync’s impressive suite of built-in batteries like Gate, Semaphore, and Shield.

1. Philosophy: The “LEGO” Approach

Unlike standard library synchronization primitives that provide a monolithic Mutex or RwLock, Resync treats synchronization as a composition of smaller, independent behaviors.

A blocking mutex is essentially two behaviors combined:

  1. Acquisition: How do we atomically claim ownership of a resource?
  2. Waiting: What do we do while the resource is held by someone else?

Resync decouples these concerns into the LockPolicy and RetryPolicy traits. This allows you to mix and match atomic acquisition strategies with different spin-wait strategies at compile time, tailoring the primitive exactly to your performance and environment constraints.

2. Core Traits

LockPolicy: The Acquisition Strategy

The LockPolicy trait defines the raw mechanics of claiming a resource. It is intentionally minimal and strictly atomic. It includes an associated Meta type, allowing implementations to pass state (like a ticket number or guard token) from the acquisition step (try_lock) to the release step (free).

RetryPolicy: The Waiting Strategy

The RetryPolicy trait defines what the CPU should do when a LockPolicy reports contention. This could be a tight CPU pause loop (retry::Busy), yielding to the OS scheduler (retry::Yield), or even a custom exponential backoff strategy.

SharingPolicy: The Read-Write Semantics

Extends LockPolicy to support shared (reader) access. This allows a single lock primitive to support both reader and writer access, forming the basis for the Sharex (RwLock) primitive.

NewLocked: The Initialization Strategy

Allows locks to be created in an already-acquired (locked) state. This is crucial for primitives like Gate that must start closed to prevent Time-of-Check to Time-of-Use (TOCTOU) races. By segregating this into a separate trait, Resync follows the Interface Segregation Principle.

Mutex & Sharex: The Composition

These structs bind a LockPolicy, a RetryPolicy, and the protected data (T) together, providing a safe, RAII-based interface (ExGuard and ShGuard). They also manage the Lock Poisoning state, automatically detecting thread panics (when std is enabled) to protect data integrity.

3. Batteries Included

While the core philosophy of Resync is modularity, it doesn’t mean you have to build everything from scratch. Resync ships with an impressive, production-ready arsenal of synchronization primitives and backend policies out of the box.

High-Level Primitives

  • Mutex<T, L, R>: The standard mutual exclusion lock. Protects data and supports lock poisoning.
  • Sharex<T, L, R>: A read-write lock (RwLock) allowing multiple concurrent readers or a single exclusive writer.
  • Gate<L, R>: A controllable barrier. Starts closed (via NewLocked) and blocks threads until explicitly opened. Perfect for thread pool initialization.
  • Semaphore<L, R>: A counting semaphore for limiting concurrent access to a pool of resources (e.g., DB connections).
  • Condvar: A condition variable for event-based waiting, fully respecting the poisoning semantics of the associated Mutex.
  • Barrier<R>: A synchronization primitive that blocks a set of threads until all of them have reached a certain point.

Lock Backends (LockPolicy & SharingPolicy)

  • Atomic: A pure, portable spinlock based on AtomicUsize. Ideal for #![no_std] and extremely short critical sections.
  • Os: OS-specific high-performance locks. Uses futex on Linux, pthread_rwlock_t on macOS, and SRWLOCK on Windows. Automatically parks threads in the kernel on contention.
  • Fs: A filesystem-based advisory lock using flock(2). Useful for cross-process synchronization.
  • Irq: A bare-metal lock that disables hardware interrupts (IRQs) upon acquisition. Essential for kernel development to prevent interrupt-handler deadlocks.
  • Nested<L1, L2>: A composite lock that strictly enforces acquisition order (L1 then L2) and reverse release order, preventing deadlocks at compile time.
  • Shield<L>: A wrapper that prevents writer starvation in read-write locks by yielding readers (shared accessors yield) the resource from new readers when a writer is waiting.

Retry Backends (RetryPolicy)

  • Busy: Executes architecture-specific CPU pause instructions (core::hint::spin_loop()).
  • Yield: Cooperatively yields the current thread to the OS scheduler (std::thread::yield_now()).

4. Comparisons with the Ecosystem

How does Resync stack up against the standard library and popular third-party crates?

std::sync vs resync

The standard library provides Mutex and RwLock, but they are monolithic. You cannot change how they wait (they always yield/park via the OS), and they are strictly tied to std. Furthermore, std::sync::Mutex::lock() returns a PoisonError, which conflates poisoning with other potential lock failures. Resync separates these concerns using AcquireError, distinguishing between Poisoned (data inconsistency), Lock (fatal OS/hardware error), and Retry (timeout).

parking_lot vs resync

parking_lot is incredibly fast and widely used. However, it hardcodes its OS-level parking mechanism. If you are writing a hybrid application where 99% of locks should park in the OS, but 1% of locks (e.g., inside a specific audio processing callback or embedded context) must never yield to the OS, parking_lot forces you to use a completely different crate (like spin) for that 1%. Resync allows you to use the exact same Mutex<T> API, simply swapping the generic parameter from Os to Atomic and Yield to Busy for that specific critical section.

spin vs resync

The spin crate provides excellent pure spinlocks, but lacks advanced composable features like SharingPolicy (RW semantics), NewLocked (TOCTOU-free initialization), and adaptive retry strategies out of the box. Resync’s Atomic backend provides similar raw performance, but plugs into a much richer ecosystem of high-level primitives like Gate and Semaphore.

lock_api vs resync::api

lock_api is the standard for abstracting over locks in the ecosystem. However, its RawMutex trait assumes that locking is infallible (it cannot return an error, nor can it represent timeouts or poisoning). Resync provides its own resync::api::Mutex trait, which embraces granular error handling. It allows generic code to accept any compatible synchronization primitive while preserving safety guarantees like poisoning and timeout handling.

5. Usage Cases & Full Examples

Case A: The Fair Read-Write Lock (Preventing Writer Starvation)

In standard std::sync::RwLock or basic atomic RW locks, a continuous stream of readers can starve a waiting writer indefinitely. Resync solves this with the Shield battery, which wraps any SharingPolicy and blocks new readers the moment a writer starts waiting.

#![allow(unused)]
fn main() {
use resync::{Sharex, lock::{Os, Shield}, retry::Yield};

// Define a fair RW lock type
type FairRwLock<T> = Sharex<T, Shield<Os>, Yield>;

let lock = FairRwLock::new(vec![1, 2, 3]);

// Readers can proceed concurrently
let r1 = lock.read().unwrap();

// If a writer tries to acquire and fails, Shield increments a pending
// counter. Subsequent readers will receive `LockStatus::Fail` and yield,
// guaranteeing the writer gets the lock as soon as `r1` is dropped.
}

Case B: Thread Pool Initialization with Gate

When spawning a pool of worker threads, you often want them to block until the main thread finishes setting up the environment. Using a std::sync::Barrier requires knowing the exact number of threads upfront and is single-use. Using channels introduces allocation overhead.

Gate starts closed (via the NewLocked trait), ensuring no thread can slip through before the setup is done (preventing TOCTOU races).

use resync::{Gate, lock::Os, retry::Yield};
use std::sync::Arc;
use std::thread;

fn main() {
    // Gate is CLOSED by default.
    let gate = Arc::new(Gate::<Os, Yield>::new());
    
    let workers: Vec<_> = (0..8).map(|id| {
        let g = Arc::clone(&gate);
        thread::spawn(move || {
            // All 8 threads block here immediately.
            g.wait().unwrap();
            println!("Worker {id} is processing!");
        })
    }).collect();

    // Main thread does heavy setup...
    std::thread::sleep(std::time::Duration::from_secs(1));
    
    // Unleash all workers simultaneously.
    gate.open();

    for w in workers { w.join().unwrap(); }
}

Case C: Bare-Metal Kernel Development (no_std + Irq)

In OS kernel development, if a thread holds a spinlock and gets interrupted by a hardware IRQ, and the IRQ handler tries to acquire the same spinlock, the system deadlocks.

Resync provides the Irq lock policy, which automatically saves the CPU flags, disables interrupts on acquisition, and restores them on release.

// In a #![no_std] kernel environment
use resync::{Mutex, lock::Irq, retry::Busy};

// The lock protects per-CPU data.
static KERNEL_STATE: Mutex<u32, Irq, Busy> = Mutex::new(0);

fn thread_context() {
    // Interrupts are disabled while this guard is alive.
    let mut state = KERNEL_STATE.lock().unwrap();
    *state += 1;
}

fn hardware_interrupt_handler() {
    // Safe to acquire the lock here, because thread_context 
    // guaranteed interrupts were disabled before taking it.
    let mut state = KERNEL_STATE.lock().unwrap();
    *state += 1;
}

Case D: Graceful Recovery from Poisoning

Unlike std::sync::Mutex which forces you to unwrap() or manually extract the inner error, Resync’s AcquireError allows you to match on the exact reason of failure, including timeouts from custom RetryPolicy implementations.

#![allow(unused)]
fn main() {
use resync::{Mutex, AcquireError, lock::Os, retry::Yield};

let mutex = Mutex::<i32>::new(42);

match mutex.lock() {
    Ok(guard) => println!("Data is safe: {}", *guard),
    Err(AcquireError::Poisoned(err)) => {
        println!("Thread panicked! Inspecting corrupted data...");
        let mut guard = err.into_inner();
        *guard = 0; // Manually repair the state
        unsafe { mutex.clear_poison(); }
    },
    Err(AcquireError::Retry(timeout_err)) => {
        eprintln!("Lock acquisition timed out: {}", timeout_err);
    },
    Err(AcquireError::Lock(os_err)) => {
        eprintln!("Fatal OS error: {}", os_err);
    }
}
}

3. Design Decisions

Why no is_locked() or is_free() in LockPolicy?

A common question when designing lock APIs is: “Why can’t I check if the lock is currently held before trying to acquire it?”

In concurrent programming, checking a state and then acting on it introduces a Time-of-Check to Time-of-Use (TOCTOU) race condition. Consider this hypothetical anti-pattern:

#![allow(unused)]
fn main() {
// HYPOTHETICAL BAD CODE
if !lock.is_locked() {
    // Another thread could acquire the lock RIGHT HERE
    lock.lock();
}
}

By the time you call lock() after checking is_locked(), the state may have already changed. The check is not only wasted CPU cycles, but it gives a false sense of security and predictability.

By omitting state-querying methods, Resync forces you to use LockPolicy::try_lock, which is an atomic check-and-acquire operation. The result of the operation tells you the state at the exact moment the atomic instruction executed, eliminating TOCTOU bugs by design.

Why AcquireError and TryLockError instead of just PoisonError?

Standard library locks return PoisonError<Guard>, which conflates poisoning with other potential lock failures. Resync separates these concerns using AcquireError and TryLockError. These enums distinguish between:

  • Poisoned: A previous thread panicked, and the data might be inconsistent.
  • Lock: A fatal, unrecoverable error occurred in the underlying LockPolicy (e.g., OS resource exhaustion).
  • Retry: The RetryPolicy aborted the wait loop (e.g., due to a timeout).

This granular error handling allows no_std environments and complex systems to react appropriately to timeouts or hardware failures without relying on panics.

Why is Lock Poisoning std-only?

Lock poisoning relies on detecting whether the current thread is unwinding due to a panic (std::thread::panicking()). In #![no_std] environments (like kernels or embedded systems), a panic typically triggers an immediate abort or system reset, making the concept of “recovering from a panic inside a lock” inapplicable. Therefore, the poisoning machinery (AtomicBool flags and guard checks) is conditionally compiled only when the std feature is enabled, ensuring zero overhead for bare-metal targets.

Why is LockPolicy::free taking metadata?

The free method requires the Meta object returned by try_lock. For simple locks (like Atomic), Meta is just (), making the release trivial. However, for more complex locks (like ticket locks or OS futexes that need to track waiter queues), this metadata is essential to correctly release the exact lock instance or wake the correct threads.

Because the Meta is tied to the specific acquisition, it also prevents accidentally releasing a lock you don’t own or releasing it multiple times with stale state. For simple locks where Meta = (), calling free(&()) on an already free lock remains a guaranteed no-op, which dramatically simplifies the implementation of composite locks (like lock::Nested) and error-handling paths. If an abort occurs halfway through acquiring a nested lock, the cleanup code can safely call free on all inner locks that successfully returned their metadata.

Why lock::Nested?

Deadlocks often occur when multiple locks are acquired in inconsistent orders across different threads. lock::Nested enforces a strict, deterministic acquisition order (L1 then L2) and a reverse release order (L2 then L1). This provides a compile-time building block for safe multi-resource locking.

Why AcquireError instead of just PoisonError?

Standard library locks return PoisonError<Guard>, which conflates poisoning with other potential lock failures. Resync separates these concerns using AcquireError and TryLockError. These enums distinguish between:

  • Poisoned: A previous thread panicked, and the data might be inconsistent.
  • Lock: A fatal, unrecoverable error occurred in the underlying LockPolicy (e.g., OS resource exhaustion).
  • Retry: The RetryPolicy aborted the acquisition loop (e.g., due to a timeout).

This granular error handling allows no_std environments and complex systems to react appropriately to timeouts or hardware failures without relying on panics.

Why Shield instead of a custom RwLock?

Writer starvation is a common problem in RwLocks. Instead of hardcoding a “writer-preference” mode into the base Os or Atomic locks (which adds overhead to readers who don’t need it), Resync provides Shield. It acts as a transparent wrapper that intercepts try_lock and try_share, dynamically blocking readers only when a writer is actively waiting. This keeps the base locks fast and simple while providing a composable solution for fairness.

5. Limitations and Caveats

While Resync is highly flexible, it is important to understand its boundaries:

  • No Async/Await Support: Resync is strictly designed for synchronous, thread-based, or interrupt-level synchronization. It does not integrate with Rust’s Waker or Context APIs. Using spin-locks inside an async executor will block the executor thread and starve other futures.
  • Fairness is Not Guaranteed: The default lock::Atomic uses a simple compare_exchange. Under extreme contention, this can lead to thread starvation (where one thread repeatedly wins the race). If strict fairness is required, you must implement a custom LockPolicy (e.g., a ticket lock or MCS lock).
  • Nightly vs. Stable: On stable Rust, traits and default implementations cannot be const. If you require const initialization of your locks in static variables, you must compile your crate with a nightly toolchain. Resync will automatically detect the nightly channel and enable const_trait_impl and const_default.
  • Spin-Loop Starvation: If the thread holding the lock is preempted by the OS while a waiting thread is executing a retry::Busy loop, the waiting thread will burn CPU cycles until the OS reschedules the holder. Always prefer retry::Yield in user-space applications unless you are certain the critical section is shorter than a context switch.
  • Poisoning Requires std: The lock poisoning mechanism relies on std::thread::panicking() to detect unwinding. In #![no_std] environments, panics typically abort the process, making poisoning irrelevant. Thus, poisoning features are gated behind the std feature and add zero overhead to bare-metal targets.

6. Summary

Resync gives you the raw materials to build exactly the synchronization primitive you need, while enforcing safe, race-free API boundaries. By understanding the distinction between acquisition and waiting, you can optimize your concurrent code for any environment, from bare-metal microcontrollers to high-throughput user-space servers.