me-wiki
Log in

Sync Primitives

Synchronization primitives are the cornerstone of concurrent and asynchronous programming in Rust. Choosing the correct primitive ensures both optimal performance and safety from deadlocks, race conditions, and undefined behavior [50, 516]. This document provides a comprehensive analysis and comparison of the synchronization tools found in the Rust standard library (std::sync) and the Tokio runtime (tokio::sync) [50, 51].


📊 High-Level Comparison Matrix

Primitive / Channel Ecosystem Concurrency Pattern Thread-Safety Poisoning Blocking Behavior Recommended Use Case
std::sync::Arc<T> Standard Library Shared Ownership [72] Send + Sync (if T: Send + Sync) [16] N/A None (Atomic pointer swap) [72, 593] Thread-safe read-only sharing [40, 591, 592]. Wraps mutable primitives (e.g., Arc<Mutex<T>>) [51].
std::sync::Mutex<T> Standard Library Mutual Exclusion (Sync) [412] Send + Sync (if T: Send) [16] Yes [417, 697] Blocks OS thread [416] Default choice for short-lived, synchronous critical sections (e.g., updating a counter, pushing to a Vec) [51, 418, 424]. Never hold across .await [51, 419, 532].
tokio::sync::Mutex<T> Tokio Mutual Exclusion (Async) [415] Send + Sync (if T: Send) No [417, 698] Yields task back to executor [416, 533] Only use when you must hold a lock across an .await boundary (e.g., network request, database call) [51, 419, 422].
std::sync::RwLock<T> Standard Library Reader-Writer Lock (Sync) [11, 620] Send + Sync (if T: Send + Sync) [16] Yes [696, 697] Blocks OS thread [11] High-Read / Low-Write synchronous state [53, 575, 690]. Allows parallel reads but blocks on writes [11, 12, 690].
tokio::sync::RwLock<T> Tokio Reader-Writer Lock (Async) [11] Send + Sync (if T: Send + Sync) No [698] Yields task back to executor [11] High-Read / Low-Write state accessed across asynchronous .await boundaries [4, 11, 53].
std::sync::OnceLock<T> Standard Library Lazy Initialization [574] Send + Sync (if T: Send + Sync) N/A Blocks OS thread on initialization Safe creation of global, lazy-initialized singletons [574, 575].
tokio::sync::watch::* Tokio State Broadcasting (SPMC) [54, 660] Receiver is Clone, Sender requires Arc [3, 675, 676] No Non-blocking / Yields on .changed().await [663] UI reactive updates, status tickers, or configuration distribution where only the latest state matters [3, 54, 661].
tokio::sync::broadcast::* Tokio Event Streaming (MPMC) [5, 656] Both Sender and Receiver are Clone [5, 8] No Non-blocking / Yields on .recv().await Event feeds, activity logs, pub/sub, or message buses where every single event must be processed in order [5, 8].
CancellationToken Tokio (tokio_util) Trigger / Flag Broadcasting [31, 658] Clone + Send + Sync [31] No Non-blocking / Yields on .cancelled().await Broadcasting graceful shutdown or cooperative cancellation signals across numerous background tasks [31, 658, 659].

🔒 1. Standard Library (std::sync) Primitives

The standard library primitives are designed for threads and synchronize at the operating system level [415, 416]. They are exceptionally fast and rely on direct hardware/OS primitives but are generally blocking [416, 417].

Arc<T> (Atomically Reference Counted)

Arc is a thread-safe, shared-ownership smart pointer [16, 72]. It does not duplicate the underlying data T when cloned [72, 591]. Instead, it increments an internal atomic reference counter [72, 73, 591].

  • Layout & Size: On a 64-bit architecture, an Arc<T> pointer takes up exactly 8 bytes on the stack [24]. The heap allocation stores the strong reference count (8 bytes), the weak reference count (8 bytes), and the actual data T [26].
  • Cloning: Cloning an Arc is incredibly cheap (taking mere nanoseconds) because it copies only the pointer and performs a single optimized atomic CPU instruction to increment the reference count [72, 76, 593].
  • Mutability: By default, data inside an Arc is immutable [592]. For shared mutability across threads, you must wrap a mutable primitive inside it (e.g., Arc<Mutex<T>>) [51, 620].

Mutex<T> (Synchronous Mutual Exclusion)

std::sync::Mutex guarantees that only one thread can access the wrapped data at any given time [412, 415].

  • Under the Hood: Calling .lock() blocks the current OS thread [416]. If the lock is held by another thread, the operating system puts the thread to sleep until it is notified of the lock's release [416].
  • Lock Poisoning: If a thread panics while holding a standard Mutex, the lock becomes "poisoned" to protect data integrity [417, 697]. Subsequent attempts to lock it will return an Err(PoisonError) [696, 697]. Developers typically call .lock().unwrap() to propagate the panic, indicating that the inner state has been corrupted [696, 697].
  • Best Use Case: It is the fastest, lowest-overhead mutual exclusion container [417, 418, 423]. Use it by default for highly transient synchronous operations where lock hold times are measured in nanoseconds (e.g., updating numbers, modifying a configuration field, or pushing to a Vec) [51, 418, 424].
  • The Async Hazard: Never hold a standard Mutex lock across an .await boundary [51, 419, 532]. If a task holding a standard mutex gets suspended at an .await point, the underlying OS worker thread might execute a different task that attempts to acquire the same lock [419, 532]. Because the first task cannot resume to release the lock, and the second task blocks the thread waiting for it, your application deadlocks [419, 532].

RwLock<T> (Synchronous Reader-Writer Lock)

std::sync::RwLock allows multiple concurrent readers to access data, but requires exclusive access for a single writer [11, 620].

  • Locking Rules: Multiple read guards (.read()) can exist simultaneously [11]. However, any thread calling .write() will block until all active readers release their locks [11, 12].
  • Performance: Highly optimized for High-Read / Low-Write patterns [53, 575, 690]. Concurrent readers incur near-zero overhead [11, 690], but frequent writes will degrade throughput and can starve readers [691].

⚡ 2. Tokio Asynchronous (tokio::sync) Primitives

Tokio's synchronization primitives are specifically designed for cooperative multitasking within an asynchronous runtime [415, 416]. Instead of blocking OS threads, they yield task execution back to the executor [416].

tokio::sync::Mutex<T> (Asynchronous Mutex)

Tokio's mutex provides mutual exclusion without blocking threads [416].

  • Under the Hood: Calling .lock().await is an asynchronous operation [421]. If the lock is busy, the calling task yields control back to the Tokio scheduler, allowing other tasks to run on that same OS thread in the meantime [416].
  • Lock Poisoning: Unlike std::sync::Mutex, Tokio's async mutex does not implement lock poisoning [417, 698]. If a task panics while holding the lock, the lock simply becomes available to the next task [417].
  • When to Use: Use it only when your lock must remain active while the thread yields to the async runtime—i.e., holding the lock across an .await boundary (such as waiting on a slow database query or an HTTP response) [51, 419, 422].
  • Performance Overhead: It is significantly heavier and slower than std::sync::Mutex because managing task-switching and yielding requires heap allocation and complex runtime bookkeeping [417, 423].

tokio::sync::watch (State Broadcast Channel)

A Single-Producer, Multi-Consumer (SPMC) channel designed to broadcast the latest state or configuration to multiple active listeners [54, 660].

  • No Queue / History: It does not buffer values [657, 662]. If a new value is sent before the receiver reads the previous one, the old value is instantly overwritten [3, 657, 660, 662]. Receivers are guaranteed to never lag and will always pull the freshest state [3, 657, 660].
  • Ref/Read Locks: Calling .borrow() on a receiver returns a watch::Ref<'_, T> which acts as a read lock on the channel [664, 666]. You must drop this borrow quickly; holding it across an .await point will block the Sender from updating the value [664].
  • Cloning: The Receiver is cheap to clone [663, 708]. The Sender strictly enforces single-ownership and does not implement Clone [3, 675, 709]. To share write capabilities among multiple tasks, wrap the sender in an Arc (Arc<watch::Sender<T>>) [3, 73, 182, 710].
  • Optimization with send_if_modified: If your data implements PartialEq, you can call .send_if_modified() with a closure [53, 671, 672]. This mutates the value in-place and only wakes up downstream listeners if the data actually changed, preventing unnecessary UI re-renders [3, 53, 671].

tokio::sync::broadcast (Event Broadcast Channel)

A Multi-Producer, Multi-Consumer (MPMC) channel that acts as an event stream [5, 656].

  • Keeps History: Uses a buffered ring-buffer queue so that every listener receives every single message in order [5, 8, 85]. Both Sender and Receiver can be cloned natively [5, 8].
  • Lag Handling: If a receiver is too slow and the ring buffer overflows, the receiver will receive a RecvError::Lagged(u64) error indicating how many messages were dropped [5, 8, 12, 656].
  • No Replay: Calling .subscribe() places the new receiver's pointer at the current moment; it will not replay past events sent prior to its subscription [9].

🏷️ 3. Thread Safety: Send and Sync

In Rust, multi-threaded safety is built directly into the type system via two crucial auto-traits [14].

  • Send: A type is Send if ownership of a value can be transferred from Thread A to Thread B [14]. Most types are Send (e.g., String, Vec<u8>, and Arc<T>) [14, 16]. Non-examples include Rc<T> because copying its non-atomic counter concurrently would lead to data corruption [14].
  • Sync: A type is Sync if it is safe for multiple threads to hold immutable references (&T) to the same memory location simultaneously [15].
  • The Golden Relationship: T is Sync if and only if &T is Send [15].
  • Auto-Trait Derivation: If all fields of a struct are Send, the struct is automatically Send [17]. If all fields are Sync, the struct is automatically Sync [17]. Adding a single non-Send or non-Sync field (like Rc<T> or a raw pointer) strips that capability, preventing you from passing the struct into a tokio::spawn block or sharing it across thread boundaries [14, 17].

⚖️ 4. Architectural Rules of Thumb

std's Mutex<T> vs. tokio's Mutex<T>

  1. Rule 1 (The Default): Always default to std::sync::Mutex (or parking_lot::Mutex) [418]. It is significantly faster and lighter [417, 423].
  2. Rule 2 (The .await Check): Scan your critical section. If there is no .await point while the lock is held, use std::sync::Mutex [51, 418, 424].
  3. Rule 3 (The Asynchronous Lock): Only switch to tokio::sync::Mutex if a task must hold the lock across a yielding .await boundary [51, 419, 424].

tokio::sync::watch vs. tokio::sync::broadcast

  • Use watch if you are broadcasting state (e.g., UI status, configuration settings, live stock ticker values) where intermediate updates are safely overwritten and consumers only require the latest data [6, 54, 661].
  • Use broadcast if you are streaming discrete events (e.g., a chat application, server access logs, real-time telemetry) where missing a single message represents a protocol failure or data loss [5, 8, 83].

Consuming Locks and Shared Pointers

If you need to extract the inner data from a lock or reference pointer, use these safe ownership-consuming patterns:

  • For std::sync::RwLock: Use .into_inner() to dismantle the lock and return the inner value T by value [699, 700]. This takes self by value, requiring full ownership but completely bypassing locking overhead [700].
  • For std::sync::Mutex: Change your function signature to modify data in-place via &mut T to avoid ownership-taking battles [426]. If T implements Default, use std::mem::take(&mut *guard) to temporarily replace the lock's contents [426], or wrap the data in an Option<T> and use .take() to pull it out safely [427].
  • For Arc<T>: Use Arc::try_unwrap(arc) or Arc::into_inner(arc) [701]. If the atomic strong reference count is exactly 1 (meaning you are the sole owner), Rust destroys the Arc wrapper and hands you the inner data T by value [701]. If other cloned pointers exist, it fails safely to prevent undefined behavior [701].