Sharing state across tasks
You have one logical piece of state; several tasks need to read/write it. Which primitive fits?
Shared-wrapped atomic receivers preserve the stored atomic value in
interpreter and native execution. That check covers primitive receiver
identity, not every concurrent composition below.
Mutex guard ownership and cleanup still differ between the interpreter and native AOT: a lock can be released too early or remain held after scope exit. Treat mutex-based examples as API guidance until their full lifetime behavior is verified. See synchronization limitations.
The decision matrix
| State | Contention | Use |
|---|---|---|
| Read-only, frozen at start | — | Shared<T> (no lock) |
| Read-mostly, rare writes | Low | Shared<RwLock<T>> |
| Read-write, simple CAS | — | AtomicU64 / AtomicBool / AtomicPtr<T> |
| Read-write, short critical sections | Low–medium | Shared<Mutex<T>> |
| Read-write, long critical sections | Any | Actor via channel |
| Append-only log | High | channel (MPMC if many readers) |
| Multi-writer accumulator | High | Atomic + fan-in |
Shared<T> — frozen at start
let cfg = Shared.new(load_config()?);
for _ in 0..num_workers {
let c = cfg.clone();
spawn async move {
use_config(&c); // immutable; no lock
};
}
Cheapest option. Use whenever the data never changes after creation.
Shared<Mutex<T>> — short critical sections
let state = Shared.new(Mutex.new(Counter { value: 0 }));
let c = state.clone();
spawn async move {
for _ in 0..1000 {
let mut g = c.lock().await;
g.value += 1;
}
};
Rule: critical sections should not contain .await on unrelated
futures. If they must, consider an actor.
Shared<RwLock<T>> — read-mostly
let cache = Shared.new(RwLock.new(Map<Text, Value>.new()));
async fn get_or_compute(cache: &Shared<RwLock<Map<Text, Value>>>, key: &Text) -> Value {
// Fast path: reader lock
{
let r = cache.read().await;
if let Maybe.Some(v) = r.get(key) { return v.clone(); }
}
// Slow path: writer lock
let v = compute_slow(key).await;
let mut w = cache.write().await;
w.insert(key.clone(), v.clone());
v
}
Multiple concurrent readers; exactly one writer. Watch for writer starvation on very read-heavy workloads.
Atomics — lock-free counters / flags
let counter = Shared.new(AtomicU64.new(0));
let stopped = Shared.new(AtomicBool.new(false));
spawn async move {
while !stopped.load(MemoryOrdering.Acquire) {
counter.fetch_add(1, MemoryOrdering.Relaxed);
sleep(10.millis()).await;
}
};
MemoryOrdering.Relaxed for counters without inter-thread ordering
constraints; Acquire/Release for handshakes. When in doubt, use
SeqCst — it's correct; only weaken after profiling.
Actor — long critical sections
Instead of locking, give the state to a dedicated task and communicate via channel:
type Req is
| Get { key: Text, reply: OneshotSender<Maybe<Value>> }
| Put { key: Text, value: Value }
| Shutdown;
async fn actor_loop(mut rx: Receiver<Req>) {
let mut store: Map<Text, Value> = Map.new();
while let Maybe.Some(req) = rx.recv().await {
match req {
Req.Get { key, reply } => {
let _ = reply.send(store.get(&key).cloned());
}
Req.Put { key, value } => {
store.insert(key, value);
}
Req.Shutdown => break,
}
}
}
// Client
fn make_actor() -> (ActorHandle, JoinHandle<()>) {
let (tx, rx) = bounded<Req>(128);
let h = spawn actor_loop(rx);
(ActorHandle { tx }, h)
}
type ActorHandle is { tx: Sender<Req> };
implement ActorHandle {
async fn get(&self, key: &Text) -> Maybe<Value> {
let (reply, wait) = oneshot<Maybe<Value>>();
self.tx.send(Req.Get { key: key.to_string(), reply }).await.unwrap();
wait.await.unwrap()
}
async fn put(&self, key: Text, value: Value) {
self.tx.send(Req.Put { key, value }).await.unwrap();
}
}
Benefits:
- No lock contention — requests queue.
- Arbitrary async inside the actor (DB calls, etc.).
- State mutations linearise on the actor.
- Easy to swap implementations for tests.
Trade-off: one round-trip latency per operation.
Pitfall — await inside a mutex
// DO NOT
let mut guard = mu.lock().await;
let response = Http.get(url).await?; // serialises all callers!
guard.commit(response);
Get what you need, drop the guard, then await:
let req = {
let g = mu.lock().await;
g.build_request()
};
let response = Http.get(req.url).await?;
{
let mut g = mu.lock().await;
g.commit(response);
}
Pitfall — deadlock from lock ordering
Two locks + two tasks + opposite acquisition order = deadlock. Mitigate:
- Define a canonical lock order (document it!). All tasks acquire in the same order.
- Prefer one lock (or actor) over many.
- Use
try_lockwith retries if you must acquire multiple.
See also
- sync — atomics, mutex, rwlock, condvar.
- async → channels — the actor's communication primitive.
- Performance — when locking becomes the bottleneck.