Part 01 · Low Level Design

Concurrency in LLD: Locks and Thread Safety

Race conditionsLock placementImmutabilityProducer-consumerThread pool

Two threads, one object: where the lock goes, objects that need no lock at all, producer-consumer with a queue, and a thread pool. Local concurrency only; the distributed kind comes later.

⏱ 2-3 hours · Station 12 of 80

Two tickets for seat A-7

A cinema in Dhaka sells tickets at the counter and online at the same time. Both run the same code: "if seat A-7 is free, mark it taken and print a ticket". On a Friday evening, two people hold tickets for A-7.

Nothing is wrong with the rule. Both threads checked "is it free?" at nearly the same instant, both saw yes, and both wrote taken. The bug lives in the gap between the check and the write.

This chapter is about concurrency inside one program: several threads (or goroutines, or async tasks) sharing objects. How races happen, where locks go, how to avoid locks entirely, and how to hand work to a pool of workers safely. Concurrency across machines (distributed locks, consensus) is a different problem, covered in the HLD part.

The problem: shared state plus parallel work

SymptomCause
Two bookings for one seatCheck and write in separate steps, with no lock around both
A counter that ends up smaller than it shouldcount++ is really read, add, write, and threads interleave
Random crashes in a mapTwo threads write a non-thread-safe map at once
The app hangs foreverTwo locks taken in different orders (deadlock)
Memory grows until the process diesWork is produced faster than it's consumed, into an unbounded queue

We ran the shared counter from this chapter's code without a lock: 4 goroutines × 10,000 increments, expecting 40,000. Three runs printed 40,000, 26,224 and 28,185. Right once, by luck. That's what makes races dangerous: tests often pass.

Think of a single bathroom key

An office has one bathroom and one key on a hook. Whoever takes the key goes in; everyone else waits. The key makes "check the door, go in, lock it" a single step, so two people can't both decide the room is empty. That's a mutex (mutual exclusion).

And think of a restaurant kitchen: waiters pin orders on a rail with room for five slips, and four cooks take the next slip when they're free. If the rail is full, the waiter holds the slip until there's space. That's a bounded queue with a worker pool (producer-consumer).

bounded queue: 5 slotsproducerorders 1..20worker 1worker 2worker 3worker 4#1#2#3#4#5#6#7#8#9#5queue full: the producer waits instead of piling up workworker 1 took #5, so the producer can add more
Press play. Orders flow from one producer through a queue with 5 slots to four workers. When every worker is busy and the queue is full, the producer waits instead of piling up work; as soon as worker 1 takes #5, the producer can add more.

Where the comparison breaks down. A person waiting for the bathroom notices if someone fainted inside. A thread waiting on a lock will wait forever if the holder never releases it (a crash between Lock and Unlock, or a deadlock). That's why real code releases locks with defer, with, finally or RAII.

Predict first

Book(seat) does: lock; if seat is free { mark taken }; unlock. Eight threads each try seats 1 to 10. How many bookings succeed?

Races, locks and safe hand-offs

  1. A race condition happens when the result depends on the timing of threads. The classic shape is check-then-act ("if free, take") or read-modify-write (count++), where another thread can run in the middle.
  2. A critical section is the code that must run alone. Lock placement is about drawing its boundary: the check and the act must be inside the same critical section. Locking only the write, or two separate locks for check and write, leaves the race in place.
  3. Keep critical sections small and never do slow I/O (network calls, disk) while holding a lock. Do the slow part outside, then lock briefly to update shared state.
  4. Immutability needs no lock. An object that never changes after creation (a config snapshot, a price list, a Money value) can be read by any number of threads safely. To "change" it, build a new one and swap a single reference.
  5. Producer-consumer: instead of sharing objects, pass them through a thread-safe queue. Producers put work in, consumers take it out. A bounded queue gives you backpressure: producers slow down when consumers fall behind, instead of memory growing forever.
  6. Thread pool: a fixed number of worker threads taking jobs from a queue. You control how much runs in parallel (4 workers, not 4,000 threads), and threads are reused instead of created per job.
  7. Deadlock: thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1. Avoid it by always taking locks in the same global order, or by holding one lock at a time.

Two threads, one seat

Step through it

Counter A and counter B both book seat 7

Counter AHall lockSeat mapCounter B

Step 1/5 A arrives first and takes the lock

Counter A calls Book(7). It gets the lock. Counter B, a microsecond later, also calls Book(7) and has to wait.

lock held by
A
B
waiting

Three questions for shared data

For every piece of data more than one thread can reach, ask:

  • Can I make it immutable? Then no lock is needed.
  • Can I stop sharing it? Give each thread its own copy, or pass it through a queue so only one thread owns it at a time.
  • If it must be shared and mutable: which lock protects it, and does that lock cover the whole check-then-act?

Try it: safe or racy?

Try each case

Is this thread-safe?

lock; if !taken[s] { taken[s] = me }; unlock
works: Safe. The whole check-then-act is one critical section.

Seats, a worker pool and a counter in code

Eight threads booking ten seats through one lock, a producer feeding a bounded queue of 5 to a pool of 4 workers, and a shared counter protected by a lock. The output is the same on every run because every shared write is synchronised and the totals don't depend on order.

package main

import (
	"fmt"
	"sync"
)

// ---- Lock placement: the check ("is it free?") and the write ("mark it taken") must be
// inside the SAME critical section, or two threads can both see "free".

type Hall struct {
	mu    sync.Mutex
	seats map[int]string // seat -> who booked it
}

func (h *Hall) Book(seat int, who string) bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	if _, taken := h.seats[seat]; taken {
		return false
	}
	h.seats[seat] = who
	return true
}

func main() {
	// 1. Eight booking counters race for the same ten seats.
	hall := &Hall{seats: map[int]string{}}
	var wg sync.WaitGroup
	var countMu sync.Mutex
	booked, refused := 0, 0
	for w := 1; w <= 8; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for seat := 1; seat <= 10; seat++ {
				ok := hall.Book(seat, fmt.Sprintf("counter-%d", w))
				countMu.Lock()
				if ok {
					booked++
				} else {
					refused++
				}
				countMu.Unlock()
			}
		}()
	}
	wg.Wait()
	fmt.Printf("seats: %d booked, %d requests refused, %d seats with an owner\n", booked, refused, len(hall.seats))

	// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
	jobs := make(chan int, 5) // bounded: the producer waits when 5 orders are queued
	results := make(chan int)
	var workers sync.WaitGroup
	for range 4 {
		workers.Add(1)
		go func() {
			defer workers.Done()
			for order := range jobs {
				results <- order * 10 // "price" each order
			}
		}()
	}
	go func() {
		for order := 1; order <= 20; order++ {
			jobs <- order
		}
		close(jobs) // no more work: workers finish their loops
	}()
	go func() { workers.Wait(); close(results) }()
	count, total := 0, 0
	for r := range results {
		count++
		total += r
	}
	fmt.Printf("orders: %d processed by 4 workers through a queue of 5, total %d tk\n", count, total)

	// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
	var mu sync.Mutex
	counter := 0
	var cw sync.WaitGroup
	for range 4 {
		cw.Add(1)
		go func() {
			defer cw.Done()
			for range 10000 {
				mu.Lock()
				counter++
				mu.Unlock()
			}
		}()
	}
	cw.Wait()
	fmt.Printf("counter with a lock: %d (expected %d)\n", counter, 4*10000)
}

All six print exactly this:

seats: 10 booked, 70 requests refused, 10 seats with an owner
orders: 20 processed by 4 workers through a queue of 5, total 2100 tk
counter with a lock: 40000 (expected 40000)

Reading the output:

  • seats: 8 counters × 10 seats = 80 requests. Exactly 10 succeed, one per seat, and 70 are refused. Which counter wins each seat changes from run to run; the totals don't.
  • orders: all 20 orders were priced (1 + 2 + … + 20 = 210, × 10 = 2,100). The queue never held more than 5, so the producer was slowed down instead of memory growing.
  • counter: with the lock, 40,000 every time. Without it we saw 26,224 and 28,185.
  • The Go version was also run with go run -race, Go's race detector, and reported nothing.
  • What each language changed: Rust puts the data inside the Mutex (Mutex<HashMap<…>>), so touching the seats without locking is a compile error, and Arc shares ownership across threads. Go uses a channel with capacity 5 as the bounded queue, and closing it tells workers to stop. Python's queue.Queue(maxsize=5) blocks on put when full; the GIL doesn't make count += 1 safe, so the lock is still needed. Java uses synchronized, ArrayBlockingQueue and ExecutorService. C++ builds the bounded queue from a mutex and two condition_variables. TypeScript is the odd one out: it runs on one thread, so code without an await can't be interrupted, and the "lock" is simply not awaiting inside book.

Concurrency in production code

  • Prefer not sharing. Pass data through queues or channels, or give each worker its own state and merge results at the end. Shared mutable state is the last resort.
  • Use the standard tools. sync.Mutex, RWMutex (many readers, one writer), atomics for simple counters, concurrent maps where the language has good ones. Don't invent your own locking.
  • Release locks automatically. defer mu.Unlock(), with lock:, try/finally, RAII guards. A forgotten unlock on an error path is a hang waiting to happen.
  • Bound everything. Queues, worker counts, connection pools. Unbounded work means unbounded memory.
  • Run the race detector in CI. Go's -race, ThreadSanitizer for C/C++ and Rust, and stress tests that run the concurrent part many times.
  • Know where local locks stop. A sync.Mutex protects one process. Two servers booking the same seat need the database (a unique constraint, SELECT … FOR UPDATE) or a distributed lock: that's HLD territory.

Designs that cause problems later

Try each case

Pick the concurrency bug

A: lock(acc1); lock(acc2)   B: lock(acc2); lock(acc1)
breaks: Deadlock: each holds one lock and waits for the other forever. Always lock in a fixed order (for example by account ID).

Pause and think

The booking code now runs on three servers behind a load balancer, each with its own Hall and its own mutex. Is seat A-7 still safe?

Reveal

No. Each mutex only protects its own process's memory, and each server has its own copy of the seat map. Two servers can both see A-7 as free. The truth has to live in one shared place: the database. Use a unique constraint on (show_id, seat) so the second insert fails, or UPDATE seats SET owner = ? WHERE id = ? AND owner IS NULL and check that one row changed. The local lock is still useful inside each server, but correctness across servers comes from the shared store. That's where HLD takes over.

Quick quiz

1. Why must the check and the write be in the same critical section?

Answer

Otherwise another thread can run between them, see the same "free" state, and act on it too: both take the seat.

2. Why doesn't an immutable object need a lock?

Answer

Races need at least one writer. If nothing ever changes the object, every reader always sees the same, complete value.

3. What does a bounded queue give you that an unbounded one doesn't?

Answer

Backpressure: when consumers fall behind, producers wait instead of filling memory. It also caps how much work is in flight.

4. How do you avoid deadlock when a transfer needs two account locks?

Answer

Always take the locks in the same order (for example, lower account ID first), or redesign so only one lock is needed at a time.

Your challenge: a VIP row

Add BookBest(who) (int, bool) to Hall: it books the lowest-numbered free seat and returns it, or false if the hall is full. Then run 12 threads that each call BookBest once after the existing races, on a fresh hall with 10 seats.

  • The search for the lowest free seat and the write must be in one critical section.

Before you run it, predict the new line.

You've succeeded when a new line prints best seats: 10 booked, 2 refused, seats 1-10 each have one owner, the race detector (or ThreadSanitizer) stays quiet, and the earlier three lines are unchanged.

In one line

Races live in the gap between checking and acting. Put the whole check-then-act inside one lock and keep it short, prefer immutable data and passing work through bounded queues over sharing, use a fixed pool of workers, and remember that an in-process lock only protects one process.

Station stamp

Finish the chapter

Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.

Go Pro to earn XP →