How it works
The explanation, with examples you can run.
JavaScript runs one piece of code at a time, but an await is a point where other tasks can run. That is enough for a lost update. In the lesson below, two withdrawals each read the balance, pause (as a database call would), then write their result.
Watch it run
Play it or step through. Every step says what just happened.
let balance = 100;const pause = () => new Promise(resolve => setTimeout(resolve, 0));async function withdraw(amount) { const seen = balance; await pause(); balance = seen - amount;}Promise.all([withdraw(30), withdraw(50)]).then(() => console.log(balance));Task A: withdraw(30)
What A has read or written
Task B: withdraw(50)
What B has read or written
Shared balance
Timer queue
Paused tasks waiting to resume
Console0 lines
Nothing has run yet. Two withdrawals of 30 and 50 should leave 20.
Real threads can interleave between any two machine instructions, not only at await, so the same bug is even easier to hit.
Real threads can interleave between any two machine instructions, not only at await, so the same bug is even easier to hit.
Try it yourself
Change the code, predict the order, then run it for real in your browser.
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.
Fixing it
There are three families of fixes, in every language:
- Don't share. Give each piece of state one owner, and send it messages (actors, channels, a single-writer queue).
- Make the critical section exclusive. A lock (mutex) lets only one thread at a time run the read-modify-write. In JavaScript, the simplest fix here is to remove the
awaitbetween read and write:balance -= amountruns in one uninterrupted step. Across processes or machines, the database does this for you with a transaction,SELECT … FOR UPDATE, or a conditionalUPDATE … WHERE balance >= 30. - Use an atomic operation. CPUs provide instructions that read, modify and write a memory location in one indivisible step: atomic add, exchange and compare-and-swap (CAS). Java's
AtomicLong, C#'sInterlocked, Go'ssync/atomicand JavaScript'sAtomicson aSharedArrayBufferexpose them.
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
This prints 20: the second withdrawal can't start its read until the first has written.
Deadlock
Locks bring their own failure. Coffman and colleagues showed in 1971 that deadlock requires four conditions at once: mutual exclusion (a resource can be held by one party), hold and wait (a party holds one resource while waiting for another), no preemption (nobody can take a resource away), and circular wait (A waits for B, which waits for A). Break any one and deadlock can't happen. In practice: acquire locks in one global order, hold as few as possible for as short as possible, use timeouts, and never call unknown code (callbacks, I/O) while holding a lock.