cosmix/loom

loom-concurrency

Concurrency and parallelism patterns for multi-threaded and async code.

First seen May 20, 2026

Installation

$ npx skills add cosmix/loom --skill loom-concurrency

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from cosmix/loom · top by installs.

npx skills add cosmix/loom

Browse all from cosmix/loom

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 53
License LICENSE
Default branch main
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 19,253 B
  • docs SUMMARY.md 95 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 5 installs

SKILL.md

Concurrency

Overview

Cross-language concurrency reference organized by concept, not by language. Each concept lists the shared principle plus per-language gotcha callouts for Rust (tokio), Python (asyncio), TypeScript (event loop), and Go. The hard part is never the happy-path API; it's the failure modes — data races, deadlocks, latent OOM from unbounded queues, futures dropped mid-flight by cancellation, and memory-ordering bugs that only surface under load on weakly-ordered CPUs.

Agent Delegation

  • loom-senior-software-engineer (Opus) — DEFAULT. Threading models, shared-state vs message-passing, race/TOCTOU analysis, lock ordering, memory ordering, distributed concurrency/consistency, saga patterns.
  • loom-software-engineer (Sonnet) — ONLY boilerplate async handlers or unit tests following an established pattern.

Mental Model: Pick the Right Executor

Workload Rust Python TS/JS Go
I/O-bound tokio tasks asyncio async/Promises goroutines
CPU-bound rayon / spawn_blocking ProcessPoolExecutor Worker threads goroutines (real parallelism)
Blocking call in async spawn_blocking runinexecutor/to_thread Worker fine (goroutines block cheaply)

Runtime model determines everything:

  • Rust/tokio, Go — genuinely multi-threaded; tasks run on a thread pool → shared mutable state needs real synchronization.
  • Python asyncio — single OS thread; the GIL means asyncio gives you concurrency, not parallelism. Great for I/O, useless for CPU (one core). CPU work → ProcessPoolExecutor. A blocking call (sync DB driver, time.sleep, heavy compute) freezes the entire event loop.
  • JS/TS — single-threaded event loop. Same rule: a long synchronous loop blocks all timers, I/O callbacks, and rendering. CPU work → Web/Worker threads. There are no data races on shared JS objects (no preemption between awaits within a microtask), but state can change across an await.

The blocking-in-async footgun (asyncio + JS): await only yields at await points. Synchronous CPU work or a blocking syscall between awaits stalls every other task. Never call a sync HTTP/DB client, time.sleep, or a tight compute loop directly in an async function — offload it.

Data Races & Shared State

A data race = two threads touch the same memory, ≥1 writes, no synchronization. Rust makes most compile-errors via Send/Sync; Go/Python/JS do not.

Principle: minimize shared mutable state. Prefer message passing (channels) or immutable snapshots. When you must share:

// Rust: Arc<Mutex<T>> for writes, Arc<RwLock<T>> for read-heavy.
use std::sync::Arc;
use tokio::sync::Mutex;               // async Mutex; std::sync::Mutex for sync-only sections
let cache = Arc::new(Mutex::new(HashMap::<String, String>::new()));
let mut g = cache.lock().await;
g.insert(k, v);                        // guard drops at end of scope → keep scope tiny

Per-language gotchas:

  • RustRwLock can starve writers under continuous readers; prefer Mutex unless reads vastly dominate. std::sync::Mutex is not poisoned-safe across await — use tokio::sync::Mutex only when the guard must cross .await (see below), otherwise the std mutex is faster.
  • Go — the race detector (go test -race, go run -race) is your primary tool; run it in CI. Copying a sync.Mutex by value silently breaks it (go vet catches most). Map access is not goroutine-safe — a concurrent read+write panics ("concurrent map writes"); use sync.RWMutex or sync.Map.
  • Python — even with the GIL, x += 1 is not atomic (LOAD/ADD/STORE bytecodes can interleave on thread switch). Threaded code still needs threading.Lock. asyncio code needs asyncio.Lock only around state that spans an await.
  • JS — no locks needed for sync sections, but "read state → await → write state" is a check-then-act race: the awaited gap lets other tasks mutate. Re-read after the await or guard with an async mutex.

Locks, Lock Ordering & Deadlock

Deadlock rule #1: acquire locks in a globally consistent order. Two code paths taking A→B and B→A will eventually deadlock. Assign a total order (by address, id, or name) and always lock ascending.

Thread 1: lock(A); lock(B)   ┐  both block forever
Thread 2: lock(B); lock(A)   ┘  → enforce A-before-B everywhere

Defenses, in order of preference:

  1. Don't hold two locks. Restructure so critical sections don't nest.
  2. Consistent ordering when you must nest.
  3. try_lock with timeout + backoff as a last resort (detects, doesn't prevent).
  4. Keep critical sections tiny — never do I/O, call user callbacks, or .await unrelated work while holding a lock.

Holding a lock across .await (the async deadlock/Send trap):

  • Rust — holding std::sync::MutexGuard across .await makes the future !Send, so tokio::spawn won't compile. Fix: drop the guard before .await, or use tokio::sync::Mutex (designed to be held across await — but then two tasks awaiting the same lock on one thread can deadlock the logical flow). Best: copy the data out, drop the guard, then await.
  • Python/JS — holding an asyncio.Lock / async mutex across an await that (transitively) tries to re-acquire the same lock is a self-deadlock. Async locks are not reentrant.
  • Gosync.Mutex is not reentrant either; a method holding the lock calling another method that locks the same mutex deadlocks.

Re-entrancy: none of these mutexes are reentrant. If a locked method calls another locked method on the same object → deadlock. Split into a public (locks) + private (assumes locked) method pair.

Channels & Backpressure

Channels decouple producers from consumers. The single most important decision: bounded vs unbounded.

Unbounded channels are latent OOM. If producers outpace consumers, an unbounded queue grows without limit until the process is killed. Default to bounded; a full bounded channel applies backpressuresend blocks/awaits, throttling the producer to the consumer's rate. Only use unbounded when you can prove the producer is rate-limited by something else.

Bounded Unbounded
Rust tokio mpsc::channel(N)send().await mpsc::unbounded_channel() — ⚠ OOM risk
Python asyncio.Queue(maxsize=N) asyncio.Queue() (maxsize=0) — ⚠
Go make(chan T, N) make(chan T) (unbuffered = rendezvous, or N too small = stall)

Per-language gotchas:

  • Rusttokio::sync::mpsc::Receiver is not clonable (single consumer). For fan-out to many workers, share one Arc<Mutex<Receiver>> or use async_channel/flume (MPMC). The old skill's "clone the receiver" pattern does not compile. Dropping all senders closes the channel; recv() then returns None — the clean shutdown signal.
  • Goonly the sender closes a channel, and only once; sending on a closed channel panics, closing twice panics. Ranging over a channel (for v := range ch) exits when it's closed. Reading from a nil channel blocks forever (useful in select to disable a case). Leaked goroutines blocked on a channel nobody closes are a top Go bug.
  • Python/JS — bound the queue and handle QueueFull/awaiting put as backpressure; an unbounded producer feeding a slow consumer is the classic memory leak.

Structured Concurrency

Principle: spawned tasks have a bounded lifetime tied to a scope; the scope waits for all children and propagates the first error, cancelling siblings. This kills orphaned/leaked tasks and lost exceptions.

# Python 3.11+: TaskGroup — cancels siblings on first exception, raises ExceptionGroup
async with asyncio.TaskGroup() as tg:
    tg.create_task(fetch(a))
    tg.create_task(fetch(b))
# exits only when all done; any failure cancels the rest
// Rust tokio: JoinSet — owns tasks, join_next() yields results as they finish;
// dropping the JoinSet aborts all remaining tasks.
let mut set = tokio::task::JoinSet::new();
for url in urls { set.spawn(fetch(url)); }
while let Some(res) = set.join_next().await {
    let _ = res?;   // JoinError on panic/abort
}
// Go: errgroup — first non-nil error cancels the derived ctx; Wait returns it.
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
    u := u                       // pre-1.22 loop-var capture!
    g.Go(func() error { return fetch(ctx, u) })
}
if err := g.Wait(); err != nil { return err }

Gotchas:

  • Python — bare asyncio.gather(*tasks) does NOT cancel siblings on failure and, without returnexceptions=True, leaves other tasks running as it raises. TaskGroup (3.11+) is the correct default. A task created with createtask and never awaited can have its exception silently swallowed ("Task exception was never retrieved").
  • Rusttokio::spawn detaches: the task keeps running even if the handle is dropped, and its panic is isolated (only surfaced via the JoinHandle). Prefer JoinSet when you need lifetime + error propagation. join!/tryjoin! run futures on the current task (no parallelism across threads, but concurrent) and tryjoin! short-circuits on first error.
  • Gosync.WaitGroup gives you waiting but not error propagation or cancellation; errgroup adds both. Classic footgun: capturing the loop variable by reference (fixed in Go 1.22, still bites older code) — every goroutine sees the last value.
  • JSPromise.all rejects on first failure but does NOT cancel the other in-flight promises (JS promises aren't cancellable); they run to completion, their results discarded. Use Promise.allSettled when you need every outcome. AbortController is the cancellation mechanism for fetch/streams.

Cancellation & Timeouts

Cancellation must propagate. A timeout at the top is useless if inner operations ignore it.

// Rust: tokio::time::timeout returns Err(Elapsed) and DROPS the future at the
// current await point → cancellation. The dropped future must be cancel-safe.
match tokio::time::timeout(Duration::from_secs(5), op()).await {
    Ok(v) => v?, Err(_) => return Err("timed out".into()),
}
// Go: context is the cancellation currency. Thread ctx through every call;
// select on ctx.Done(). ALWAYS defer cancel() to avoid ctx leaks.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
select {
case r := <-ch:   return r, nil
case <-ctx.Done(): return zero, ctx.Err()
}

Cancellation safety (Rust select!, Go select, any timeout): when a future is dropped mid-flight, work already in progress is lost. If that future had taken an item from a channel or half-written to a buffer, dropping it loses the item / corrupts state. In tokio::select!, only use futures whose partial execution is safe to discard; otherwise use Semaphore::acquire_owned / hold the value outside the select!, or use cancellation tokens. mpsc::Receiver::recv is cancel-safe; a hand-rolled read-modify-write across await usually is not.

  • Python — cancellation raises asyncio.CancelledError inside the task at its next await. Never swallow it with a bare except Exception (in 3.8+ it derives from BaseException, so except Exception won't catch it — but except: will; don't). Cleanup goes in finally; if you must shield critical cleanup from cancellation, use asyncio.shield.
  • JS — pass an AbortSignal to fetch/streams; a rejected/aborted promise still lets already-started microtasks finish. There is no forced cancellation of arbitrary async work.
  • Go — a goroutine only stops if it checks ctx.Done(); there is no preemptive kill. Blocking on a channel without a ctx case = uncancellable = leak.

Atomics & Memory Ordering

For a single counter/flag, an atomic beats a mutex. But memory ordering is the trap.

Ordering, weakest→strongest: Relaxed < Acquire/Release < AcqRel < SeqCst.

  • Relaxed — atomicity only, NO ordering guarantee vs other memory. Safe for a standalone counter you only read at the end (e.g. metrics). NOT safe to publish data ("store flag, then reader sees the data") — the flag store can be reordered before the data writes.
  • Acquire (loads) / Release (stores) — the workhorse. A Release store publishes all prior writes; an Acquire load that sees it observes those writes. This is what you want for lock-free publish/handoff (the "message passing" pattern). Most code that isn't a plain counter wants Acquire/Release.
  • SeqCst — single global total order; simplest to reason about, slowest. Use only when multiple atomics must agree on one global order (rare). Reaching for SeqCst "to be safe" is a performance smell, but it's the correct default if you're unsure and can't prove Acquire/Release suffices.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
COUNT.fetch_add(1, Ordering::Relaxed);          // pure counter: Relaxed is fine
DATA.store(payload, Ordering::Relaxed);
READY.store(true, Ordering::Release);           // publish: Release
// reader:
if READY.load(Ordering::Acquire) { use(DATA.load(Ordering::Relaxed)); }  // Acquire pairs with Release

Per-language:

  • Gosync/atomic operations are effectively sequentially consistent; you don't pick an ordering, but you still must use atomics (or a mutex) for any concurrent access — a plain int read/write across goroutines is a race even if "it looks atomic." Prefer atomic.Int64 (Go 1.19+) typed wrappers over the free functions.
  • Python/JS — no user-facing memory-ordering knobs (GIL / single event loop). JS SharedArrayBuffer + Atomics is the exception (shared memory across Workers) and there ordering matters again.

ABA problem (lock-free CAS): a value reads A, changes to B, back to A; a naive compare-and-swap succeeds though the world moved underneath it. Mitigate with tagged pointers / version counters (AtomicU64 epoch) or hazard pointers. When NOT to go lock-free: unless you've measured lock contention as a real bottleneck, a Mutex is simpler, correct, and usually fast enough. Lock-free code is a maintenance and correctness liability — reserve it for proven hot paths.

False sharing: two unrelated atomics/fields on the same 64-byte cache line cause cores to ping-pong ownership, silently tanking throughput. Pad hot per-thread counters to a cache line (#[repr(align(64))] in Rust, //go:align/padding in Go) when profiling shows it.

Worker Pools & Rate Limiting

Bounded concurrency = fixed workers draining a bounded channel, OR a semaphore capping in-flight tasks.

// Semaphore caps concurrency without spawning a fixed pool.
let sem = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
let tasks = urls.into_iter().map(|u| {
    let permit = sem.clone();
    tokio::spawn(async move { let _p = permit.acquire_owned().await.unwrap(); fetch(&u).await })
});
sem = asyncio.Semaphore(max_concurrent)
async def limited(u):
    async with sem:            # acquire/release even on exception
        return await fetch(u)
await asyncio.gather(*(limited(u) for u in urls))
// Worker pool: N goroutines range over a bounded jobs channel; close(jobs) to drain.
jobs := make(chan Job, 100)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); for j := range jobs { process(j) } }()
}
// producer: for _, j := range work { jobs <- j }; close(jobs); wg.Wait()
  • Fixed pool (channel + N workers) gives strict backpressure and bounded memory. Semaphore-per-task is simpler but each task still allocates until it acquires — bound the spawn rate too for huge inputs (spawn inside the semaphore, or chunk the input).
  • Rate limiting (requests/sec) is orthogonal to concurrency (max in-flight): a token bucket / time.Ticker throttles rate; a semaphore caps simultaneity. Real clients usually need both.

Gotchas Quick Reference

  • ⚠ Unbounded channel/queue = latent OOM. Default bounded.
  • ⚠ Blocking call (sync I/O, sleep, tight loop) in asyncio/JS freezes the whole loop.
  • ⚠ Lock held across .await: Rust !Send (won't spawn) or async self-deadlock; copy-out then await.
  • ⚠ Inconsistent lock order between two paths → deadlock. Total-order your locks.
  • ⚠ Mutexes are non-reentrant (Rust/Go/asyncio) — nested same-lock acquire deadlocks.
  • gather/Promise.all don't cancel siblings; use TaskGroup/JoinSet/errgroup.
  • ⚠ Cancelled/timed-out future dropped mid-flight loses in-progress work — verify cancel-safety.
  • ⚠ Go: only sender closes a channel, once; send-on-closed and double-close panic; loop-var capture (<1.22).
  • ⚠ Rust: tokio::mpsc::Receiver is single-consumer (not clonable); spawn detaches & isolates panics.
  • ⚠ Python: x += 1 isn't atomic even under the GIL; asyncio never gives CPU parallelism.
  • ⚠ Atomics: Relaxed for pure counters; Acquire/Release to publish data; SeqCst only for cross-atomic global order.
  • ⚠ Lock-free before profiling = premature; ABA and false sharing bite silently.

Verification Checklist

Before declaring concurrent code done:

  • Every channel/queue is bounded, or unboundedness is justified by an upstream rate limit
  • No lock (sync mutex) is held across an .await / blocking call; critical sections are minimal
  • Locks acquired in one consistent global order everywhere; no reentrant same-lock nesting
  • Cancellation/timeout propagates to inner ops (ctx threaded / AbortSignal passed) and dropped futures are cancel-safe
  • Spawned tasks are owned by a scope (JoinSet/TaskGroup/errgroup) — no detached, unawaited, or leaked tasks
  • Errors from tasks propagate and cancel siblings where required (not swallowed by gather/spawn)
  • Shared mutable state is guarded; atomic orderings justified (default Acquire/Release, not reflexive SeqCst/Relaxed)
  • Go: go test -race green in CI; only the sender closes channels; every WithCancel/Timeout has a defer cancel()
  • Python/JS: no blocking/CPU work on the event loop (offloaded to executor/Worker); CancelledError not swallowed
  • Shutdown path drains queues, joins workers, and closes channels cleanly (no leaked goroutines/tasks)
  • Stress-tested under load (many iterations / high concurrency), not just a single happy-path run