Skip to content

Commit dbfc716

Browse files
feat: teach threading's data race on the canvas (phase 8)
A thread is a call running in its own frame that shares the module heap and is preempted after every statement (not just at a yield, the way a coroutine is). join is the scheduler: it round-robins the started threads one statement at a time, so two threads on one box visibly interleave on the shared memory. lock.acquire() on a held lock blocks the caller until the holder release()s. - thread/lock value shapes + guards (target/name/status/pc/locals; held/holder) - makeThread/makeLock, threadStart/threadJoin, lockAcquire/lockRelease - stepThread steps exactly one statement; a blocked acquire does NOT advance pc - canvas thread/lock boxes with lifecycle status chips; RAM rows for both - predicates isThread/threadStatus/differentThreads/isLock/lockHeld/lockFree/ listValueAt - 5-ladder series: build a thread -> start+join -> the lost-update race (box ends 1, not 2) -> the lock fixes it (box ends 2) -> block/wait capstone - deep lesson ELI5->ELI-PHD + 2 schematics (lost update; lock critical section) - docs: catalog now 55 levels / 13 series; asyncio exemplar marked shipped; archetype inventory + roadmap updated 95 tests green (6 new threading engine tests).
1 parent fb80967 commit dbfc716

8 files changed

Lines changed: 906 additions & 14 deletions

File tree

‎docs/CURRICULUM.md‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ step. The numpy and pandas series are the two worked references.
200200
201201
## 3. The catalog (current)
202202
203-
**45 levels across 11 series.** Series are in teaching order. `band` is the
203+
**55 levels across 13 series.** Series are in teaching order. `band` is the
204204
level's ELI band.
205205
206206
| Series | Title | Levels |
@@ -213,6 +213,8 @@ level's ELI band.
213213
| `functional` | Defaults & Functional | 4 |
214214
| `numpy` | NumPy: Buffers & Views | 5 |
215215
| `pandas` | Pandas: Tables as windows over a buffer | 5 |
216+
| `asyncio` | Asyncio: Coroutines, Tasks & the Event Loop | 5 |
217+
| `threading` | Threading: Threads, Shared State & the Data Race | 5 |
216218
| `mutation` | Mutation & Methods | 4 |
217219
| `functions` | Functions & Frames | 4 |
218220
| `plotting` | Plotting & Capstone | 3 |
@@ -321,6 +323,38 @@ level's ELI band.
321323
| `pd-iloc-view` | eli20 | A row-slice is a view |
322324
| `pd-copy-independent` | eliphd | Capstone: view vs copy |
323325
326+
### Concurrency
327+
328+
**`asyncio` — Asyncio: Coroutines, Tasks & the Event Loop** (the concurrency exemplar)
329+
330+
| id | band | name |
331+
|----|------|------|
332+
| `as-make-coro` | eli10 | Call an async def |
333+
| `as-create-task` | eli15 | Wrap a coroutine in a task |
334+
| `as-run-one` | eli15 | Run the loop (one task) |
335+
| `as-await-yields` | eli20 | await yields the tick |
336+
| `as-produce-consume` | eliphd | Capstone: producer & consumer |
337+
338+
**`threading` — Threading: Threads, Shared State & the Data Race** (the preemption exemplar)
339+
340+
| id | band | name |
341+
|----|------|------|
342+
| `th-make-thread` | eli10 | Build a thread |
343+
| `th-start-join` | eli15 | Start and join one thread |
344+
| `th-race` | eli20 | Two threads, one box: the lost update |
345+
| `th-lock` | eliphd | Fix the race with a lock |
346+
| `th-critical-capstone` | eliphd | Capstone: block, wait, and proceed in order |
347+
348+
The threading series is the memory-lens answer to "what is a data race?" A
349+
thread is a call running in its own frame that **shares the module heap** and is
350+
preempted **after every statement** (not just at a `yield`, the way a coroutine
351+
is). `join` is the scheduler that round-robins the started threads one statement
352+
at a time, so two threads touching the same box visibly interleave on it. `box[0]
353+
= box[0] + 1` written as two statements (`v = box[0]` then `box[0] = v + 1`)
354+
interleaves into a **lost update**: both read 0, both write 1, the box ends at 1
355+
not 2. Wrapping the read-modify-write in `lock.acquire()`/`release()` serializes
356+
it (a held lock **blocks** the caller until release), and the box ends at 2.
357+
324358
---
325359
326360
## 4. The lesson spec
@@ -376,10 +410,10 @@ The deep lesson ramps from "a grid of beads" (ELI5) to "copy-on-write, and why
376410
a view can mutate its source" (ELI-PHD), with the *DataFrame window over one
377411
buffer* schematic in the ELI5 section.
378412
379-
### 4.4 Worked exemplar — **asyncio** (design; on the roadmap)
413+
### 4.4 Worked exemplar — **asyncio** (shipped)
380414
381-
Concurrency is the second named exemplar. It is not built yet; this is the
382-
worked *design*, so it is ready to implement the same way numpy/pandas were.
415+
Concurrency is the second named exemplar. It is built (the `asyncio` series);
416+
the design below matches the implementation.
383417
384418
**Thesis:** a coroutine is an object; `await` is *suspension*, not blocking; the
385419
event loop is a **tick-based scheduler** that advances ready tasks one step at a
@@ -411,11 +445,11 @@ then the next), `producer-consumer` (a queue box between two tasks), and
411445
412446
| level (id) | band | task | goal |
413447
|------------|------|------|------|
414-
| `as-make-coro` | eli10 | `task = a.create_task(coro)` — a task is an object | `isTask('task')` |
415-
| `as-two-tasks` | eli15 | two tasks on the loop | both `ready` in the loop's queue |
416-
| `as-await-yields` | eli20 | one `await` yields the tick to the other task | the other task advanced (status/pc) |
417-
| `as-interleave` | eli20 | watch two tasks interleave over ticks | the loop's `tick` advanced, both progressed |
418-
| `as-produce-consume` | eliphd | capstone: producer/consumer through a queue | the queue drains; both tasks `done` |
448+
| `as-make-coro` | eli10 | `c = greet()` — calling an async def returns a coroutine | `isCoroutine('c')` |
449+
| `as-create-task` | eli15 | `t = a.create_task(c)` — a task is a named handle | `isTask('t')` |
450+
| `as-run-one` | eli15 | `a.run(t)` — the loop drives one task to done | `taskStatus('t','done')`, `loopRan(1)` |
451+
| `as-await-yields` | eli20 | `a.run(t1, t2)` — an `await` yields the tick to the other | both `done`; the interleaved stdout |
452+
| `as-produce-consume` | eliphd | capstone: producer/consumer through a shared list | both `done`; the consumer saw the write |
419453
420454
**Deep-lesson ramp:** ELI5 = "two people taking turns at one stove" (the loop is
421455
the stove); ELI10 = a task is a saved place in a function, `await` sets the place
@@ -442,13 +476,15 @@ teal = a buffer/data box, muted = a note).
442476
| 6 | iterator protocol | `iter` → `next`, the cursor | reusable |
443477
| 7 | refcount graph | who keeps an object alive | reusable |
444478
| 8 | buffer + overlapping views | two windows, one run | reusable (numpy/pandas) |
445-
| 9 | event-loop tick | the loop handing a tick back and forth | for `asyncio` |
446-
| 10 | producer–consumer | a queue box between two tasks | for `asyncio` |
447-
| 11 | lock / critical section | two tasks, one lock | for `threading` |
479+
| 9 | event-loop tick | the loop handing a tick back and forth | ✅ (`asyncio` deep lesson) |
480+
| 10 | producer–consumer | a queue box between two tasks | for `asyncio` depth |
481+
| 11 | lock / critical section | one gate: a thread holds it, the other waits | ✅ (`threading` deep lesson) |
448482
| 12 | DataFrame structure | columns as virtual slices of a row-major buffer | for `pandas` depth |
449483
| 13 | DOM tree | parent → child heap edges | for `beautifulsoup` |
450484
| 14 | regex groups | pattern → match → group chips | for `re` |
451485
| 15 | pickle freeze / restore | a frozen graph, restored with the same aliasing | for `pickle` |
486+
| 16 | **lost update (data race)** | two threads read-modify-write one box; a write is clobbered | ✅ (`threading` deep lesson) |
487+
| 17 | **thread / lock states** | not-started → running ⇄ blocked → done; HELD ⇄ free | reusable (concurrency) |
452488
453489
When you add a series, add its archetype(s) here with a one-line "teaches" so the
454490
diagram library stays a map, not a graveyard.
@@ -473,7 +509,9 @@ the whole reason we prioritize.
473509
| 5 | re, datetime, pytz | a compiled pattern; identity = value | `Pattern`/`Match`, `Datetime`/`TZ` | #14 | ★★ |
474510
| 6 | requests | a Response box → body → `.json()` dict | `Response` | (response chain) | ★ |
475511
| 7 | SQLAlchemy, pyodbc | a Model/Session; a Connection → row boxes | `Model`/`Session`, `Connection`/`Cursor` | (1-to-many links) | ★★ |
476-
| 8 | **asyncio**, threading/multiprocessing | a coroutine; the tick loop; a lock | `coroutine`/`task`/`eventloop` (or `Thread`) | #9, #10, #11 | ★★★ |
512+
| 8 | **asyncio** ✅ | a coroutine; the tick loop | `coroutine`/`task`/`eventloop` | #9 | ★★★ |
513+
| 8 | **threading** ✅ | a call in its own frame sharing the heap, preempted per statement; a lock serializes a critical section | `thread`/`lock` | #11, #16 | ★★★ |
514+
| 8 | multiprocessing | a process is a *separate* heap (no sharing) | `Process` | (fork picture) | ★★ |
477515
| 9 | matplotlib, plotly | a Figure/Axes **is an object**, not magic | `Figure`/`Axes`, `Figure` | (artist chips) | ★ |
478516
| 10 | capstones | multi-library assembly | (composes) | (mix) | — |
479517

‎src/engine/checks.ts‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,73 @@ export const preds = {
427427
return ta.coro !== tb.coro ? true : `'${a}' and '${b}' wrap the same coroutine.`;
428428
};
429429
},
430+
431+
/** `name` is a thread (the object `threading.Thread(...)` returns). */
432+
isThread(name: string) {
433+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
434+
const view = slotView(name, snapshot);
435+
return view?.type === 'thread' ? true : `'${name}' is not a thread — build one with threading.Thread(target, args).`;
436+
};
437+
},
438+
439+
/** `name` (a thread) is currently in `status` (not-started / running / blocked / done). */
440+
threadStatus(name: string, status: string) {
441+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
442+
const t = threadValue(name, snapshot);
443+
if (!t) return `Bind '${name}' as a thread first.`;
444+
return t.status === status ? true : `Thread '${name}' is ${t.status}, expected ${status}.`;
445+
};
446+
},
447+
448+
/** Two threads run *different* targets (independent units of work). */
449+
differentThreads(a: string, b: string) {
450+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
451+
const ta = threadValue(a, snapshot);
452+
const tb = threadValue(b, snapshot);
453+
if (!ta || !tb) return `Bind both '${a}' and '${b}' as threads.`;
454+
return ta.target !== tb.target ? true : `'${a}' and '${b}' run the same target.`;
455+
};
456+
},
457+
458+
/** `name` is a lock (the object `threading.Lock()` returns). */
459+
isLock(name: string) {
460+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
461+
const view = slotView(name, snapshot);
462+
return view?.type === 'lock' ? true : `'${name}' is not a lock — build one with threading.Lock().`;
463+
};
464+
},
465+
466+
/** `name` (a lock) is currently held. */
467+
lockHeld(name: string) {
468+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
469+
const l = lockValue(name, snapshot);
470+
if (!l) return `Bind '${name}' as a lock first.`;
471+
return l.held ? true : `Lock '${name}' is free, expected HELD.`;
472+
};
473+
},
474+
475+
/** `name` (a lock) is currently free. */
476+
lockFree(name: string) {
477+
return (snapshot: Snapshot, _runtime: MemoryState): boolean | string => {
478+
const l = lockValue(name, snapshot);
479+
if (!l) return `Bind '${name}' as a lock first.`;
480+
return l.held ? `Lock '${name}' is HELD, expected free.` : true;
481+
};
482+
},
483+
484+
/** `name[index]` holds the number `value` — the read a race is about. */
485+
listValueAt(name: string, index: number, value: number) {
486+
return (snapshot: Snapshot): boolean | string => {
487+
const module = snapshot.frames[snapshot.frames.length - 1];
488+
const id = module.locals[name];
489+
if (typeof id !== 'string' || !snapshot.heap[id]) return `Bind '${name}' first.`;
490+
const obj = snapshot.heap[id];
491+
const arr = obj.value as unknown[];
492+
if (!Array.isArray(arr)) return `'${name}' is not a list.`;
493+
const cell = arr[index];
494+
return Number(cell) === value ? true : `'${name}[${index}]' is ${cell}, expected ${value}.`;
495+
};
496+
},
430497
};
431498

432499
/** Read a task's normalized `{ coro, name, status }` from the snapshot, or null. */
@@ -442,6 +509,32 @@ function asyncTaskValue(name: string, snapshot: Snapshot): { coro: string; name:
442509
return { coro: v.coro, name: typeof v.name === 'string' ? v.name : '', status: v.status };
443510
}
444511

512+
/** Read a thread's normalized `{ target, name, status }` from the snapshot, or null. */
513+
function threadValue(name: string, snapshot: Snapshot): { target: string; name: string; status: string } | null {
514+
const module = snapshot.frames[snapshot.frames.length - 1];
515+
if (!module) return null;
516+
const id = module.locals[name];
517+
if (typeof id !== 'string' || !snapshot.heap[id]) return null;
518+
const obj = snapshot.heap[id];
519+
if (obj.type !== 'thread') return null;
520+
const v = obj.value as { target?: unknown; name?: unknown; status?: unknown };
521+
if (typeof v.target !== 'string' || typeof v.status !== 'string') return null;
522+
return { target: v.target, name: typeof v.name === 'string' ? v.name : '', status: v.status };
523+
}
524+
525+
/** Read a lock's normalized `{ held, holder }` from the snapshot, or null. */
526+
function lockValue(name: string, snapshot: Snapshot): { held: boolean; holder: string | null } | null {
527+
const module = snapshot.frames[snapshot.frames.length - 1];
528+
if (!module) return null;
529+
const id = module.locals[name];
530+
if (typeof id !== 'string' || !snapshot.heap[id]) return null;
531+
const obj = snapshot.heap[id];
532+
if (obj.type !== 'lock') return null;
533+
const v = obj.value as { held?: unknown; holder?: unknown };
534+
if (typeof v.held !== 'boolean') return null;
535+
return { held: v.held, holder: v.holder === null ? null : (typeof v.holder === 'string' ? v.holder : null) };
536+
}
537+
445538
/** Read a pandas Series/DataFrame's normalized `buffer` id from the snapshot. */
446539
function pandasBufferId(name: string, snapshot: Snapshot): string | null {
447540
const module = snapshot.frames[snapshot.frames.length - 1];

0 commit comments

Comments
 (0)