Crabrix blog

Rust Arc, Mutex and RwLock: shared state without surprises

Choose Arc, Mutex or RwLock with clear ownership and lock boundaries. Learn deadlock prevention, poisoning, async guards and practical Rust concurrency tests.

Name the invariant before choosing a wrapper

Shared state becomes difficult when several operations must agree about one fact. A document registry might track a map of open documents and a total byte count. Updating the map without the count breaks its budget, even if each individual field is accessed safely. Rust prevents many data races, but it does not automatically define the transaction your product requires. Begin by writing the relationship that must remain true.

For this guide, imagine a background indexer that reads project settings while another operation updates them. Some values can be immutable snapshots. A small mutable registry needs a consistent update. Slow analysis should not monopolize the registry lock. Those three observations lead to different tools and a clear test plan. The complete programs use the desktop standard library; the final sections explain async choices and the more limited offline execution profile on iPhone.

Orange Crabrix crabs approach a shared data core through one controlled amber access gate.
Shared ownership and permission to mutate are different responsibilities; choose both deliberately.

Arc shares ownership; it does not lock your data

Arc keeps an allocation alive while strong owners remain. Cloning an Arc creates another shared owner; it does not deep-clone the underlying configuration. This makes an immutable settings snapshot convenient to distribute among workers. The configuration can be large while the ownership operation remains conceptually separate from copying its fields. That is a useful model, not a promise about an exact instruction count or universal speed advantage.

Thread safety still depends on the contained type. Arc<T> is Send and Sync when T satisfies the required Send and Sync bounds. Wrapping RefCell in Arc does not turn runtime borrow checking into cross-thread synchronization. If you need a private mutable value, keep ordinary ownership; if you need shared immutable data, Arc may be enough. Reach for a lock only when mutation and sharing must coexist, and document which component owns the right to replace a snapshot.

Sources: Arc ownership and thread safety

Explore in Crabrix: Box, Rc and Arc interview question · Ownership in a real program

Protect the whole invariant with one short critical section

The registry below keeps a set of document identifiers and a matching count inside one mutex. Two workers attempt the same insertion. Exactly one insertion changes both fields, and the final assertion checks their relationship. The lock protects an operation over the complete Registry, not a collection of unrelated reads. Holding separate locks for the set and count would require a more complicated protocol to preserve the same promise.

The braces make the guard's lifetime visible. Once the block ends, the mutex unlocks through the guard's destructor. No file access, network operation or expensive indexing belongs inside that insertion block. The expect calls are explicit test-harness policies: an unexpected panic or poisoned lock fails this demonstration. A service or editor should choose a recover-or-stop policy suitable for its state instead of copying those expects without thinking.

use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::thread;

#[derive(Default)]
struct Registry {
    documents: HashSet<String>,
    count: usize,
}

fn main() {
    let registry = Arc::new(Mutex::new(Registry::default()));
    let mut workers = Vec::new();
    for _ in 0..2 {
        let registry = Arc::clone(&registry);
        workers.push(thread::spawn(move || {
            {
                let mut state = registry.lock().expect("healthy registry");
                if state.documents.insert("main.rs".to_owned()) {
                    state.count += 1;
                }
                assert_eq!(state.count, state.documents.len());
            } // unlock before other work
        }));
    }
    for worker in workers {
        worker.join().expect("worker completed");
    }
    let state = registry.lock().expect("healthy final registry");
    assert_eq!(state.count, 1);
    assert_eq!(state.documents.len(), 1);
    println!("one consistent insertion");
}

Sources: Mutex guards and locking

Explore in Crabrix: Shared state lesson

Use RwLock for a measured access pattern

An RwLock allows multiple read guards or one exclusive write guard. That can fit a frequently read settings table whose updates are occasional and short. It does not imply that every read-heavy application becomes faster. Scheduling policy and the implementation can affect waiting, and the standard lock does not promise one universal reader/writer priority. Measure contention and tail latency with representative readers and writers before adopting a more complex design.

Do not retain a read guard while trying to acquire a write guard on the same lock. That is not an automatic upgrade operation. Release the read guard, compute a proposal if appropriate, then acquire the write guard and recheck the assumptions that mattered. A version field can make that recheck explicit. If another writer changed the value between those steps, decide whether to recompute, merge or reject; blindly writing the stale result defeats the point of protecting the data.

Sources: RwLock access and platform scheduling policy

Read the three contracts separately

The diagram separates lifetime ownership, exclusive access and reader/writer access. Real designs often compose them, such as Arc<Mutex<Registry>> or Arc<RwLock<Settings>>. Each layer answers a different question. Adding all three wrappers because concurrency feels dangerous does not create a stronger specification. It creates more places where a guard, a clone or an unexpected reference can outlive the operation you intended.

During review, ask who may retain the value, which operation needs mutation, and how long that permission lasts. Then ask what callers observe if the operation fails halfway through. These questions expose incomplete designs earlier than debating lock throughput. A lock that perfectly serializes updates can still serialize the wrong updates, and a data-race-free cache can still return an obsolete result after its configuration changes.

Crabrix infographic compares Arc shared ownership, Mutex exclusive guards and RwLock readers or writer.
The three wrappers describe different contracts; none supplies your application invariant automatically.

Explore in Crabrix: Cache keys and invalidation contracts · Send and Sync

Snapshot, compute, then check before committing

Move slow computation outside the lock by taking a small owned snapshot first. The next program records a version with that snapshot, prepares a proposal without a guard, and commits only if the version is unchanged. Its test deliberately introduces an intervening edit and verifies rejection. This is a simple optimistic protocol, not a complete collaborative-edit algorithm or a guarantee that every conflict can be merged.

The version itself is part of the invariant. Every meaningful write must update it through the same policy; otherwise a writer can change data without invalidating a stale proposal. The example uses checked increment and refuses a write when the counter is exhausted. Long-lived systems may need another identity strategy, but silently wrapping the counter would weaken the equality check. Keep the payload and version together under the same lock.

use std::sync::RwLock;

struct Settings {
    version: u64,
    title: String,
}

fn commit(state: &RwLock<Settings>, expected: u64, title: String) -> bool {
    let mut current = state.write().expect("healthy settings");
    if current.version != expected {
        return false;
    }
    let Some(next) = current.version.checked_add(1) else {
        return false;
    };
    current.title = title;
    current.version = next;
    true
}

fn main() {
    let state = RwLock::new(Settings {
        version: 0,
        title: "crab".into(),
    });
    let (version, title) = {
        let current = state.read().expect("healthy snapshot");
        (current.version, current.title.clone())
    };
    let proposal = title.to_uppercase(); // no guard is held
    assert!(commit(&state, version, "new edit".into()));
    assert!(!commit(&state, version, proposal));
    assert_eq!(state.read().unwrap().title, "new edit");
    println!("stale proposal rejected");
}
Crabrix crabs carry a versioned snapshot to a separate workbench and check it before returning.
Keep expensive work outside the critical section, then validate the snapshot version before committing.

Explore in Crabrix: Checked arithmetic and conversions

Prevent deadlock with a global acquisition policy

Two individually reasonable operations can deadlock when each holds a lock the other needs. Moving a document between two registries is a classic example: one path locks source then destination while another path chooses the reverse order. Specify one ordering based on stable registry identity and use it everywhere. Also handle the same-registry case before attempting to acquire the same non-reentrant lock twice.

Hidden calls matter too. A callback, formatter, destructor or logging hook may reenter code that needs your lock. Prefer extracting the small facts needed for a message and formatting after the guard is gone. Avoid calling arbitrary user code under an internal lock. If nested locking is unavoidable, document the acquisition order beside the operations and add a bounded regression test. Rust's borrow checker does not prove that competing threads will always make progress.

Explore in Crabrix: Deadlock avoidance

Poisoning is a signal to review the invariant

A standard mutex can become poisoned when a thread panics while holding its guard. Acquiring it then returns an error containing access to the guard. This warns that the protected operation may have stopped between related updates. It is not a database rollback, a complete panic detector or a memory-safety boundary you can build unsafe assumptions around. Recovery must be based on what the data means.

For the registry example, a recovery routine might rebuild the stored count from the authoritative document set, validate the rest of the state, and only then deliberately clear poison. For an externally visible financial operation, rebuilding an in-memory count may be nowhere near sufficient. Decide whether to recover, replace the state, fail the operation or terminate the component. Tests should exercise the selected policy instead of treating unwrap as the policy by accident.

Sources: Mutex poisoning and its limitations

Explore in Crabrix: Mutex poisoning lesson

Async changes waiting, not the business invariant

An async mutex lets a task wait without occupying an executor thread while the lock is unavailable. A standard mutex may still be appropriate for a short, uncontended critical section that never crosses await. Choosing an async wrapper does not justify slow work under a guard. It can still serialize every request behind one task, and an async program can still deadlock through circular waits.

Prefer a synchronous helper that completes the protected update before the caller awaits unrelated work. A standard guard held across await can also make a future fail Send requirements for spawning, depending on the guard and executor contract. An async guard that is permitted across await solves that type boundary, not your cancellation policy. If cancellation can interrupt a multi-stage update, define the visible intermediate state or commit only after the awaited work succeeds.

Sources: Tokio guidance on shared state

Explore in Crabrix: Lock scope across await · Cancellation without lost work

Use Send and Sync as compiler-checked boundaries

Send concerns transferring ownership between threads. Sync means that a shared reference can be transferred safely. These are related but different properties, and neither means the value performs its own background work. Small compile-time assertions make an intended boundary executable: require a type to satisfy the traits where your worker or shared container actually needs them. This turns a future refactor into a useful compiler diagnostic.

The next complete program checks the intended registry wrapper without spawning a thread. Add a non-Send field in a scratch copy and inspect the resulting error. Do not fix a failing bound with an unsafe implementation merely because a wrapper looks similar to a thread-safe type. The implementation must uphold the real ownership and aliasing contract for every operation. Most application code should select suitable fields and established primitives instead.

use std::sync::{Arc, Mutex};

struct Registry {
    names: Vec<String>,
}
fn assert_send_sync<T: Send + Sync>() {}

fn main() {
    assert_send_sync::<Arc<Mutex<Registry>>>();
    let state = Arc::new(Mutex::new(Registry {
        names: vec!["main.rs".into()],
    }));
    let another_owner = Arc::clone(&state);
    assert!(Arc::ptr_eq(&state, &another_owner));
    assert_eq!(another_owner.lock().unwrap().names.len(), 1);
    println!("ownership and trait bounds checked");
}

Sources: Send boundary · Sync definition

A scoped borrow can remove the need for shared ownership

Not every worker requires an Arc. If desktop threads are guaranteed to finish before a surrounding scope returns, scoped threads can borrow data from that scope. This can be a simpler contract for a fixed batch of independent calculations over immutable input. The scope joins unfinished scoped threads before returning, which ties their access to an explicit lifetime instead of requiring a globally retained owner.

That does not remove synchronization requirements for shared mutation, and it does not add OS-thread support to a target without it. It changes the ownership shape of a desktop batch. Before introducing reference counting, ask whether the worker really outlives the caller or whether the API merely made that relationship difficult to see. Likewise, a single-thread event loop may use ordinary owned state without cross-thread locks at all.

Sources: Scoped thread lifetimes

Exercise the recovery policy with a deliberate panic

The program below poisons a mutex inside catch_unwind, repairs a derived count from authoritative data, and clears poison only after restoring that invariant. It is a small recovery test, not an instruction to catch every panic in production. It assumes an unwind panic strategy; a panic-abort build cannot run this recovery path. The default panic hook may still print the deliberately triggered panic even though the program catches it and finishes successfully.

The important assertion comes after repair: later users acquire healthy state with a correct count. Merely extracting the poisoned guard and continuing would hide the broken value. Real recovery may require checking more fields, rebuilding an index, discarding an incomplete transaction or refusing access entirely. Keep the test aligned with the actual invariant, and include a case where repair itself is impossible instead of reporting every poisoned state as successfully recovered.

use std::panic::catch_unwind;
use std::sync::Mutex;

struct Index {
    items: Vec<String>,
    count: usize,
}

fn main() {
    let index = Mutex::new(Index {
        items: Vec::new(),
        count: 0,
    });
    let failed = catch_unwind(|| {
        let mut state = index.lock().unwrap();
        state.items.push("main.rs".into());
        panic!("injected interruption before updating count");
    });
    assert!(failed.is_err());
    assert!(index.is_poisoned());
    {
        let mut state = match index.lock() {
            Ok(_) => panic!("fixture must be poisoned"),
            Err(error) => error.into_inner(),
        };
        state.count = state.items.len();
        index.clear_poison();
    }
    let state = index.lock().expect("repaired index");
    assert_eq!(state.count, 1);
    assert_eq!(state.count, state.items.len());
    println!("derived state repaired before reuse");
}

Measure waiting separately from useful work

A lock optimization needs a question more precise than whether one benchmark got faster. Record time spent waiting, time spent holding the guard and end-to-end request latency. A short average critical section can still have rare expensive paths that dominate responsiveness. Test a mix of readers, writers and duplicate work similar to the application rather than only uncontended acquisitions in a tight loop.

Use those observations to choose the next change. A long hold suggests moving computation or allocation out of the critical section. Frequent contention may suggest batching or a different ownership boundary. High memory use after snapshotting may mean the cloned payload is too large. Verify that the optimized version still rejects stale commits and preserves the same invariant; a faster result obtained by dropping that check is a different contract.

Prefer a single owner when the protocol gets complicated

If every mutation needs several locks and a page of ordering rules, consider one owner processing commands. Message passing can centralize validation and make the transaction boundary explicit. It also creates a queueing problem: messages consume memory, senders can outrun the owner, and callers need a response, cancellation and shutdown policy. A bounded queue supplies backpressure only when the sender actually respects its result or waiting behavior.

Atoms are another focused tool, not a universal replacement. A standalone metrics counter may tolerate relaxed ordering because no surrounding payload is being published through it. A ready flag controlling access to other memory needs a deliberate ordering argument. Likewise, sharding a map can reduce contention but makes cross-shard invariants harder. Compare designs using the actual operation, contention distribution and memory budget instead of choosing whichever primitive sounds most advanced.

Sources: Bounded standard channels · Atomic memory ordering

Explore in Crabrix: Channels versus mutex interview · Measure before optimizing

Test behavior, and state where the example can run

The first program exercises real desktop OS threads; run it with rustc or a normal Cargo binary. Repeat runs are useful smoke tests, but they do not enumerate every schedule. Check invariant preservation, duplicate operations, stale proposals, poison recovery and shutdown under a bounded deadline. A hang must fail the harness rather than leave a test running forever. Specialized schedule exploration can complement tests when a concurrent algorithm warrants it.

Crabrix targets a local WASI profile that does not provide general OS-thread spawning or listening servers. Therefore the desktop-thread program is not advertised as an iPhone concurrency benchmark. The snapshot and trait-bound examples separate logic from spawning, but still require target support for the primitives they use. Inspect the exact local profile. The design habit travels further than any wrapper: keep ownership explicit, protect complete invariants, minimize guard lifetime, and make stale or interrupted work produce a deliberate outcome.

Explore in Crabrix: Local execution limits · Concurrency and async course · Testing useful boundaries

Explore the free Crabrix lessons →