// lesson: concurrency
Concurrency โ Many Hands on One Table
Everything so far assumed one thread. Real databases serve many connections at once โ and the moment two threads touch the same table, code that has worked perfectly all course becomes a lottery. This lesson is about why it breaks and the discipline that fixes it.
What Actually Goes Wrong
Consider two threads both running our insert. It ends with:
t->rows[t->count].id = t->next_id++;
return t->rows[t->count++].id;
t->count++ looks atomic. It isn't โ it compiles to load, add, store.
Interleave two threads:
Thread A: load count (=5)
Thread B: load count (=5)
Thread A: store 6, write row into slot 5
Thread B: store 6, write row into slot 5 โ overwrites A's row!
Two inserts, one row, count is 6 but slot 6 was never written โ it's uninitialized garbage that a later scan will happily serve as data. This is a race condition, and it has the worst possible debugging profile: it's timing-dependent, so it passes every test on your laptop and fires under production load; adding printf changes the timing and makes it vanish (a "heisenbug").
It gets worse. Our table reallocs when it grows. If thread A's insert
triggers realloc โ which may move the whole array and free the old one โ
while thread B is mid-scan holding a pointer into the old array, thread B is
now reading freed memory. And even without realloc, a reader can see a
torn row: writer has copied the name but not yet the age. There is no
"mostly safe" here: the C memory model says a data race is undefined
behavior, full stop.
Critical Sections and Mutexes
The fix is mutual exclusion: mark the read-modify-write sequences that must never interleave (critical sections) and let only one thread inside at a time. POSIX gives us the mutex:
pthread_mutex_lock(&t->lock); /* blocks until the lock is free */
/* ... critical section: this thread has exclusive access ... */
pthread_mutex_unlock(&t->lock); /* next waiting thread may now enter */
The rules that make mutexes work in practice:
- The whole invariant, not the hot line. The critical section must cover
the entire sequence that takes the table from one valid state to another โ
grow-check, realloc, row copy, id assignment, count bump. Locking just
count++still lets a reader see the row half-copied. - Every path unlocks. Including early returns on allocation failure. A
returned-without-unlock mutex deadlocks the next caller forever. (This is
the C version of why other languages have
defer/RAII.) - Never return pointers into locked state. If
table_find_by_idreturns&t->rows[i]and then unlocks, the caller reads that pointer outside the lock โ racing every future insert's realloc. The safe pattern is copy out: the lookup copies the row into a caller-provided struct while holding the lock. This is why the challenge below has atable_find_copy(t, id, &out)signature, and it's the same copies-vs-references tension you met in the querying lesson โ concurrency turns "slightly risky" into "undefined behavior".
Granularity: One Big Lock, and Why That's Respectable
We'll protect the whole table with a single mutex โ every operation takes it. Simple to reason about, obviously correct, and it serializes everything: two CPU-heavy queries can't run simultaneously. The alternatives are a ladder of complexity you climb only when profiling says you must:
- Readers-writer locks (
pthread_rwlock_t): many concurrent readers OR one writer. Great when reads dominate. - Fine-grained locking: lock per page/row-range so writers on different pages proceed in parallel. Now deadlock becomes possible โ thread A holds lock 1 wanting 2, thread B holds 2 wanting 1 โ and the classic cure is a global lock ordering (always acquire in address/page-number order).
- MVCC: writers create new row versions instead of mutating, so readers never block at all โ Postgres's approach, and SQLite's WAL mode for readers.
For perspective: SQLite itself runs one big lock. A single writer at a time, enforced with file locks; WAL mode relaxes readers, not writers. It handles enormous workloads that way. "One big lock, correctly" beats "clever locks, incorrectly" every time โ you can always optimize a correct program.
โบ Thread-Safe Table
25 ptsMake the table safe for concurrent use: table_insert and a copy-out lookup
table_find_copy, both holding the table's mutex for their entire critical
section. The tests hammer the table from 8 threads and then mix readers with
writers; a missing or too-narrow lock shows up as lost rows, duplicate IDs, or
a crash.
Log in to submit a solution and earn points.