all_lessons/SICP JavaScript/12 · Concurrency & timelesson 12 / 22

Concurrency and time

Lesson 11 gave us mutable data — set_head, mutable queues and tables, the agenda-driven circuit simulator — and one quiet warning: the rear-pointer trick worked only because exactly one process touched the queue at a time. This lesson removes that assumption. The moment two processes share a mutable cell and their steps can interleave, the order in which their operations land becomes a correctness problem. We trace the canonical bank-account race step by step, watch a deposit silently vanish, and then state precisely what "correct concurrent behavior" even means and how serializers buy it back. The deep finding: mutable state forced a notion of time back in Lesson 09, and concurrency is what drags that hidden time into the open where it can hurt you.

Book source
Maps to SICP 3.4 (concurrency: time is of the essence) — the bank-account race, the "equivalent to some serial order" correctness criterion, serializers and serialized procedures, and deadlock with the acquire-in-a-global-order fix. We follow that arc but every interleaving table, trace, and snippet here is original JavaScript; SICP's own examples use a different surface syntax.
Linear position
Prerequisite: Lesson 11 (mutable data behind a small interface — accounts and queues whose balance/pointers change in place) and Lesson 09 (assignment makes a value depend on the history of calls, not just arguments).
New capability: diagnose any concurrency bug as the same two ingredients — shared mutable state plus interleaving — state the only correctness criterion that survives (equivalent to some serial order), and reason about serializers, mutual exclusion, and the deadlock they introduce.
The plan
Five moves. (1) Why concurrency exists at all — and why it is not optional. (2) Build the canonical race: two withdrawals on one balance, and an interleaving table that loses an update. (3) State the correctness criterion — not "the right answer" but "equivalent to some serial order." (4) Serializers and mutual exclusion: how a lock forces interleavings back into legal serial orders. (5) Deadlock — the disease the cure introduces — and the global-ordering fix.

1 · Why concurrency is forced on us

So far our processes ran one step after another: a single evaluator, one expression at a time. Real systems break that single thread for two unavoidable reasons. Reflecting reality: the world is parallel — many bank customers, many sensors, many requests arrive at once, and a faithful model must let independent objects act independently. Performance: a CPU with eight cores, or a program waiting on the network, wastes time if it insists on one-at-a-time execution. So we let multiple processes run concurrently — their steps overlapping or arbitrarily interleaved in time.

For purely functional code (Lessons 01–08) this costs nothing: with no mutable state, no process can observe another's intermediate steps, so any interleaving gives the same answer. The trouble begins exactly where Lesson 09 began — with assignment. Once two processes share a mutable cell, one can read it in the middle of another's update, and now when each step happens changes the result.

concurrent ≠ parallel
Concurrent means the processes' steps may be interleaved in any order (even on one core, by a scheduler). Parallel means they literally run at the same instant on different cores. Concurrency is the broader hazard: even a single core that switches between processes exposes every race below.
the model of "time"
We treat a concurrent run as a single global sequence in which each process contributes its steps in its own internal order, but the two orders are shuffled arbitrarily. A bug exists if any legal shuffle produces a result no serial order could.
The one sentence to remember
A concurrency bug requires two things at once: state that is mutable and shared, and processes whose steps can interleave. Remove either — make the state immutable, or give each process its own copy, or forbid interleaving — and the bug is impossible. Lesson 13 attacks the first; this lesson attacks the third.

2 · The canonical race — a lost update

Take the account object from Lesson 09, stripped to its mutating core. The balance is a shared mutable cell; withdraw reads it, computes, and writes it back.

function make_account(balance) {                 // balance is the SHARED mutable cell
  function withdraw(amount) {
    const current = balance;                     // STEP read
    const next = current - amount;               // STEP compute
    balance = next;                              // STEP write
    return balance;
  }
  return withdraw;
}
const acc = make_account(100);
// Two processes share `acc` and run "concurrently":
//   Process A: acc(10)
//   Process B: acc(20)

The bug is that withdraw is not atomic: it is three separate steps (read, compute, write), and the scheduler may run a step of B between A's read and A's write. Each process holds its own private current/next, but they both target the one shared balance. If A reads 100, then B reads 100 before A has written, both compute from the same stale value and the second write clobbers the first.

Worked example — the interleaving that loses A's withdrawal
Start: balance = 100. A withdraws 10, B withdraws 20. A correct serial run leaves 100 - 10 - 20 = 70. Here is a legal interleaving that leaves 80:
timeprocessstepA's current/nextB's current/nextshared balance
t1Aread balancecurrent=100100
t2Bread balancecurrent=100current=100100
t3Acomputenext=90current=100100
t4Bcomputenext=90next=80100
t5Awrite balancenext=90next=8090
t6Bwrite balancenext=8080
B read 100 at t2 before A's write at t5, so B never saw the 90. B's write at t6 overwrites A's — A's 10-unit withdrawal is gone. The final 80 corresponds to no serial order: not A-then-B (70), not B-then-A (70). The money simply vanished. This is a lost update, the textbook race condition.

The defect is structural, not a typo. There are six ways to interleave A's three steps with B's three (preserving each process's internal order); several of them — any in which one process reads before the other writes — produce 80 or 90 instead of 70. The answer depends on scheduler timing the program never controls, so the same code gives different results on different runs. That non-reproducibility is what makes these bugs notorious.

3 · What does "correct" even mean here?

You might hope concurrency would just "give the right answer," but with truly independent processes there is often no single right answer — if A and B both deposit, the order genuinely does not matter, yet for read-modify-write it does. SICP's criterion is sharper and is the one to memorize:

The correctness criterion
A concurrent execution is correct if its result is the same as the result of running the same operations in some serial (one-at-a-time) order. Not a specific order — some order. The system is free to pick A-then-B or B-then-A, but the outcome must equal one of those, never a torn middle state like our 80.

This is exactly why the 80 is a bug and a reordering of two deposits is not: 70 is achievable by both serial orders, so any execution yielding 70 is correct; 80 is achievable by neither, so it is wrong. The criterion deliberately permits nondeterminism (you do not get to demand a particular order) while forbidding the impossible interleaved states. It is weaker than "deterministic" and that weakness is the point — demanding a single fixed order would throw away the parallelism we paid for.

executionfinal balancematches a serial order?verdict
A fully, then B fully70yes (A→B)correct
B fully, then A fully70yes (B→A)correct
A reads, B reads, A writes, B writes80noincorrect — torn
B reads, A reads, B writes, A writes90noincorrect — torn

4 · Serializers and mutual exclusion

To guarantee "equivalent to some serial order," we make the dangerous region — the whole read-compute-write of withdraw — run as one indivisible unit. A region that must not interleave with itself is a critical section; the rule that at most one process is inside it at a time is mutual exclusion. SICP packages this as a serializer: an object that wraps procedures so that no two serialized procedures from the same serializer ever overlap.

// A serializer hands back wrapped procedures that cannot run concurrently
// with each other. (mutex acquire/release are the atomic primitive; a real
// one is built from a hardware test-and-set, not from ordinary assignment.)
function make_serializer() {
  const mutex = make_mutex();
  return function serialized(fn) {
    return function (...args) {
      mutex.acquire();              // wait until no one else holds it
      try { return fn(...args); }   // critical section: runs alone
      finally { mutex.release(); }  // always hand it back
    };
  };
}

function make_account(balance) {
  const protect = make_serializer();
  const withdraw = protect(function (amount) {   // whole body is one critical section
    balance = balance - amount;
    return balance;
  });
  const deposit = protect(function (amount) {     // SAME serializer ⇒ excludes withdraw too
    balance = balance + amount;
    return balance;
  });
  return { withdraw, deposit };
}

Now re-run the race: A acquires the mutex at t1; when B tries to acquire it, B blocks until A releases. B's read cannot slip in before A's write, so the read-modify-write is atomic and the only possible outcomes are the two serial ones (both 70). Two notes that matter: (a) withdraw and deposit must share the same serializer instance, or a deposit could still interleave a withdrawal on the same balance; (b) the mutex itself can't be built from ordinary assignment — "test the lock, then set it" is itself a race — so it rests on an atomic hardware primitive (test-and-set / compare-and-swap). Serialization is how we re-impose order; the cost is that processes now wait.

5 · Deadlock — the cure has its own disease

Mutual exclusion fixes lost updates but introduces a new failure: processes can wait forever. Consider an exchange that moves the difference between two accounts and must lock both to stay atomic.

Worked example — the classic two-lock deadlock
Process P:  exchange(a1, a2)        Process Q:  exchange(a2, a1)
  acquire a1   ✔  (P holds a1)        acquire a2   ✔  (Q holds a2)
  acquire a2   …blocks (Q has it)     acquire a1   …blocks (P has it)
                ▲                                    ▲
                └──────── each waits for a lock the other holds ───────┘
                          neither can proceed or release  →  DEADLOCK
P grabbed a1 and wants a2; Q grabbed a2 and wants a1. Each holds what the other needs and neither will release first. The program is frozen with no error — the hardest kind of hang to diagnose.

The standard cure is a global ordering on the resources: give every account a unique id and require that any process acquire locks in increasing id order. Then exchange always locks the lower-numbered account first, no matter which way it was called:

function exchange(x, y) {                  // x, y have stable ids x.id, y.id
  const [first, second] = x.id < y.id ? [x, y] : [y, x];
  first.lock.acquire();                    // ALWAYS lower id first
  second.lock.acquire();
  try { /* atomic transfer between x and y */ }
  finally { second.lock.release(); first.lock.release(); }
}

Why this works: a deadlock cycle needs P waiting on Q while Q waits on P. But if everyone acquires in increasing id order, a process holding lock i only ever waits for some lock j > i. You cannot form a cycle out of strictly increasing steps — the "waits-for" graph has no loop — so deadlock becomes impossible. Ordering acquisitions does not make processes faster; it makes the cycle that deadlock requires structurally unbuildable.

Common mistakes / failure modes

"It worked on my machine"
Races are timing-dependent: the bad interleaving may surface once in a million runs, then never in testing and constantly in production. Reproducibility is not evidence of correctness — you must reason about all legal interleavings, not the ones you happened to observe.
Thinking one statement is atomic
balance = balance - amount looks like one step but is read, compute, write — three points where another process can cut in. Atomicity is something you must impose (a serializer), not something a single line of code gives you for free.
Different serializers, same data
Wrapping withdraw and deposit with two separate serializers leaves them free to interleave on the shared balance — the race is back. Mutual exclusion only excludes procedures from the same serializer instance.
Over-locking into deadlock
Adding more locks "to be safe" multiplies the chance of a hold-and-wait cycle. The fix is not more locks but a consistent global acquisition order (and holding locks for as short a region as possible).

Checkpoint exercise

Try it
Start with balance = 50. Process A runs deposit(30) (read, +30, write); process B runs withdraw(40) (read, −40, write). (1) What are the two serial results? (2) Build an interleaving table where B reads before A writes, and give its (incorrect) final balance. (3) Is your result "correct" by the criterion of §3 — why or why not? (4) If A and B used the same serializer, which results remain possible?

Answers: (1) Both serial orders give 50 + 30 − 40 = 40. (2) e.g. A reads 50; B reads 50; A writes 80; B writes 50 − 40 = 10 — final 10 (A's deposit lost); or symmetrically a final 80 if A writes last. (3) Incorrect: 10 (and 80) match no serial order — only 40 does. (4) Only 40: the same serializer forces one of the two serial orders, both of which yield 40.

Where this points next

We have a cure, but look at its shape: serializers, mutexes, deadlock-avoidance orderings — an entire apparatus whose only job is to re-impose the serial order that assignment threw away. The root disease is the pair we named in §1: mutable shared state plus interleaving in time. Lesson 11's mutation introduced the shared state; concurrency exposed the time. What if we attacked the disease instead of medicating the symptom? Lesson 13 — streams and delayed evaluation — does exactly that. By modeling a changing quantity not as one cell rewritten over time but as an immutable sequence of its successive values, it decouples the order in which we compute from the order in which events occur. A balance becomes a stream of balances; there is no cell to race on, no critical section to guard, because nothing is ever mutated. Streams cure concurrency by removing the assignment that made time matter in the first place.

Takeaway
Concurrency lets independent processes overlap — necessary to model a parallel world and to use parallel hardware — but it turns Lesson 09's hidden notion of time into a correctness hazard. Every concurrency bug is shared mutable state meeting interleaving: the canonical bank race is a read-compute-write that is not atomic, so a second process reading the stale balance produces a lost update (final 80 where every serial order gives 70). The only workable correctness criterion is equivalent to some serial order — it permits nondeterministic ordering but forbids torn middle states. Serializers enforce it with mutual exclusion around the critical section (built on an atomic test-and-set, sharing one serializer per protected datum), at the price that processes must wait — and waiting on multiple locks risks deadlock, cured by acquiring locks in a fixed global order so the waits-for graph can never cycle. All of this is machinery to re-impose the order assignment destroyed; Lesson 13 removes the assignment instead.

Interview prompts