Skip to main content

Sharing state across tasks

You have one logical piece of state; several tasks need to read/write it. Which primitive fits?

Mutex guard lifetime remains incomplete

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​

StateContentionUse
Read-only, frozen at start—Shared<T> (no lock)
Read-mostly, rare writesLowShared<RwLock<T>>
Read-write, simple CAS—AtomicU64 / AtomicBool / AtomicPtr<T>
Read-write, short critical sectionsLow–mediumShared<Mutex<T>>
Read-write, long critical sectionsAnyActor via channel
Append-only logHighchannel (MPMC if many readers)
Multi-writer accumulatorHighAtomic + 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_lock with retries if you must acquire multiple.

See also​