Skip to content
Software Atlas

Races & Deadlocks

Why concurrent code gives different answers on different runs, why it sometimes stops forever — and the tools and habits that find and prevent both, in Go, JavaScript and the database.

⏱ ~45 min read14 sections7 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA race condition is a bug where the result depends on who gets there first. A deadlock is a bug where everyone is waiting for someone else, so nothing ever happens again.

Whenever two things run concurrently — two goroutines, two async functions, two HTTP requests, two servers — their steps get mixed together in some order. Usually you can't control that order, and it changes from run to run. A Race condition is code that's only correct for some of those orders.

Too much milk: a race condition with no computers involved
you
fridge: no milk
walking to the shop…
buy milk
walking home
🥛 put away
roommate
fridge: no milk
walking to the shop…
buy milk
🥛🥛 two!
Each roommate's logic is perfectly reasonable on its own. The bug only exists in the overlap: both checked before either acted.

Programs do exactly this. Two requests both check "is this username taken?", both see "no", and both create the account. Two goroutines both read count = 5, both add 1, and both write 6. You'll see a dozen real versions of this bug on this page.

Three families of concurrency bugs

≠

Race condition

Wrong answer, sometimes. Usually right, so tests pass. Then one day, under load, two orders get the same ticket. §02–§06.
∞

Deadlock

Stuck forever. Each task holds something the other needs and waits for it. CPU drops to 0%, requests hang. §07–§08.
↻

Livelock & starvation

Busy, but going nowhere — or one task never gets its turn while the others are fine. §09.

All three come from the same root: several tasks sharing something (a variable, a lock, a database row, a file) without agreeing on the order they use it in. And all three are timing-dependent: they hide on your laptop and appear in production, under real load, at the worst moment.

Foundations§02

Why count++ isn't one step

Here's the smallest race there is. Ten goroutines each add 1 to a shared counter a thousand times. The answer should be 10,000:

counter.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    count := 0
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                count++ // ← ten goroutines, one variable, no protection
            }
        }()
    }
    wg.Wait()
    fmt.Println(count) // 10000? On a multi-core machine: often less, and different every run
}

Run it a few times and you'll typically see numbers like 6,412, then 8,907, then 10,000 (exact values depend on your machine). Increments are going missing. (wg is a sync.WaitGroup: it makes main wait until all ten goroutines call Done().)

count++ is really three steps

You write one line, but it isn't indivisible: it's a Read-modify-write, and another goroutine can act between its parts. Logically, it does:

  1. Read count from memory into a private scratch slot (call it tmp).
  2. Add 1 to tmp.
  3. Write tmp back to count.

Each goroutine has its own tmp. If two goroutines both read before either writes, they both compute the same new value, and one increment is lost. Whether that happens depends on the Interleaving — the order in which the two goroutines' steps get mixed. Go's scheduler (the part of the Go Runtime that decides which goroutine runs when, and on which core) decides that, not you. Below, you decide:

You are the scheduler
Two goroutines each run count++, which the CPU really does in three steps. Click which goroutine runs its next step, in any order you like.
G1 · count++tmp = —
▶ tmp := count
tmp = tmp + 1
count = tmp
G2 · count++tmp = —
▶ tmp := count
tmp = tmp + 1
count = tmp
shared memory
count
0
order so far:nothing yet
Pick a goroutine to start. Try G1, G1, G1, G2, G2, G2 first — then try G1, G2, G1, G2, G1, G2.

Two things to notice:

  • Most orders are wrong. Click "show all possible orders": only 2 of the 20 give 2. On a machine with one CPU core, the bug is still fairly rare, because a goroutine usually runs a long stretch before being switched out. But on a multi-core machine (nearly every laptop and server today), G1 and G2 run on different cores at the very same instant, so their steps genuinely overlap — no switching needed. That's why the counter program above loses thousands of increments.
  • The lock removes the bad orders. With the mutex, G2 can't start reading until G1 has written and unlocked. The three steps become a Critical section: a stretch only one goroutine can be in at a time.

Two fixes in Go

var mu sync.Mutex // one lock guarding count

go func() {
    defer wg.Done()
    for j := 0; j < 1000; j++ {
        mu.Lock()   // wait here until no one else holds the lock
        count++     // read, add, write — nobody can squeeze in
        mu.Unlock() // let the next goroutine in
    }
}()

And in JavaScript?

The same loop in JS is always correct: one thread runs your code, and it never stops in the middle of synchronous code. But add an await and the three steps can be pulled apart again:

let count = 0;
await Promise.all(
  Array.from({ length: 10 }, async () => {
    for (let j = 0; j < 1000; j++) count++; // nothing can interrupt this loop
  })
);
console.log(count); // 10000, always

count += await getOne() is short for count = count + await getOne(), and JavaScript evaluates left to right: it reads count (0), then pauses at the await. All ten calls read 0 before any of them resumes, so each writes 0 + 1. Fix: const n = await getOne(); count += n; — now the read and the write sit together after the await, with no pause between them.

Practitioner§03

Data races vs race conditions

People say "race" for two different bugs. Telling them apart matters, because the tools that catch one are blind to the other.

D

Data race

A memory-level bug. Two goroutines touch the same variable, at least one writes, and nothing orders the two accesses (no lock, no channel). Needs memory shared between threads or goroutines.
R

Race condition

A logic bug. The program's result depends on the order things happened in. Can happen anywhere: goroutines, async JS, two servers, two browser tabs.
⚖

The overlap

count++ with no lock is both. But each can exist without the other — and that's the interesting part.
Two different bugs that share a name
wrong result possible
(race condition)
order doesn't change
the intended result
data race
Both
Go: many goroutines run count++ with no lock. Memory is touched unsafely and the total comes out wrong.
go test -race finds it ✓
Data race only
Go: two goroutines both set ready = true, with no lock. Whichever writes last, the value is true — but it's still a data race, and in Go a data race means anything may happen. Fix it anyway.
go test -race finds it ✓
no data race
Race condition only
Every access is locked — but “is the seat free?” and “book it” are two separate locked steps. Or JS: a check, then await, then act.
No tool finds it ✗ — only careful design and tests
Neither
The check and the act happen inside one lock, one atomic operation, or one SQL statement.
Correct ✓
Rows: is shared memory touched without synchronization? Columns: can a different order give a wrong result? The bottom-left box is the one tools can't see; the top-right one is still a bug in Go.

A data race, defined precisely

A Data race needs all three of:

  1. Two goroutines (or threads) access the same memory location,
  2. at least one of the accesses is a write, and
  3. no synchronization orders them — no mutex both of them use, no channel send/receive between them, no WaitGroup.

"Orders them" has a precise name: Happens-before. If goroutine A unlocks a mutex and goroutine B locks it afterwards, everything A did before the unlock happens before everything B does after the lock. With no such link, the two accesses are "concurrent" — even if, on the clock, they were a second apart, and even on a single core. A data race is defined by that missing order, not by actual timing.

Why "it's just a bool" is still a bug

This looks harmless — one goroutine sets a flag, another waits for it:

var done bool

go func() {
    doWork()
    done = true // write…
}()

for !done { // …read, with nothing ordering the two
}
fmt.Println("finished")

It's a data race, and it can genuinely hang. With no synchronization, the compiler is allowed to assume no other goroutine changes done — so it may legally turn your loop into this:

✕ What you wrote
for !done { // you meant: re-read done every time round
}
✓ What the compiler may legally run
if !done {
    for { // read done ONCE… then spin forever
    }
}

CPUs also keep values in per-core caches, so even without that rewrite, nothing guarantees when the write becomes visible to the other core. Synchronization is what forces "re-read it, and see the latest write":

var done atomic.Bool // sync/atomic, Go 1.19+

go func() {
    doWork()
    done.Store(true)
}()

for !done.Load() { // re-reads every loop ✓ (but burns CPU while waiting)
}

A race condition with no data race

Now the opposite: every access is properly locked, the race detector is happy — and the code is still wrong.

type Seats struct {
    mu     sync.Mutex
    booked map[string]bool
}

func (s *Seats) IsFree(seat string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    return !s.booked[seat]
}

func (s *Seats) Book(seat string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.booked[seat] = true
}

// In each request handler:
if seats.IsFree("12A") { // check  (lock, read, unlock)
    seats.Book("12A")    // act    (lock, write, unlock)  ← someone else may have booked in between
}

Each method is safe on its own. The bug is the gap between the two calls, when the lock isn't held. That's a Check-then-act race: a logical race condition. No tool will flag it. The fix is to make the check and the act one critical section:

// TryBook books the seat only if it's free, and reports whether it did.
func (s *Seats) TryBook(seat string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    if s.booked[seat] {
        return false
    }
    s.booked[seat] = true // check and act under ONE lock
    return true
}

JavaScript: no data races, plenty of race conditions

Ordinary JavaScript has no data races at all: one thread runs your code, so two lines can never touch a variable at the same instant. (The exception is SharedArrayBuffer memory shared between worker threads — rare in everyday code.)

But JS has every kind of logical race, because each await is a point where other code runs. If you checked something before an await, it may be false after it. The withdraw race on the async/await page is the classic example; §04 shows the ones you'll meet at work.

Practitioner§04

Logical races in the wild

Logical race conditions all share one shape: look at something, then act on what you saw — with a gap in between where the world can change. Here are the ones you'll meet most. You already know read-modify-write from §02; the other three get a subsection each below, from inside one process to across the whole internet.

PatternThe checkThe gapThe act
Read-modify-writeread countanother write landswrite count + 1
Check-then-act"is it in the cache?"an await"put it in the cache"
TOCTOU (files)"does the file exist?"another process creates itcreate / write the file
Double submit"the user clicked buy"the user clicks againcreate an order

1. Check-then-act across await: the duplicate fetch

A cache that's supposed to fetch each user once:

const cache = new Map();

async function getUser(id) {
  if (!cache.has(id)) {                  // check
    cache.set(id, await fetchUser(id));  // act — but only AFTER an await
  }
  return cache.get(id);
}

When a page renders, three components call getUser(7) at almost the same moment. Each call checks the cache before any fetch has finished:

Check-then-act across await: three callers, three fetches
caller 1
has? no
await fetchUser(7)
set
caller 2
has? no
await fetchUser(7)
set
caller 3
has? no
await fetchUser(7)
set
All three checks run before the first fetch returns, so all three see an empty cache.

Three requests instead of one. Under real traffic, a popular key expiring can send thousands of identical requests to your database at once — a cache stampede. The fix is to make the check and the act synchronous by caching the promise, not the value:

const cache = new Map();

function getUser(id) {
  let p = cache.get(id);
  if (!p) {
    p = fetchUser(id);                 // start the fetch, don't await it
    cache.set(id, p);                  // check + act with no await between ✓
    p.catch(() => cache.delete(id));   // don't keep failures forever
  }
  return p;                            // every caller awaits the same promise
}

singleflight only merges calls that are in flight at the same time; it doesn't store results. Put a normal cache in front of it for that.

2. TOCTOU: files that change under you

TOCTOU is check-then-act where the thing you checked lives outside your program — usually a file. Here the "other side" isn't another goroutine: it's another process, or another copy of your server, which no lock in your program can stop.

TOCTOU: the file changed between the check and the use
process A
exists(x)? no
…a few µs…
write x ✗ clobbers B
process B
create x ✓
Process A's check was true when it ran. By the time A acts on it, it's false — and A overwrites B's file.
✕ Don't
import { existsSync, writeFileSync } from "node:fs";

if (!existsSync(path)) {      // check…
  writeFileSync(path, data);  // …use. Someone may have created it in between.
}
✓ Do
import { writeFileSync } from "node:fs";

try {
  writeFileSync(path, data, { flag: "wx" }); // create, but FAIL if it exists
} catch (err) {
  if (err.code !== "EEXIST") throw err;
  // someone else got there first — handle it
}
Go: the same fix
import (
    "errors"
    "io/fs"
    "os"
)

f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, fs.ErrExist) {
    return errAlreadyExists // someone else created it first — stop here
}
if err != nil {
    return err
}
defer f.Close()
// … write to f …

The pattern is "don't ask, just do it, and handle the failure." The wx flag (Node) and O_EXCL (Go) tell the Operating system (OS) / kernel to check and create in one indivisible step. The OS owns the file system, so it's the only one that can do both at once.

3. The double-submitted form

A user clicks "Pay", nothing seems to happen, so they click again. Or their phone loses signal and the browser retries. The server receives two valid requests:

Double submit: one intent, two orders
browser
click
slow…
click!
waiting for answers
request 1
POST /orders
order #41
charge card
request 2
POST /orders
order #42 ✗
charge again ✗
The server sees two perfectly valid requests. Nothing tells it they are the same click — unless the client sends an idempotency key.

Disabling the button after the first click helps, but it can't stop a retry, a second tab, or a flaky network. The real fix is on the server: give each intent to buy a unique ID — an Idempotency key — and let the database refuse the second one.

// Create the key when the checkout form OPENS — not on each click.
const idempotencyKey = crypto.randomUUID();

payButton.addEventListener("click", async () => {
  payButton.disabled = true; // nice for the user, not enough on its own
  await fetch("/orders", {
    method: "POST",
    headers: { "Idempotency-Key": idempotencyKey, "Content-Type": "application/json" },
    body: JSON.stringify(order),
  });
});

The UNIQUE constraint turns "is this a repeat?" + "create the order" into one step that the database guarantees. Notice where the charge sits: after the insert, and only for the request whose insert won. Retrying is now Idempotent: doing it twice has the same effect as doing it once.

The common cure

Every fix on this page moves the check and the act into one indivisible step, owned by whoever owns the data:

Data lives in…One-step tool
A Go variablea mutex, or sync/atomic
A JS variablesynchronous code — no await between check and act
A fileO_EXCL / "wx" (let the OS check)
A database rowone SQL statement, a UNIQUE constraint, or a transaction that locks the row with SELECT … FOR UPDATE (§05) — a plain transaction is not enough
Practitioner§05

Two requests, one row: lost updates

Every web server handles requests concurrently: Go runs each request in its own Goroutine, and Node interleaves them at every await. So when two requests change the same database row, you have count++ again (§02) — just with the database as the shared memory:

The trapA mutex in your server doesn't help. You run three copies of the server, and they share one database. The fix has to live in the database.

Step through all five versions of buy(). The first two are buggy — yes, including the one wrapped in a transaction. The last three are fixes.

Two requests, one row
Alice and Bob buy the same mug at the same moment. Step through each version of buy(): ① and ② are buggy, ③–⑤ are fixes.
Code
1async function buy(id) {
2 const { rows } = await db.query(
3 "SELECT stock FROM products WHERE id = $1", [id]);
4 const stock = rows[0].stock;
5 if (stock < 1) throw new Error("sold out");
6 await db.query(
7 "UPDATE products SET stock = $1 WHERE id = $2",
8 [stock - 1, id]);
9}
10
11buy(1); // Alice
12buy(1); // Bob
Alice's request
not started
Bob's request
not started
products row id = 1
stock = 5
The shop has 5 mugs. Alice and Bob click “buy” at the same moment, so the server runs buy(1) twice, concurrently. After both, stock should be 3.
1 / 5

Tab ① is a Lost update: both requests read 5, both compute 4 in their own code, and the second write erases the first. Note there's no data race anywhere — the database is perfectly safe to use concurrently. It's a pure logical race.

Tab ② is the trap most people fall into: wrapping the same code in BEGIN … COMMIT (a Database transaction) does not fix it at PostgreSQL's default settings. A transaction makes the second writer wait for the first, but it doesn't make it re-read — it still writes the number it computed from the old value.

Fix 1: let the database do the arithmetic

If the change can be written in SQL, do it in one statement (tab ③). The database locks the row while that statement runs (and until COMMIT, if it's inside a transaction), so the read and the write can't be separated:

const { rowCount } = await db.query(
  `UPDATE products SET stock = stock - 1
   WHERE id = $1 AND stock >= 1`,   // check and act in ONE statement
  [id]
);
if (rowCount === 0) throw new Error("sold out");

This is the best fix when it fits: no extra round trips, no retries, no locks held in your code. The WHERE … stock >= 1 is the check; the SET is the act; rowCount tells you which way it went.

Fix 2: optimistic locking with a version column

Sometimes the new value must be computed in your code — a document edit, a price with business rules. Then use Optimistic locking: add a version column, and make every write say "only if the version is still the one I read":

UPDATE docs
SET body = $1, version = version + 1
WHERE id = $2 AND version = $3   -- $3 = the version we read earlier

If someone else wrote in between, the version has moved on, no row matches, and you get rowCount 0. Then re-read and retry (tab ④), or — for a human editing a document — answer with HTTP 409 Conflict (the status code for "your change conflicts with the current state") and show "someone else changed this; here's the new version". Many ORMs build this in (it's often called a "version" or "concurrency token" field).

Fix 3: lock the row first (SELECT … FOR UPDATE)

The pessimistic option (tab ⑤): inside a transaction, read the row and lock it with a Row lock. Other transactions that want to lock or change that row wait until you commit. Compared with tab ②, the second buyer now waits before reading, so it reads the fresh value.

const client = await db.connect();     // a transaction needs ONE connection
try {
  await client.query("BEGIN");
  const { rows } = await client.query(
    "SELECT stock FROM products WHERE id = $1 FOR UPDATE", [id]); // 🔒 until COMMIT
  if (rows.length === 0) throw new Error("no such product");
  if (rows[0].stock < 1) throw new Error("sold out");
  await client.query(
    "UPDATE products SET stock = $1 WHERE id = $2", [rows[0].stock - 1, id]);
  await client.query("COMMIT");        // releases the row lock
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
} finally {
  client.release();                    // give the connection back to the pool
}

Which one?

FixUse whenCost
One UPDATE … SET x = x - 1 WHERE …The change fits in SQLCheapest. Use it whenever you can.
Version column (optimistic)Logic lives in code; conflicts are rare; users edit thingsA retry or a 409 when conflicts happen
SELECT … FOR UPDATE (pessimistic)Logic lives in code; conflicts are common (a hot row)Others wait; can deadlock (§10)
Practitioner§06

Go's race detector

Data races are hard to find by reading code and nearly impossible to reproduce on purpose. So Go ships a tool that finds them for you: the Race detector. You turn it on with one flag:

go test -race ./...     # run all tests with race detection  ← the one to put in CI
go run -race .          # run a program with race detection
go build -race          # build a race-detecting binary (e.g. for a test server)

What it does, in plain words

With -race, the compiler adds a tiny check around every memory read and write in your program. While the program runs, the detector keeps a logbook:

  • for each piece of memory, the last few goroutines that touched it (and whether they read or wrote), and
  • for each goroutine, which synchronization events it has "seen": mutex unlocks it came after, channel messages it received, wg.Wait()s that returned.

On every access, it asks: is this access ordered after the previous one by some chain of locks, channels or WaitGroups? If not, and one of them is a write, that's a data race — the precise definition from §03 — and it prints a report.

Reading the report

Here's a tiny program with the count++ race from §02, using just two goroutines:

main.go
package main

import (
    "fmt"
    "sync"
)

func main() {
    count := 0
    var wg sync.WaitGroup
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++ // ← line 15: the race
        }()
    }
    wg.Wait()
    fmt.Println(count)
}

go run -race . prints something like the report below. Step through it:

Reading a race report, block by block
This is the real shape of what go run -race prints for the counter program above.
Code
1==================
2WARNING: DATA RACE
3Read at 0x00c000012128 by goroutine 8:
4 main.main.func1()
5 /app/main.go:15 +0x7c
6
7Previous write at 0x00c000012128 by goroutine 7:
8 main.main.func1()
9 /app/main.go:15 +0x8e
10
11Goroutine 8 (running) created at:
12 main.main()
13 /app/main.go:13 +0x7a
14
15Goroutine 7 (finished) created at:
16 main.main()
17 /app/main.go:13 +0x7a
18==================
192
20Found 1 data race(s)
21exit status 66
your code
—
goroutines involved
—
The detector prints one block like this per race it finds, framed by ==================. Seeing WARNING: DATA RACE means: two goroutines touched the same memory and nothing (no lock, no channel, no WaitGroup) ordered those two accesses. It is never a false alarm.
1 / 5

The habit that makes reports easy: find the two line numbers (here both are line 15), then ask "which lock or channel was supposed to order these two?" Usually the answer is "none — I forgot one".

What it costs

Every memory access now does extra bookkeeping. Go's documentation says to expect roughly 2–20× slower execution and 5–10× more memory (approximate; it depends heavily on the program). That's fine for tests and for a test server getting realistic traffic; too expensive for most production servers.

What it can't find

The detector is precise — when it reports a race, the race is real — but it only sees what actually happens during that run:

🙈
Code that didn't runIt watches executions, it doesn't analyze source. A race in a code path your tests never take stays hidden. Tests must actually run things concurrently.
🧠
Logical racesCheck-then-act with proper locks (§03), lost updates in the database (§05), every JS await race: there's no unsynchronized memory access, so there's nothing to report.
🍀
Orders that happened to be safeIf, in this run, the two accesses were ordered by some unrelated lock — say, both goroutines happened to call log.Printf, which uses a mutex inside — the detector sees them as ordered and stays quiet. Adding a log line can hide a race from it.
🔒
DeadlocksIt's a race detector, not a deadlock detector. A program that hangs forever under -race just hangs (see §11).

And JavaScript?

There's no race detector for JavaScript, because ordinary JS has no data races to detect (§03). Its races are all logical, so they're found the way logical races always are: by design (no await between check and act; one SQL statement) and by tests that force the bad order. For example, make a fake fetchUser that doesn't resolve until the test says so, start two getUser(7) calls, then resolve it — and assert it was called once.

cache.test.js
import { test } from "node:test";
import assert from "node:assert/strict";

test("concurrent getUser calls share one fetch", async () => {
  let calls = 0, release;
  const gate = new Promise((r) => (release = r));       // stays pending until release()
  const fetchUser = async (id) => { calls++; await gate; return { id }; };
  // makeCachedGetUser: the §04 cache, written to take fetchUser as a parameter
  // so the test can pass in this fake.
  const getUser = makeCachedGetUser(fetchUser);

  const both = Promise.all([getUser(7), getUser(7)]);   // start both BEFORE anything resolves
  release();                                            // now let the fetch finish
  await both;
  assert.equal(calls, 1);                               // fails on the buggy version: 2
});

The test controls the timing, so the bad Interleaving happens on every run instead of once a month in production.

Foundations§07

Deadlock: everyone waits forever

In one sentenceA deadlock is a circle of waiting: A waits for something B holds, B waits for something A holds, and neither will let go first. Nothing crashes. Everything just stops.

The classic: two locks, opposite order

A bank transfer locks both accounts, so no one else changes either balance mid-transfer. Each account has its own mutex:

type Account struct {
    mu      sync.Mutex
    balance int
}

func transfer(from, to *Account, amount int) {
    from.mu.Lock()            // 1. lock the source…
    defer from.mu.Unlock()
    to.mu.Lock()              // 2. …then the destination  ← can wait forever
    defer to.mu.Unlock()
    from.balance -= amount
    to.balance += amount
}

// At the same moment, two requests arrive:
go transfer(alice, bob, 10)  // locks alice, then wants bob
go transfer(bob, alice, 5)   // locks bob, then wants alice

Most of the time one transfer finishes before the other starts, and all is well. But if both run step 1 before either reaches step 2:

  • transfer #1 holds alice and waits for bob;
  • transfer #2 holds bob and waits for alice.

Neither can continue, so neither ever unlocks. Every later request touching alice or bob queues up behind them, forever.

Five philosophers, five forks

Computer scientists tell this story with the dining philosophers. Five philosophers sit at a round table with one fork between each pair. To eat, a philosopher needs both forks next to them. Each one follows a simple rule: pick up the left fork, then the right fork, eat, put both down.

Play with it. Then press "Everyone grabs at once":

The dining philosophers
Five philosophers, five forks. Each needs the two forks next to them to eat. Click a philosopher to give them a turn, or let it run.
0 meals eaten01234P0thinkingmeals 0P1thinkingmeals 0P2thinkingmeals 0P3thinkingmeals 0P4thinkingmeals 0
Rule: pick up your left fork first, then the other. Hold the first while waiting for the second. Eat, then put both down.
Try “everyone grabs at once”. Then switch on the fix and try it again.

When everyone picks up their left fork at the same moment, every fork is taken and everyone is waiting for their right fork — which their neighbour is holding. A closed circle of waiting. The red arrows show it.

The fix: everyone agrees on one order

Switch on "fix: lower number first". Now each philosopher picks up their lower-numbered fork first. For four of them that's still the left fork. But P4 sits between forks 4 and 0, so P4 now reaches for fork 0 first — the same fork P0 wants first. They compete for it, one of them gets it, and the circle can't close: someone always ends up with two forks.

That's Lock ordering, and it's the most common deadlock fix in real code. For the bank:

func transfer(from, to *Account, amount int) {
    if from == to {
        return // locking the same mutex twice would deadlock on its own
    }
    first, second := from, to
    if to.id < from.id { // always lock the account with the smaller ID first
        first, second = to, from
    }
    first.mu.Lock()
    defer first.mu.Unlock()
    second.mu.Lock()
    defer second.mu.Unlock()
    from.balance -= amount
    to.balance += amount
}

Now both transfers between alice (id 1) and bob (id 2) lock alice first. Whichever gets alice first proceeds; the other simply waits for it to finish. Waiting, but not circular waiting.

Practitioner§08

The four conditions — break any one

In 1971, Edward Coffman and colleagues showed that a Deadlock can only happen when four conditions hold at the same time. They're now called the Coffman conditions. This is more useful than it sounds: it means you never need a clever fix — you just need to make one of the four impossible.

The four conditions — a deadlock needs all of them at once
G1AG2✕
1Mutual exclusion
A resource can be held by only one task at a time. (That's the whole point of a lock.)
AG1Bholdswants
2Hold and wait
A task keeps what it already holds while it waits for more.
G1AG2can't snatch it
3No preemption
Nobody can take a resource away from its holder. Only the holder can let go.
G1BG2A
4Circular wait
A closed loop: G1 waits for G2, G2 waits for G1 (or a longer ring).
Circles are goroutines, squares are locks. Solid arrow: the lock is held by that goroutine. Dashed arrow: the goroutine is waiting for that lock.

See all four at once

Below, three goroutines each hold one lock. You choose what each one is waiting for. The picture is a Wait-for graph: solid grey arrows mean "this lock is held by that goroutine", dashed arrows mean "this goroutine is waiting for that lock".

Find the cycle
Each goroutine already holds one lock. Choose what each one is waiting for. A deadlock is a closed loop of arrows.
G1G2G3🔒A🔒B🔒C
G1 holds A, wants
G2 holds B, wants
G3 holds C, wants
● holdsMutual exclusion — each lock has exactly one holder
● holdsHold and wait — someone holds a lock and waits for another
● holdsNo preemption — sync.Mutex can't be taken away
● holdsCircular wait — there's a loop
💀 Deadlock. All four conditions hold. The loop: G1 → B → G2 → C → G3 → A → G1. Every goroutine in it waits for another one in it.

Try these:

  1. The full ring (the starting setup): G1 → B → G2 → C → G3 → A → G1. Deadlock.
  2. Set G3 to "nothing". The ring opens. G3 finishes, releases C, which lets G2 finish, which lets G1 finish.
  3. A two-goroutine loop: G1 wants B, G2 wants A. That's the bank transfer from §07.
  4. Switch on "enforce A → B → C". Now try to build any loop. You can't: every arrow points forward in the order, so none can lead back.

Notice that mutual exclusion and no preemption hold in every case, and hold-and-wait holds whenever anyone is waiting. The deadlock appears and disappears with circular wait alone — so breaking that one condition is enough.

Breaking each condition

ConditionBreak it by…In practice
1. Mutual exclusionNot sharing the thing at allGive data a single owner goroutine and send it messages (channels); use immutable data; use an atomic for a single counter.
2. Hold and waitNever waiting while holdingUse one coarser lock instead of several (e.g. one lock for "all accounts" instead of one per account), so there's never a second lock to wait for; release your lock before calling something slow or unknown.
3. No preemptionLetting a held lock be taken backYou can't snatch a lock from another goroutine, but a goroutine can give up its own: try-lock, or a lock wait with a timeout — if you can't get the next lock, release what you hold and retry later.
4. Circular waitA global orderLock ordering: always take locks in the same order (by ID, by name, by level). The most common fix.

What "giving up" looks like in code

Both snippets below break condition 3, no preemption: when a goroutine can't get what it needs, it lets go of what it already holds instead of waiting forever.

Go's sync.Mutex has no timeout, but it has TryLock (Go 1.18+): take the lock only if it's free right now. (The Go docs warn that correct uses of TryLock are rare — reach for lock ordering first.) For a lock with a deadline, use a weighted Semaphore, which accepts a context.Context:

import "golang.org/x/sync/semaphore"

lock := semaphore.NewWeighted(1) // a lock with room for 1 holder

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := lock.Acquire(ctx, 1); err != nil {
    return fmt.Errorf("gave up waiting for the lock: %w", err) // no deadlock: we stopped waiting
}
defer lock.Release(1)
// … critical section …
Practitioner§09

Livelock and starvation

A Deadlock is the loud failure: everything stops. Its two cousins are quieter, because the program is visibly doing something.

∞

Deadlock

Everyone waits. CPU ~0%. No progress for anyone.
↻

Livelock

Everyone keeps reacting to everyone else. CPU busy. No progress for anyone.
⌛

Starvation

The system makes progress — but one task never gets a turn.

Livelock: too polite to proceed

A Livelock often comes from a deadlock "fix". Take the two-lock transfer from §07 and make it polite: if the second lock is busy, release the first and try again.

for {
    a.mu.Lock()
    if b.mu.TryLock() { // got both → done
        break
    }
    a.mu.Unlock()                     // polite: give back what we hold…
    time.Sleep(10 * time.Millisecond) // …wait, and try again
}
// (the other goroutine runs the same loop with a and b swapped)

There's no deadlock any more — nobody waits forever while holding a lock. But if two goroutines run this loop with the locks in opposite order, they can stay in lockstep: both lock, both fail, both release, both sleep exactly 10 ms, both lock again…

Livelock vs the same code with jitter
fixed back-off (10 ms)
G1: A then B
lock
try ✗
free
sleep
lock
try ✗
free
sleep
lock
try ✗
free
sleep
G2: B then A
lock
try ✗
free
sleep
lock
try ✗
free
sleep
lock
try ✗
free
sleep
random back-off (jitter)
G1: A then B
lock
try ✗
free
lock
got B ✓
work, unlock
G2: B then A
lock
try ✗
free
sleep (longer)
waits for B
lock
got A ✓
work
Top: both goroutines back off for exactly the same time, so they collide again and again — busy forever, zero progress. Bottom: each sleeps a random amount, they drift apart, and one gets both locks.

The fix is randomness. If each one sleeps a random amount — Jitter — they drift apart, and one gets through:

time.Sleep(time.Duration(rand.IntN(20)) * time.Millisecond) // math/rand/v2 (Go 1.22+): 0–19 ms

The same idea protects retries everywhere: database transactions retried after a deadlock (§10), clients retrying a failed server. Without jitter, everyone retries at the same instant and collides again (a Thundering herd). Better still, use lock ordering (§08) and you never need the retry loop at all.

Starvation: never your turn

Starvation means one task waits indefinitely while others keep going. Nothing is "stuck" in the deadlock sense — the lock is constantly being used, just never by you. Three places it shows up:

1. Readers starving a writer. A read-write lock lets many readers in at once, but a writer needs everyone out. If readers keep arriving so there's never a moment with zero readers, a naive implementation makes the writer wait forever:

Starvation: readers keep the lock busy, the writer never gets in
reader 1
read
read
read
reader 2
read
read
read
reader 3
read
read
read
writer
waiting for zero readers… still waiting… still waiting…
No single reader holds the lock for long, but there is never a moment with zero readers. A naive read-write lock lets new readers join, so the writer waits forever. (Go's sync.RWMutex prevents this: once a writer is waiting, new readers queue behind it.)

Go's sync.RWMutex avoids this: once a writer calls Lock(), new RLock() calls wait behind it. (Pitfall #6 in §12 shows the surprising deadlock this rule can cause.)

2. A greedy goroutine. If goroutine A unlocks and immediately locks again in a loop, it often wins the race against waiting goroutine B, because A is already running on a CPU while B has to be woken up first. Since Go 1.9, sync.Mutex has a starvation mode: once a waiter has waited more than 1 ms, the mutex is handed directly to the longest waiter, first-come first-served, until the queue drains.

3. Hogging the event loop (JS). In Node, one request doing 2 seconds of synchronous work (a huge JSON.parse, a slow regex) starves every other request for those 2 seconds — they're all waiting for the one thread. A promise chain that keeps scheduling more promise callbacks starves timers and I/O the same way (microtask starvation).

Telling them apart

DeadlockLivelockStarvation
CPUNear 0%BusyBusy
ProgressNoneNoneOthers yes, one task no
Typical signRequests hang; goroutine dump shows everyone waiting on locksRetry/conflict logs flood; nothing completesSome requests time out while most succeed
Typical fixLock orderingJitter, or remove the retry loop with lock orderingFair queueing; shorter critical sections; don't block the event loop
Staff-level§10

Deadlocks in the database

Databases lock rows too. Every UPDATE locks the rows it changes until the Database transaction ends, and SELECT … FOR UPDATE (§05) locks the rows it reads. So the bank-transfer Deadlock from §07 happens in SQL exactly the same way:

-- transaction 1: move 10 from account 1 to account 2
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;  -- 🔒 row 1
UPDATE accounts SET balance = balance + 10 WHERE id = 2;  -- wants row 2
COMMIT;

-- transaction 2, at the same time: move 5 from account 2 to account 1
BEGIN;
UPDATE accounts SET balance = balance - 5 WHERE id = 2;   -- 🔒 row 2
UPDATE accounts SET balance = balance + 5 WHERE id = 1;   -- wants row 1
COMMIT;
Two transfers, opposite order — inside PostgreSQL
tx 1: 1→2
BEGIN
UPDATE row 1 🔒
UPDATE row 2: waits for tx 2
ERROR: deadlock
tx 2: 2→1
BEGIN
UPDATE row 2 🔒
UPDATE row 1: waits for tx 1
gets row 1 ✓
Each transaction holds one row lock and waits for the other's. After deadlock_timeout (1 s by default) Postgres checks its wait-for graph, finds the cycle and kills one transaction so the other can finish.

The database breaks the tie for you

Unlike a Go program, a database does detect deadlocks among its own transactions, because it knows exactly who holds and who waits for every row lock. It builds the Wait-for graph from §08, finds the cycle, and kills one transaction — the victim — so the others can go on:

DatabaseWhen it checksWhat the victim gets
PostgreSQLAfter a lock wait lasts deadlock_timeout (default 1 s)ERROR: deadlock detected, SQLSTATE 40P01
MySQL (InnoDB)Immediately, on every lock wait (by default)ERROR 1213: Deadlock found when trying to get lock; try restarting transaction, SQLSTATE 40001

The victim's whole transaction is rolled back. Your code has to retry it — the message even says so. That makes deadlocks in the database survivable, but each one still costs a wait (up to a second in Postgres) and a retry.

Retry the whole transaction

These helpers are for PostgreSQL: the Go one uses the pgx driver (github.com/jackc/pgx/v5, whose errors are *pgconn.PgError; with the older lib/pq driver the type is *pq.Error), the JS one uses node-postgres. With MySQL's mysql2 package, check err.code === "ER_LOCK_DEADLOCK" (its errno is 1213) instead.

import (
    "context"
    "errors"
    "math/rand/v2"
    "time"

    "github.com/jackc/pgx/v5/pgconn"
)

func withRetry(ctx context.Context, fn func() error) error {
    for attempt := 1; ; attempt++ {
        err := fn() // fn runs the WHOLE transaction: BEGIN … COMMIT
        var pgErr *pgconn.PgError
        retryable := errors.As(err, &pgErr) &&
            (pgErr.Code == "40P01" || pgErr.Code == "40001") // deadlock / serialization failure
        if !retryable || attempt == 3 {
            return err // success (nil), a real error, or out of attempts
        }
        // back off with jitter (§09) so the two victims don't collide again
        wait := time.Duration(attempt*50+rand.IntN(50)) * time.Millisecond
        select {
        case <-time.After(wait):
        case <-ctx.Done(): // the caller gave up (e.g. the HTTP request was cancelled)
            return ctx.Err()
        }
    }
}

Retry the entire transaction, never just the failed statement: the rollback undid everything before it too. And keep side effects (emails, HTTP calls to other services) outside the retried function, or a retry will send them twice.

Prevent it: the same rules as in Go

  1. Lock rows in a consistent order. Sort the IDs in your code before updating, or lock them up front in order:
    SELECT id FROM accounts WHERE id IN ($1, $2) ORDER BY id FOR UPDATE;
    Now every transfer locks the lower ID first — Lock ordering, in SQL.
  2. Keep transactions short. Every lock is held until COMMIT. Never make a network call, wait for a user, or do slow work inside a transaction.
  3. Use one statement when you can. UPDATE … SET stock = stock - 1 (§05) holds its lock for microseconds.
  4. Set a safety net. Postgres lock_timeout (e.g. SET lock_timeout = '2s') makes a statement fail instead of waiting behind a stuck lock forever. The failed transaction rolls back and releases its locks — the database version of breaking "no preemption" (§08).
Staff-level§11

Debugging a hang

A service has stopped answering. Before reaching for any tool, look at its CPU usage (top, or your dashboard). It splits the problem in two:

CPUProbablyNext step
~0%A Deadlock, or everyone waiting on something that will never answerLook at what every goroutine / request is waiting on
~100% of a coreAn infinite loop, a livelock (§09), or a blocked JS event loopLook at what's running: a CPU profile or a debugger pause

When Go tells you — and when it can't

The Go Runtime has a built-in check. If every Goroutine is blocked and nothing (a timer, the network, a system call) could ever wake one up, it stops the program:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.Mutex.Lock]:
sync.(*Mutex).Lock(...)
main.main()
	/app/main.go:9 +0x45

That's what you saw in §07 and in the channels pitfalls. But notice the word every. The check can't fire when even one goroutine might still wake up — and in real programs, one almost always can:

  • An HTTP server's accept loop is waiting on the network. A new connection could arrive.
  • A time.Ticker for metrics will fire again.
  • A goroutine is blocked in a system call, which the runtime can't see into.

So in a server, two deadlocked goroutines just sit there, silently, while everything else keeps running. Go's check is a nice safety net for small programs and tests; in production you need to look.

Step 1: get a goroutine dump

A Goroutine dump lists every goroutine with its stack and what it's waiting for. Two ways to get one:

kill -QUIT <pid>     # or press Ctrl+\ in the terminal running it
# The runtime prints every goroutine's stack to stderr (the error output), then exits with code 2.

SIGQUIT is the right tool when the process is already broken and you'll restart it anyway — say, a hung test (go test also dumps all goroutines when a test exceeds its -timeout, default 10 minutes). pprof is the right tool for production: you can take several dumps a minute apart without killing anything.

Step 2: read it

Reading a goroutine dump
A hung transfer service. Find the two goroutines that are waiting on each other.
Code
1goroutine 1 [IO wait, 12 minutes]:
2internal/poll.runtime_pollWait(…)
3…
4net/http.(*Server).ListenAndServe(…)
5main.main()
6 /app/main.go:31 +0x1d5
7
8goroutine 51 [sync.Mutex.Lock, 4 minutes]:
9sync.(*Mutex).Lock(…)
10 /usr/local/go/src/sync/mutex.go:46
11main.transfer(0xc000110000, 0xc000110018, 10)
12 /app/bank.go:21 +0x9e
13main.handleTransfer(…)
14 /app/main.go:18 +0x14c
15created by net/http.(*Server).Serve in goroutine 1
16
17goroutine 52 [sync.Mutex.Lock, 4 minutes]:
18sync.(*Mutex).Lock(…)
19 /usr/local/go/src/sync/mutex.go:46
20main.transfer(0xc000110018, 0xc000110000, 5)
21 /app/bank.go:21 +0x9e
22main.handleTransfer(…)
23 /app/main.go:18 +0x14c
24created by net/http.(*Server).Serve in goroutine 1
25
26… 214 more goroutines [sync.Mutex.Lock] in main.transfer …
bank.go
—
goroutines
—
A server stopped answering /transfer requests, but CPU is near 0%. You fetched /debug/pprof/goroutine?debug=2. This is a shortened version of what came back. Every block is one goroutine.
1 / 5

What to look for in any dump:

  • The bracket on each header line: [sync.Mutex.Lock] (before Go 1.20 it said [semacquire]), [chan send], [chan receive], [select] mean "waiting on another goroutine". [IO wait] usually means "waiting for the network" — normal for idle connections.
  • Durations. 4 minutes on a lock wait is never normal. Take two dumps a minute apart: goroutines that haven't moved are your suspects.
  • Pile-ups. Hundreds of goroutines with the same stack (debug=1 counts them for you) point at the one place everything is queuing.
  • Extra frames are normal. The example above is trimmed. Real dumps show a few runtime frames above Lock (e.g. sync.runtime_SemacquireMutex, (*Mutex).lockSlow); skip down to the first frame in your code.
  • Pairs. For a deadlock, find two goroutines each blocked in a Lock — then work out, from their arguments or code, which lock each one already holds.

In Node.js

A hung Node process has the same two shapes, but the tools differ:

CPU at 100%: the event loop is blocked. One piece of synchronous code is running and won't stop, so nothing else — no request, no timer — gets a turn. Attach a debugger and press pause to see where:

node --inspect server.js     # start with the debugger enabled
kill -USR1 <pid>             # …or switch it on in an already-running process (Linux/macOS)
# Open chrome://inspect in Chrome → "inspect" → press ⏸ pause: you land on the running line.

CPU at 0%: a promise that never settles. Some await is waiting for a promise nobody will ever resolve — a lock never released (§07), a request with no timeout, a callback that's never called. There's no stack to pause on, because nothing is running. Useful tools:

  • node --report-on-signal server.js, then kill -USR2 <pid>: Node writes a JSON diagnostic report with the JavaScript stack and every open handle (sockets, timers, files).
  • process.getActiveResourcesInfo() (Node ≥ 17.3) returns what's keeping the process alive, e.g. ['TCPServerWrap', 'Timeout'].
  • If nothing is pending at all, Node simply exits — the awaiting code never runs. With a top-level await in an ES module, it exits with code 13 and Warning: Detected unsettled top-level await. That exit is Node's version of "all goroutines are asleep".
Practitioner§12

Pitfalls gallery

#1

“Fixing” a race with a sleep

Adding time.Sleep(100 * time.Millisecond) (or setTimeout(…, 100)) so that "the other one finishes first" doesn't fix a race. It changes the odds. On a busy server, a slow disk or a garbage-collection pause, the other one won't finish first, and the bug is back — now rarer and harder to find.

✕ Don't
go loadConfig()
time.Sleep(100 * time.Millisecond) // hope it's done by now
use(config)
✓ Do
done := make(chan struct{})
go func() { loadConfig(); close(done) }()
<-done // wait for the actual event, not for a guess
use(config)
#2

An early return that skips the unlock

If any path out of the function forgets to unlock a Lock, the lock stays held forever and the next caller deadlocks. Pair every Lock with a defer Unlock on the very next line (Go), or try … finally (JS).

✕ Don't
mu.Lock()
v, ok := cache[k]
if !ok {
    return nil, errNotFound // ← returns while still holding mu
}
mu.Unlock()
return v, nil
✓ Do
mu.Lock()
defer mu.Unlock() // runs on every return path, and on panic
v, ok := cache[k]
if !ok {
    return nil, errNotFound
}
return v, nil
#3

Locking a mutex you already hold

Go's sync.Mutex is not re-entrant: if a goroutine that holds it calls Lock() again, it waits for itself — forever. This usually happens when one locked method calls another:

func (s *Store) Set(k, v string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.data[k] = v
    s.Log("set " + k) // Log also calls s.mu.Lock() → self-deadlock
}

The common convention: public methods lock; private helpers named …Locked (e.g. logLocked) assume the caller already holds the lock and never lock themselves.

#4

Copying a mutex

A sync.Mutex must not be copied after first use. Quick refresher: a method declared on Counter (a value receiver) receives a copy of the struct; a method declared on *Counter (a pointer receiver) works on the original. So a value-receiver method copies the mutex too — it locks a lock no one else uses (and changes a copy of the data).

✕ Don't
type Counter struct {
    mu sync.Mutex
    n  int
}

func (c Counter) Inc() { // value receiver: c is a COPY
    c.mu.Lock()
    c.n++                // increments the copy; the original never changes
    c.mu.Unlock()
}
✓ Do
func (c *Counter) Inc() { // pointer receiver: the real Counter
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

go vet catches this (its copylocks check prints "passes lock by value"). go test runs only a small subset of vet checks automatically, and copylocks isn't one of them — so run go vet ./... explicitly in CI.

#5

Writing a map from several goroutines

Go maps aren't safe for concurrent writes, and the runtime checks: instead of silently corrupting the map, it crashes the whole program with fatal error: concurrent map writes (or concurrent map read and map write). This is a fatal error, not a panic — recover can't catch it. Protect the map with a mutex, or use sync.Map for the special case of keys that are written once and read many times.

#6

Taking a read lock twice (RWMutex)

It seems safe for a reader to RLock again — readers share. But Go's RWMutex makes new readers wait once a writer is waiting (that's what prevents writer starvation, §09). So this sequence deadlocks:

  1. Goroutine A: RLock() ✓
  2. Goroutine B: Lock() — waits for A to finish reading
  3. Goroutine A: RLock() again — waits behind B. A waits for B, B waits for A.

It only happens when a writer arrives at exactly the wrong moment, so it can pass every test. The sync docs say it plainly: recursive read locking is not allowed.

#7

Doing slow work while holding a lock

Everything inside a Critical section runs one goroutine at a time. Put a network call in there and every other goroutine waits for the network too. Worse, if that call (or a callback you invoke) takes another lock, you've created a lock-ordering problem you can't see from this function.

✕ Don't
mu.Lock()
defer mu.Unlock()
resp, err := http.Get(url) // everyone waits for the network
if err != nil {
    return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) // …and for the download
if err != nil {
    return err
}
cache[url] = body
return nil
✓ Do
resp, err := http.Get(url) // slow work outside the lock
if err != nil {
    return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
    return err
}
mu.Lock()
cache[url] = body // lock only for the shared update
mu.Unlock()
return nil
#8

JS: an await between the check and the act

The single-threaded promise doesn't protect you across await (§02, §04):

✕ Don't
if (!(await isTaken(name))) {   // check…
  await createUser(name);       // …another request did the same check meanwhile
}
total += await price(item);     // reads total BEFORE the await
✓ Do
try {
  await createUser(name);       // a UNIQUE constraint on name decides
} catch (err) {
  if (err.code !== "23505") throw err; // 23505 = Postgres "unique_violation"
  return "name taken";
}
const p = await price(item);
total += p;                     // read and write together, after the await
#9

Old Go: every goroutine sharing one loop variable

Before Go 1.22, a for loop had one variable for all iterations, so goroutines started in the loop raced on it and often all saw the last value:

for _, job := range jobs {
    go func() {
        process(job) // Go ≤ 1.21: data race, often the same job N times
    }()              // Go ≥ 1.22: each iteration has its own job ✓
}

The fix applies when your go.mod says go 1.22 or later. In older modules, pass the value in: go func(job Job) { process(job) }(job).

#10

“The race detector is green, so we're safe”

go test -race only judges code that ran, in the orders that happened (§06). It's blind to logical races, database races and deadlocks. A green run means "no data race was observed", not "no race exists". Design the check-and-act steps to be indivisible first; use the tool to catch what slipped through.

Practitioner§13

Check yourself

Commit to an answer before you click. Q1 is covered in §02, Q2 in §03, Q3–Q4 in §11, Q5 in §08 and Q6 in §05.

QUESTION 1 / 6
Single-threaded, so no race… right?
let count = 0;
const getOne = async () => 1;

await Promise.all(
  Array.from({ length: 10 }, async () => {
    count += await getOne();
  })
);
console.log(count);
QUESTION 2 / 6
Will go test -race catch this?
var (
    mu    sync.Mutex
    seats = map[string]bool{}
)

func isFree(s string) bool {
    mu.Lock(); defer mu.Unlock()
    return !seats[s]
}
func book(s string) {
    mu.Lock(); defer mu.Unlock()
    seats[s] = true
}

// called from many goroutines:
if isFree("12A") {
    book("12A")
}
QUESTION 3 / 6
What does this program do?
func main() {
    var mu sync.Mutex
    mu.Lock()
    fmt.Println("locked once")
    mu.Lock()
    fmt.Println("locked twice")
}
QUESTION 4 / 6
Same bug, inside an HTTP handler
http.HandleFunc("/pay", func(w http.ResponseWriter, r *http.Request) {
    mu.Lock()
    mu.Lock() // oops
    w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
QUESTION 5 / 6
Which change makes the transfer deadlock impossible?
func transfer(from, to *Account, amt int) {
    from.mu.Lock()
    defer from.mu.Unlock()
    to.mu.Lock()
    defer to.mu.Unlock()
    from.balance -= amt
    to.balance += amt
}
QUESTION 6 / 6
Optimistic locking: what does rowCount 0 mean?
const { rowCount } = await db.query(
  "UPDATE docs SET body = $1, version = version + 1 WHERE id = $2 AND version = $3",
  [newBody, id, versionWeRead]);
Answered 0 / 6
Foundations§14

Cheat sheet

🏁
Race condition = timing decides the resultCorrect for some orders, wrong for others. Hides in tests, appears under load.
🧮
count++ is read, add, writeThree steps. Two goroutines can both read before either writes → a lost update.
🔀
Data race ≠ race conditionData race: unsynchronized parallel memory access (Go). Race condition: wrong result from some order (everywhere, incl. JS across await).
🧱
Check + act = one stepA mutex, an atomic, synchronous JS, O_EXCL, one SQL statement, a UNIQUE constraint.
🗄️
Across servers, the database decidesUPDATE … SET x = x - 1 WHERE …, a version column, or SELECT … FOR UPDATE.
🔎
go test -race in CIEvery report is real. Silence only means no data race was observed.
♾️
Deadlock needs all four conditionsMutual exclusion, hold and wait, no preemption, circular wait. Break one.
🔢
Lock ordering beats clevernessAlways take locks in one global order. Timeouts recover; ordering prevents.
🎲
Retries need jitterSame back-off for everyone → livelock and thundering herds.
🩺
Hung? Look before you restartCPU 0% → goroutine dump (pprof ?debug=2, SIGQUIT). CPU 100% → profiler / debugger pause.
SymptomLikely causeFirst move
Total is a bit too low, differs per runRead-modify-write racego test -race; mutex or sync/atomic
Duplicate rows / double chargesCheck-then-act, double submitUNIQUE constraint + idempotency key
Stock or balance "forgets" changesLost update in the DBOne UPDATE, version column, or FOR UPDATE
fatal error: all goroutines are asleepEvery goroutine blockedRead the stacks it prints
Requests hang, CPU idleDeadlock or a wait with no timeoutGoroutine dump / Node diagnostic report
ERROR: deadlock detected (40P01)Rows locked in different ordersRetry the transaction; sort IDs before locking
Busy CPU, nothing completes, conflict logsLivelockAdd jitter; better, lock ordering
Some requests time out, most fineStarvation / blocked event loopShorter critical sections; move CPU work off the event loop
GoJavaScript
sync.Mutex + defer mu.Unlock()No lock needed for sync code; async-mutex across await
atomic.Int64, atomic.Bool— (plain variables are safe without await)
singleflight.GroupCache the promise, not the value
os.O_CREATE|os.O_EXCL{ flag: "wx" }
ctx with timeout, semaphore.Acquire(ctx, 1)AbortSignal.timeout(ms), withTimeout(new Mutex(), ms)
go test -race, go vetTests that control timing; ESLint require-atomic-updates
/debug/pprof/goroutine?debug=2, kill -QUITnode --inspect, --report-on-signal, getActiveResourcesInfo()
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Concurrency & AsyncLocks, Mutexes & Atomics
Read →