Part 01 · Low Level Design
Concurrency in LLD: Locks and Thread Safety
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
| Symptom | Cause |
|---|---|
| Two bookings for one seat | Check and write in separate steps, with no lock around both |
| A counter that ends up smaller than it should | count++ is really read, add, write, and threads interleave |
| Random crashes in a map | Two threads write a non-thread-safe map at once |
| The app hangs forever | Two locks taken in different orders (deadlock) |
| Memory grows until the process dies | Work 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).
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.
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
- 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. - 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.
- 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.
- Immutability needs no lock. An object that never changes after creation (a config snapshot, a price list, a
Moneyvalue) can be read by any number of threads safely. To "change" it, build a new one and swap a single reference. - 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.
- 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.
- 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
Counter A and counter B both book seat 7
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?
Is this thread-safe?
lock; if !taken[s] { taken[s] = me }; unlockSeats, 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)
}import queue
import threading
class Hall:
"""The check ("is it free?") and the write ("mark it taken") share one lock."""
def __init__(self) -> None:
self._lock = threading.Lock()
self.seats: dict[int, str] = {}
def book(self, seat: int, who: str) -> bool:
with self._lock:
if seat in self.seats:
return False
self.seats[seat] = who
return True
# 1. Eight booking counters race for the same ten seats.
hall = Hall()
count_lock = threading.Lock()
counts = {"booked": 0, "refused": 0}
def counter_desk(w: int) -> None:
for seat in range(1, 11):
ok = hall.book(seat, f"counter-{w}")
with count_lock:
counts["booked" if ok else "refused"] += 1
desks = [threading.Thread(target=counter_desk, args=(w,)) for w in range(1, 9)]
for t in desks:
t.start()
for t in desks:
t.join()
print(f"seats: {counts['booked']} booked, {counts['refused']} requests refused, {len(hall.seats)} seats with an owner")
# 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
jobs: "queue.Queue[int | None]" = queue.Queue(maxsize=5) # put() waits when 5 are queued
results: "queue.Queue[int]" = queue.Queue()
def worker() -> None:
while (order := jobs.get()) is not None:
results.put(order * 10) # "price" each order
pool = [threading.Thread(target=worker) for _ in range(4)]
for t in pool:
t.start()
for order in range(1, 21):
jobs.put(order)
for _ in pool:
jobs.put(None) # one "stop" per worker
for t in pool:
t.join()
done = [results.get() for _ in range(results.qsize())]
print(f"orders: {len(done)} processed by 4 workers through a queue of 5, total {sum(done)} tk")
# 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
lock = threading.Lock()
counter = 0
def bump() -> None:
global counter
for _ in range(10000):
with lock:
counter += 1
threads = [threading.Thread(target=bump) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"counter with a lock: {counter} (expected {4 * 10000})")// JavaScript runs this code on one thread. Concurrency comes from async tasks that take
// turns at every `await`, so a race can only happen across an await, never inside sync code.
class Hall {
readonly seats = new Map<number, string>();
// No await between the check and the write, so no other task can run in between.
book(seat: number, who: string): boolean {
if (this.seats.has(seat)) return false;
this.seats.set(seat, who);
return true;
}
}
// A bounded async queue: put() waits while it's full, take() waits while it's empty.
class BoundedQueue<T> {
readonly #items: T[] = [];
readonly #capacity: number;
#waitingTakers: ((v: T) => void)[] = [];
#waitingPutters: (() => void)[] = [];
constructor(capacity: number) {
this.#capacity = capacity;
}
async put(v: T): Promise<void> {
while (this.#items.length >= this.#capacity) await new Promise<void>((r) => this.#waitingPutters.push(r));
const taker = this.#waitingTakers.shift();
if (taker) taker(v);
else this.#items.push(v);
}
async take(): Promise<T> {
const v = this.#items.shift();
if (v !== undefined) {
this.#waitingPutters.shift()?.();
return v;
}
return new Promise<T>((r) => this.#waitingTakers.push(r));
}
}
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
async function main(): Promise<void> {
// 1. Eight booking counters race for the same ten seats.
const hall = new Hall();
let booked = 0;
let refused = 0;
await Promise.all(
Array.from({ length: 8 }, async (_, i) => {
for (let seat = 1; seat <= 10; seat++) {
await tick(); // let other counters run between requests
if (hall.book(seat, `counter-${i + 1}`)) booked++;
else refused++;
}
}),
);
console.log(`seats: ${booked} booked, ${refused} requests refused, ${hall.seats.size} seats with an owner`);
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
const jobs = new BoundedQueue<number | null>(5);
const results: number[] = [];
const workers = Array.from({ length: 4 }, async () => {
for (let order = await jobs.take(); order !== null; order = await jobs.take()) {
results.push(order * 10); // "price" each order
}
});
for (let order = 1; order <= 20; order++) await jobs.put(order);
for (let i = 0; i < 4; i++) await jobs.put(null); // one "stop" per worker
await Promise.all(workers);
const total = results.reduce((a, b) => a + b, 0);
console.log(`orders: ${results.length} processed by 4 workers through a queue of 5, total ${total} tk`);
// 3. A shared counter: 4 tasks x 10,000 increments. counter++ has no await inside it, so the
// single thread itself acts as the lock (the label below matches the other languages).
let counter = 0;
await Promise.all(
Array.from({ length: 4 }, async () => {
for (let i = 0; i < 10000; i++) {
counter++;
if (i % 1000 === 0) await tick();
}
}),
);
console.log(`counter with a lock: ${counter} (expected ${4 * 10000})`);
}
void main();use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
// The check ("is it free?") and the write ("mark it taken") happen under one lock.
// In Rust the map lives INSIDE the Mutex, so it can't be touched without locking.
struct Hall { seats: Mutex<HashMap<u32, String>> }
impl Hall {
fn book(&self, seat: u32, who: &str) -> bool {
let mut seats = self.seats.lock().unwrap();
if seats.contains_key(&seat) { return false; }
seats.insert(seat, who.to_string());
true
}
}
fn main() {
// 1. Eight booking counters race for the same ten seats.
let hall = Arc::new(Hall { seats: Mutex::new(HashMap::new()) });
let counts = Arc::new(Mutex::new((0u32, 0u32))); // (booked, refused)
let desks: Vec<_> = (1..=8).map(|w| {
let (hall, counts) = (Arc::clone(&hall), Arc::clone(&counts));
thread::spawn(move || {
for seat in 1..=10 {
let ok = hall.book(seat, &format!("counter-{w}"));
let mut c = counts.lock().unwrap();
if ok { c.0 += 1 } else { c.1 += 1 }
}
})
}).collect();
for d in desks { d.join().unwrap(); }
let (booked, refused) = *counts.lock().unwrap();
println!("seats: {booked} booked, {refused} requests refused, {} seats with an owner", hall.seats.lock().unwrap().len());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
let (job_tx, job_rx) = mpsc::sync_channel::<u32>(5); // send() blocks when 5 are queued
let job_rx = Arc::new(Mutex::new(job_rx)); // workers share one receiver
let (res_tx, res_rx) = mpsc::channel::<u32>();
let pool: Vec<_> = (0..4).map(|_| {
let (rx, tx) = (Arc::clone(&job_rx), res_tx.clone());
thread::spawn(move || loop {
let next = rx.lock().unwrap().recv();
match next {
Ok(order) => tx.send(order * 10).unwrap(), // "price" each order
Err(_) => break, // channel closed: no more work
}
})
}).collect();
drop(res_tx);
for order in 1..=20 { job_tx.send(order).unwrap(); }
drop(job_tx); // close the queue
for p in pool { p.join().unwrap(); }
let done: Vec<u32> = res_rx.iter().collect();
println!("orders: {} processed by 4 workers through a queue of 5, total {} tk", done.len(), done.iter().sum::<u32>());
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
let counter = Arc::new(Mutex::new(0u32));
let threads: Vec<_> = (0..4).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || for _ in 0..10000 { *counter.lock().unwrap() += 1; })
}).collect();
for t in threads { t.join().unwrap(); }
println!("counter with a lock: {} (expected {})", *counter.lock().unwrap(), 4 * 10000);
}import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
// The check ("is it free?") and the write ("mark it taken") share one lock.
static final class Hall {
private final Map<Integer, String> seats = new HashMap<>();
synchronized boolean book(int seat, String who) {
if (seats.containsKey(seat)) return false;
seats.put(seat, who);
return true;
}
synchronized int owned() { return seats.size(); }
}
private static int counter = 0;
private static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
// 1. Eight booking counters race for the same ten seats.
Hall hall = new Hall();
AtomicInteger booked = new AtomicInteger(), refused = new AtomicInteger();
ExecutorService desks = Executors.newFixedThreadPool(8);
for (int w = 1; w <= 8; w++) {
final int id = w;
desks.submit(() -> {
for (int seat = 1; seat <= 10; seat++) {
if (hall.book(seat, "counter-" + id)) booked.incrementAndGet(); else refused.incrementAndGet();
}
});
}
desks.shutdown();
desks.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("seats: %d booked, %d requests refused, %d seats with an owner%n", booked.get(), refused.get(), hall.owned());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
BlockingQueue<Integer> jobs = new ArrayBlockingQueue<>(5); // put() waits when 5 are queued
AtomicInteger count = new AtomicInteger(), total = new AtomicInteger();
final int stop = -1; // one "stop" per worker
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
try {
for (int order = jobs.take(); order != stop; order = jobs.take()) {
count.incrementAndGet();
total.addAndGet(order * 10); // "price" each order
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
for (int order = 1; order <= 20; order++) jobs.put(order);
for (int i = 0; i < 4; i++) jobs.put(stop);
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("orders: %d processed by 4 workers through a queue of 5, total %d tk%n", count.get(), total.get());
// 3. A shared counter: 4 threads x 10,000 increments, protected by a lock.
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) synchronized (LOCK) { counter++; }
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.printf("counter with a lock: %d (expected %d)%n", counter, 4 * 10000);
}
}#include <condition_variable>
#include <cstdio>
#include <map>
#include <mutex>
#include <optional>
#include <queue>
#include <string>
#include <thread>
#include <vector>
// The check ("is it free?") and the write ("mark it taken") share one lock.
class Hall {
public:
bool book(int seat, const std::string& who) {
std::lock_guard lock(mu_);
if (seats_.count(seat)) return false;
seats_[seat] = who;
return true;
}
size_t owned() {
std::lock_guard lock(mu_);
return seats_.size();
}
private:
std::mutex mu_;
std::map<int, std::string> seats_;
};
// A bounded queue: put() waits while full, take() waits while empty.
template <class T>
class BoundedQueue {
public:
explicit BoundedQueue(size_t cap) : cap_(cap) {}
void put(T v) {
std::unique_lock lock(mu_);
notFull_.wait(lock, [&] { return q_.size() < cap_; });
q_.push(std::move(v));
notEmpty_.notify_one();
}
T take() {
std::unique_lock lock(mu_);
notEmpty_.wait(lock, [&] { return !q_.empty(); });
T v = std::move(q_.front());
q_.pop();
notFull_.notify_one();
return v;
}
private:
size_t cap_;
std::mutex mu_;
std::condition_variable notFull_, notEmpty_;
std::queue<T> q_;
};
int main() {
// 1. Eight booking counters race for the same ten seats.
Hall hall;
std::mutex countMu;
int booked = 0, refused = 0;
std::vector<std::thread> desks;
for (int w = 1; w <= 8; w++) {
desks.emplace_back([&, w] {
for (int seat = 1; seat <= 10; seat++) {
bool ok = hall.book(seat, "counter-" + std::to_string(w));
std::lock_guard lock(countMu);
(ok ? booked : refused)++;
}
});
}
for (auto& d : desks) d.join();
std::printf("seats: %d booked, %d requests refused, %zu seats with an owner\n", booked, refused, hall.owned());
// 2. Producer-consumer: one producer, a bounded queue of 5, a pool of 4 workers.
BoundedQueue<std::optional<int>> jobs(5);
std::mutex resMu;
int count = 0, total = 0;
std::vector<std::thread> pool;
for (int i = 0; i < 4; i++) {
pool.emplace_back([&] {
while (auto order = jobs.take()) { // std::nullopt means "stop"
std::lock_guard lock(resMu);
count++;
total += *order * 10; // "price" each order
}
});
}
for (int order = 1; order <= 20; order++) jobs.put(order);
for (int i = 0; i < 4; i++) jobs.put(std::nullopt); // one "stop" per worker
for (auto& p : pool) p.join();
std::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.
std::mutex mu;
int counter = 0;
std::vector<std::thread> threads;
for (int i = 0; i < 4; i++)
threads.emplace_back([&] {
for (int j = 0; j < 10000; j++) {
std::lock_guard lock(mu);
counter++;
}
});
for (auto& t : threads) t.join();
std::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, andArcshares ownership across threads. Go uses a channel with capacity 5 as the bounded queue, and closing it tells workers to stop. Python'squeue.Queue(maxsize=5)blocks onputwhen full; the GIL doesn't makecount += 1safe, so the lock is still needed. Java usessynchronized,ArrayBlockingQueueandExecutorService. C++ builds the bounded queue from amutexand twocondition_variables. TypeScript is the odd one out: it runs on one thread, so code without anawaitcan't be interrupted, and the "lock" is simply not awaiting insidebook.
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.Mutexprotects 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
Pick the concurrency bug
A: lock(acc1); lock(acc2) B: lock(acc2); lock(acc1)
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 →