Skip to content
CryoCryo home
StdlibConcurrency

sync

Synchronization primitives. Cross-platform: pthread primitives on POSIX, the Win32 equivalents (SRWLOCK, condition variables) on Windows, selected by target.

ItemImport
Atomic<T>, MemoryOrder, fenceimport std::sync::atomic;
Mutex<T, A>, MutexGuardimport std::sync::mutex;
RwLock<T, A> and its guardsimport std::sync::rwlock;
CondVarimport std::sync::condvar;
Onceimport std::sync::once;
Barrierimport std::sync::barrier;
channel, Sender, Receiverimport std::sync::mpsc;

Sharing any of these across threads means wrapping them in Arc. The guard types are !Send by design — a lock guard must be released by the thread that took it. See Send and Sync.

Atomic<T>

One generic cell driven by the compiler's atomic intrinsics, which lower directly to LLVM's atomicrmw, cmpxchg, and atomic load/store. No libc, no spin loops, no calls.

import std::sync::atomic;

mut hits: Atomic<u64> = Atomic<u64>::new(0);
hits.fetch_add(1, MemoryOrder::Relaxed);
const total: u64 = hits.load(MemoryOrder::SeqCst);

T is dispatched at compile time with static match (T), so Atomic<u32>::load lowers to exactly the u32 atomic and nothing else. LLVM does not distinguish signed from unsigned for atomic integer memory operations, so the i32, i64, and boolean arms bitcast onto the same unsigned intrinsics.

Supported types are u8, u32, u64, i32, i64, and boolean. Instantiating Atomic<T> with anything else — or calling fetch_add on Atomic<boolean> — is a compile error (E0645). The absence of a matching static match arm is the type constraint; no trait bound is needed.

MethodReturns
static new(initial: T)Atomic<T>
load(order) / store(val, order)T / void
fetch_add / fetch_sub / fetch_and / fetch_or / fetch_xor(val, order)T — the previous value.
swap(val, order)T — the previous value.
compare_exchange(current, next, succ, fail)Result<T, T>Ok(prev) on swap, Err(actual) on mismatch.

Atomic cells are deliberately not Copy. Mutation through a shared pointer has to stay explicit, and bitwise-copying an atomic would silently break that. Wrap in Arc<Atomic<T>> to share.

MemoryOrder

OrderMeaning
RelaxedAtomicity only, no ordering. Cheapest; correct for hit counters where the sequence of observed values doesn't matter.
AcquireOn loads and RMW: no later access moves above this one. Pairs with Release.
ReleaseOn stores and RMW: no earlier access moves below this one. Pairs with Acquire.
AcqRelBoth, for RMW operations only.
SeqCstFull sequential consistency — every thread observes the same global order.

When in doubt, SeqCst is always safe. A load cannot use Release.

fence(order) emits a real LLVM fence. Because a Relaxed fence would be meaningless, a weaker request is clamped up to SeqCst rather than producing invalid IR. compiler_fence(order) is for constraining the compiler's reordering rather than the CPU's — signal handlers, longjmp paths — though today it conservatively lowers to a real fence too.

Mutex<T, A>

At most one thread at a time can read or mutate the guarded value.

import std::sync::mutex;

mut m: Mutex<Counter> = Mutex<Counter>::new(counter);
mut g: MutexGuard<Counter, GlobalAlloc> = m.lock();
g.as_ptr().hits = g.as_ptr().hits + 1;

new, new_in(alloc), try_new, and try_new_in construct; lock() returns a MutexGuard<T, A> and try_lock() returns Option<MutexGuard<T, A>>.

The guard dereferences through as_ptr() -> T*, valid for the guard's lifetime, and releases the lock when its drop runs.

The backing pthread buffer lives in a heap-allocated inner so its address stays stable while other threads hold or wait on the lock — moving a pthread mutex after init is undefined behaviour. The Mutex<T, A> handle itself is just a pointer, so the handle moves freely.

RwLock<T, A>

Many concurrent readers or one exclusive writer. Reach for it over a Mutex when the value is read often and written rarely, since the read path does not serialize.

new, new_in, try_new, try_new_in, then read() / try_read() for a RwLockReadGuard<T, A> and write() / try_write() for a RwLockWriteGuard<T, A>. Both guards expose as_ptr(). Multiple read guards may coexist; a write guard excludes everything else.

CondVar

Atomically release a Mutex and sleep until another thread signals — the standard producer/consumer primitive.

mut guard: MutexGuard<Queue, GlobalAlloc> = shared.lock();
while (guard.as_ptr().is_empty()) {
    cond.wait(&shared, &mut guard);
}
// act on the queue

wait(mutex, guard) releases the lock, sleeps, and reacquires before returning. The guard stays "locked" from your perspective; pthread does the unlock and relock internally, and the guard's address still refers to the same inner.

notify_one() wakes one waiter, notify_all() wakes all. If nothing is waiting, a signal is lost — standard pthread semantics, which is why the consumer must check the predicate before sleeping. Spurious wake-ups are possible regardless, so re-check the predicate after waking; that is why the example loops rather than using an if.

Cryo has no borrow checker, so it is on you not to touch the guarded value while inside wait() — doing so reads through the guard while another thread holds the lock.

CondVar is Send + Sync unconditionally.

Once

Run an initializer exactly once across all threads. The first caller runs it; every other caller blocks until it finishes, and thereafter call_once returns immediately.

import std::sync::once;

mut init: Once = Once::new();
init.call_once(() -> void { setup_tables(); });

The initializer is a () -> void function pointer, and a non-capturing lambda is one.

The backing pthread_once_t starts all-zero, which is PTHREAD_ONCE_INIT, so no separate init call is needed.

Barrier

A rendezvous for a fixed group of threads. Every thread calling wait() blocks until exactly count have arrived, then all proceed.

Barrier::new(count) constructs; wait() returns a BarrierWaitResult whose is_leader() is true on exactly one of the awakening threads. That flag is the standard way to designate one thread to do per-phase cleanup after every worker finishes a phase.

The barrier resets automatically for the next cycle. Barrier is Send + Sync unconditionally.

Channels

channel<T>() returns the owning Receiver<T>; mint producers from it with rx.sender(). Each Sender<T> is independently owned and movable into its own thread, and cloneable for further fan-out. Values of any type — including owned data like String — move through an unbounded FIFO queue.

import std::sync::mpsc;
import std::thread;

mut rx: mpsc::Receiver<i32> = mpsc::channel<i32>();
const tx: mpsc::Sender<i32> = rx.sender();

const h = thread::spawn<mpsc::Sender<i32>, i32>(tx, (s: mpsc::Sender<i32>) -> i32 {
    mut snd: mpsc::Sender<i32> = s;
    snd.send(42);
    snd.close();
    return 0;
});

match (rx.recv()) {
    Result::Ok(v)  => { use(v); }
    Result::Err(_) => { }
}
h.join();

Sender carries send(value), clone(), and close(). Receiver carries sender(), recv() (blocking), and try_recv() (non-blocking).

Disconnection

When every Sender has been dropped, a blocked recv() returns Err(RecvError::Disconnected) and try_recv() returns Err(TryRecvError::Disconnected).

When the Receiver is dropped, send() returns Err(SendError) handing the value back, rather than queueing into a channel nobody will drain.

Underneath, a heap inner holds a mutex and condition variable guarding a raw singly-linked node queue, plus a live-sender count and a total-handle refcount; the inner is reclaimed when the last handle of either kind drops. Values move out of dequeued nodes through raw pointers, so the move-checker sees each as a clean transfer — no aliasing, no double free.