Chris Collins

Tools · dev

Concurrency lab

Step through concurrent programs one operation at a time, rewind, and try other interleavings: Go goroutines, channels, select, WaitGroup, Mutex and context (with a race detector, deadlocks and leaks), async Rust futures on a tokio-like executor, Java virtual threads on carriers with pinning, executors and CompletableFuture, and the JavaScript event loop — plus a runner that shows the real order of your own JS snippet.

Added

Works with

How to use it

Concurrency bugs depend on the order things happen in, and a real program won’t show you that order or let you change it. Here each program runs in a small simulator with a scheduler you control: step forward and back, let it pick at random, or choose which goroutine, task or thread runs next and watch the result change.

How to use it

  1. Pick a language and a scenario. The code is on the left; coloured tags show where each goroutine, thread or task is about to run.

  2. Step (or the → key) runs one operation; Back (←) rewinds; the slider and the list of steps at the bottom jump anywhere.

  3. Run next lists everything that could run at this point. The highlighted button is what the scheduler would pick; press another to take a different path from here. The run is recomputed from that choice on.

  4. Scheduler: random with a seed gives a different but repeatable interleaving; New seed tries another. On scenarios with a correct answer, Find a failing interleaving searches random seeds until the result is wrong.

  5. Share link copies the scenario, the scheduler, your choices and the step you are on.

The default scheduler lets the current goroutine or thread keep going until it blocks, which is often what a real one does with short critical sections, and is why races hide in testing.

Go

Goroutines, unbuffered and buffered channels (with their buffers and the queues of waiting senders and receivers drawn), close and range, select with default and time.After, sync.Mutex, sync.WaitGroup, and context.WithTimeout. When every goroutine is blocked and no timer is pending, you get Go’s own fatal error: all goroutines are asleep - deadlock!; when main returns while goroutines are still blocked, they are listed as leaked.

A happens-before race detector (vector clocks, like go run -race) watches the shared variables. It reports the data race in counter++ even on a run where the answer came out right, and reports nothing for the check-then-act example — which is still wrong, because each access is locked but the check and the act are not under the same lock.

Rust async

An async fn is a state machine; the executor polls it. The task runs until an .await isn’t ready, registers its waker and returns Pending; when the timer fires the waker puts it back on the run queue. The table shows each task’s state and how many times it has been polled, and the timeline shows which worker thread ran what.

  • join! runs futures inside one task: they take turns and never run in parallel. tokio::spawn makes separate tasks that two workers run at once.

  • A blocking call (std::thread::sleep, a big synchronous computation, blocking I/O) inside an async task holds the worker thread, so other tasks' timers fire with nobody to poll them. spawn_blocking moves it to the blocking pool.

The executor is simplified: one global FIFO run queue, no work-stealing or LIFO slot, and a poll takes no time except where the code does work.

Java

Platform threads in a fixed pool against virtual threads mounting and unmounting on carrier threads; pinning when a virtual thread blocks inside synchronized on JDK 21–23, and the same code on JDK 24+ where JEP 491 removed that; a single-thread executor that deadlocks waiting for a task queued behind itself; which thread runs each stage of a CompletableFuture chain; and an unsafe lazy initialisation that creates two objects.

JavaScript

The call stack, the microtask queue (promise reactions, queueMicrotask, and the rest of an async function after await), the task queue (setTimeout) and animation frames. The orders shown for the scenarios were checked against node’s real event loop in the tests.

Run your own snippet runs your code for real, in a sandboxed frame that can’t reach this page, and lists every console.log in your browser’s order, labelled with what was running: the script, a task, a microtask, the continuation after an await, or an animation frame.

What these are, and aren’t

They are models, written to behave like the real runtimes on these examples, not the runtimes themselves. Timings are in virtual milliseconds. `select’s random choice uses the seed. Go’s scheduler, Tokio’s and the JVM’s all make different choices from these schedulers in detail; the point is that the program has to be correct under every interleaving, and here you can reach them.

The tests in tests/tools/concurrency-lab.test.js check the outcomes: lost updates found by random search and reproduced by replaying the choices, the deadlock and leak reports, starvation timings, pinning, and the JavaScript orders against node.

  • Memory layout — what a Go channel, slice or interface, or a Rust Arc<Mutex<T>>, looks like in memory.

  • Queueing — how waiting time grows as a pool of workers fills up.

  • Latency numbers — what the operations being waited on cost.

Privacy

Nothing leaves the browser. Scenarios run in the page. A snippet you run is executed in a sandboxed frame inside this page and is kept in this browser’s local storage; it is never sent anywhere.

Runs entirely in your browser; nothing you type leaves this page.