Programming with PythonData Formats and Pipelines › Day 96

Hands-on lab — Day 96: Concurrency and async Basics

Commands

Setup

cd labs/sections/programming-with-python/day-096-concurrency-and-async-basics
python3 --version
python3 -c "import sys, sysconfig; print(sys.version); print(sysconfig.get_config_var('Py_GIL_DISABLED'))"
python3 -c "import os; print(os.cpu_count(), 'logical CPUs')"

Run

bash tests/run_tests.sh
bash starter/02_check.sh
bash starter/02_check.sh examples/07_solutions.py
python3 examples/01_waiting.py
python3 examples/02_computing.py
python3 examples/03_blocking_coroutine.py
python3 examples/04_race.py
python3 examples/05_scheduler.py
python3 examples/06_timeouts.py

Test

bash tests/run_tests.sh

File tree

examples/01_waiting.py
examples/02_computing.py
examples/03_blocking_coroutine.py
examples/04_race.py
examples/05_scheduler.py
examples/06_timeouts.py
examples/07_solutions.py
examples/labkit.py
expected-output/blocking-coroutine.txt
expected-output/computing.txt
expected-output/FIELDS.md
expected-output/race.txt
expected-output/scheduler.txt
expected-output/starter-progress.txt
expected-output/test-run.txt
expected-output/timeouts.txt
expected-output/waiting.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/_progress.py
starter/00_brief.md
starter/01_exercises.py
starter/02_check.sh
tests/run_tests.sh
troubleshooting.md

Lab README

Day 096 lab — Waiting Versus Computing

Lesson

Purpose

Day 96 of 365. Somebody tells you to "make it concurrent". That instruction is not actionable, and acting on it anyway is how people lose weeks.

This lab replaces the instruction with a question you can answer:

Is this work waiting, or is it computing?

You then prove the answer to yourself by measuring it, three ways for waiting work and four ways for computing work, on your own machine, right now. Nothing here is taken on trust. By the end you will have watched:

  • twenty requests take 2.1 seconds in sequence and 0.12 seconds on an event loop — the same work, the same results, a seventeen-fold difference;
  • four CPU-bound tasks refuse to go faster with threads (1.01x) and then go 2.89x faster with processes, from a one-word edit;
  • five coroutines that say gather run strictly one at a time because one of them called time.sleep, with nothing raised and every answer correct;
  • an event loop starved for 211 milliseconds by a task that had nothing to do with it;
  • a shared counter lose 290,878 of 400,000 increments, and then lose none;
  • two locks deadlock, in eight lines, and stop deadlocking after one rule;
  • and an event loop you wrote yourself, in about a dozen lines, after which async/await stops being magic because you have written the loop.

Those figures are from the authoring machine on one day. The shape is what travels, and the shape is what the tests assert — never a millisecond.

Learning objectives

By the end of this lab you will be able to:

  • State the difference between concurrency (a structure: several things in progress) and parallelism (a hardware fact: several things executing at the same instant), and say which one your problem actually needs.
  • Classify a piece of work as waiting or computing, and pick the model that suits it without guessing.
  • Use ThreadPoolExecutor and ProcessPoolExecutor through the same concurrent.futures interface, and explain why swapping one for the other is a one-word edit with completely different consequences.
  • Say what the global interpreter lock actually protects — interpreter state, not your data — and why I/O releasing it is the reason threads help with waiting.
  • Report the GIL status of the interpreter in front of you rather than the one in a book, using sysconfig.get_config_var("Py_GIL_DISABLED").
  • Write coroutines, drive them with asyncio.run, and combine them with asyncio.gather and asyncio.TaskGroup, choosing between the two on the question of whether partial success is a real answer.
  • Recognise a blocking call inside a coroutine, measure the damage it does to unrelated tasks on the same loop, and repair it with asyncio.to_thread.
  • Apply a timeout with asyncio.timeout, and describe cancellation accurately: an exception delivered inside the task at its next await, so finally blocks run and nothing leaks.
  • Reproduce a lost-update race deliberately, fix it with a threading.Lock, and explain why handing subtotals down a queue.Queue is usually better than any lock.
  • Produce a deadlock from two locks taken in opposite orders and remove it with a consistent lock ordering.
  • Build a cooperative scheduler from generators — a ready queue, a sleeping list and a loop — and map each part onto what asyncio does.
  • Report a performance result honestly: several runs, the spread stated, the machine named, and a ratio rather than a stopwatch reading.

Prerequisites

  • Day 43 — a working python3 on your PATH.
  • Day 82 — the fixture-server pattern: a real HTTP server on 127.0.0.1 standing in for the internet, which is how this lab measures waiting without needing a network.
  • Day 84 — running a local server inside a test harness and shutting it down cleanly.
  • Day 63 onwards — functions, generators and yield, which exercise 8 builds an event loop out of.
  • Nothing else. No third-party package is used, and the test suite fails if any file in this lab imports one.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64, 14 logical CPUs
Linux (any current distribution) Expected to behave identically. ProcessPoolExecutor defaults to the fork start method there rather than spawn, which usually makes the process column look slightly better, not worse
Windows Use WSL and follow the Linux path. The harness scripts need bash and mktemp -d; native Windows was not tested and no output is claimed for it

The suite refuses to run its process comparison on a single-CPU machine and says so, rather than reporting a meaningless ratio.

Hardware requirements

Two or more logical CPUs. That is the only real requirement, and it is checked: with one CPU, "processes are faster than threads" is not a claim that can be tested.

More cores make the process column look better and change nothing else. No GPU, no network, no disk to speak of — the whole lab writes nothing outside a temporary directory.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 asyncio.TaskGroup, asyncio.timeout and except* all arrived in 3.11
bash 3.2 3.2.57 The two harness scripts

Standard library only: asyncio, threading, multiprocessing (through concurrent.futures), queue, time, urllib, http.server, socket, collections, statistics, sysconfig.

Check your interpreter, including the fact this lab cares about most:

python3 --version
python3 -c "import sys, sysconfig; print(sys.version); print(sysconfig.get_config_var('Py_GIL_DISABLED'))"
python3 -c "import os; print(os.cpu_count(), 'logical CPUs')"

Py_GIL_DISABLED printing 0 means your interpreter has the global interpreter lock, which is what every measurement in this lab assumes and reports. If it prints 1 you have a free-threaded build (PEP 703), the threads-do-not-help result should not hold for you, and the suite skips that one check and says so instead of failing you for having better tooling.

Free and open-source options

All of it is free, and no part of this lab is degraded without an account.

  • Python and its standard library (PSF licence) provide every concurrency model used here. There is nothing to install.
  • trio (Apache 2.0 / MIT) is the best-known alternative async library, built around structured concurrency — its nurseries are the idea that asyncio.TaskGroup later brought into the standard library. Not installed here; nothing in this lab reproduces its output.
  • anyio (MIT) lets one codebase run on either asyncio or trio. Not installed here.
  • gevent (MIT) takes the opposite approach: it monkey-patches the standard library so ordinary blocking code becomes cooperative without async/await. Not installed here.
  • Celery (BSD) is where you go when the work must outlive the process and be spread over machines rather than cores. Not installed here.

The lesson's Alternatives section covers when each is the right call. This lab runs only what ships with Python, so it works offline on a fresh machine.

Installation

None. Change into this directory and start.

cd labs/sections/programming-with-python/day-096-concurrency-and-async-basics
python3 --version

If your interpreter lives somewhere unusual, both scripts take an override rather than guessing:

PYTHON=/path/to/python3 bash tests/run_tests.sh

File structure

day-096-concurrency-and-async-basics/
├── README.md                      this file
├── metadata.yml                   lab metadata and the recorded run
├── security.md                    what this lab does to your machine
├── troubleshooting.md             grouped by the message you actually see
├── requirements/
│   ├── README.md                  versions, the calibration, what is absent
│   └── requirements.txt           empty of packages, on purpose
├── starter/                       YOUR work happens here
│   ├── 00_brief.md                the situation, and the one question
│   ├── 01_exercises.py            eight exercises, each with its approach named
│   ├── 02_check.sh                "N of 8 exercises complete."
│   └── _progress.py               the checker behind it; behaviour and ratios only
├── examples/                      the reference. Read AFTER you have tried
│   ├── labkit.py                  fixture server, CPU task, timing helpers
│   ├── 01_waiting.py              20 requests: sequential, threads, asyncio
│   ├── 02_computing.py            4 prime counts: + processes, and the flip
│   ├── 03_blocking_coroutine.py   the rule broken, measured, and repaired
│   ├── 04_race.py                 a lost-update race, three fixes, a deadlock
│   ├── 05_scheduler.py            an event loop in forty lines of generators
│   ├── 06_timeouts.py             cancellation, timeouts, gather against TaskGroup
│   └── 07_solutions.py            the eight reference answers
├── tests/
│   └── run_tests.sh               58 checks of shapes and values
└── expected-output/               captured from a real run on 2026-08-16
    ├── FIELDS.md                  what must match and what may differ
    ├── waiting.txt                ├─ the six example scripts,
    ├── computing.txt              │  captured verbatim
    ├── blocking-coroutine.txt     │
    ├── race.txt                   │
    ├── scheduler.txt              │
    ├── timeouts.txt               ┘
    ├── starter-progress.txt       0 of 8 before, 8 of 8 after
    └── test-run.txt               the full harness run

How to run

## 1. The whole thing. Start here — it should be green before you change
##    anything, and green again when you have finished. Takes about 40s.
bash tests/run_tests.sh
echo "exit code: $?"

## 2. Read the brief. It is three minutes and it is the point of the day.
##    starter/00_brief.md

## 3. Find out where you stand. It will say 0 of 8, and name each one.
bash starter/02_check.sh

## 4. Now do the work in starter/01_exercises.py, re-running step 3 as you go.
##    Look at the RATIOS the checker prints, especially for exercises 4 and 5.

## --- everything below is the reference. Look after you have tried. ---

## 5. Waiting work, three ways. Watch threads and asyncio both collapse it.
python3 examples/01_waiting.py

## 6. Computing work, four ways. Watch the answer flip completely.
python3 examples/02_computing.py

## 7. The rule you must not break, broken on purpose and then repaired twice.
python3 examples/03_blocking_coroutine.py

## 8. A counter that loses increments, three fixes, and a deadlock.
python3 examples/04_race.py

## 9. An event loop built from generators, so await stops being magic.
python3 examples/05_scheduler.py

## 10. Cancellation, timeouts, and gather against TaskGroup.
python3 examples/06_timeouts.py

## 11. The reference answers, checked the same way your own work is.
bash starter/02_check.sh examples/07_solutions.py

What the commands do

bash tests/run_tests.sh runs all six example scripts, parses the machine-readable RESULT lines they print, and applies its own thresholds to them — 58 checks in all. It deliberately does not trust the scripts' own SHAPE verdicts: a test that asks the code under test whether it passed is not a test. It then runs the starter checker in both states, sabotages the reference answer to exercise 2 so that it is secretly sequential, and confirms the checker catches it on speed while every returned value is still correct. Everything happens in a temporary directory removed on exit.

bash starter/02_check.sh imports your starter/01_exercises.py, calls each of the eight functions with controlled inputs, and reports which are complete. It checks behaviour (are the twenty bodies right, and in order?) and ratios (is the threaded version at least 2.5x faster than the sequential one on this machine, right now?). It never inspects how you wrote anything. Pass it a path to check a different module, which is how the reference answers are verified.

python3 examples/01_waiting.py starts the fixture server, warms it, then runs twenty requests sequentially, through a 20-thread pool, and through an event loop — three times each — and prints every sample, the median and the spread.

python3 examples/02_computing.py does the same for four prime counts, adding a process pool, and prints the interpreter's Py_GIL_DISABLED value so the numbers are attached to the build that produced them.

python3 examples/03_blocking_coroutine.py gathers five coroutines that call time.sleep, then the same five with await asyncio.sleep, then the same five with asyncio.to_thread — and separately measures how late an unrelated 10 ms heartbeat runs while the loop is blocked.

python3 examples/04_race.py runs one shared counter with eight threads at the default switch interval, then at a microsecond, then with a lock, then with per-thread subtotals posted to a queue.Queue, and finishes with a real deadlock detected by timeout and the ordering rule that removes it.

python3 examples/05_scheduler.py runs a scheduler built from a deque, a sleeping list and a while loop, over generators that yield to pause.

python3 examples/06_timeouts.py times out a request that will not finish, shows the cancelled task's finally block running, and contrasts asyncio.gather(return_exceptions=True) with asyncio.TaskGroup.

Expected output

The harness ends with a real captured line:

58 checks, 0 failure(s).

and exits 0. The starter reports 0 of 8 exercises complete. with exit 1 before you begin and 8 of 8 exercises complete. with exit 0 when you are done.

The two measurements the whole day turns on, captured on the authoring machine — your seconds will differ and your ratios should not:

timings                                                                    (waiting work)
  sequential (one at a time)         runs:  2.101,  2.099,  2.117   median  2.101s   spread 0.017s
  threads (ThreadPoolExecutor 20)    runs:  0.172,  0.142,  0.172   median  0.172s   spread 0.031s
  asyncio (one thread, one loop)     runs:  0.117,  0.114,  0.176   median  0.117s   spread 0.062s

timings                                                                  (computing work)
  sequential (one at a time)         runs:  1.417,  1.418,  1.413   median  1.417s   spread 0.005s
  threads (ThreadPoolExecutor 4)     runs:  1.412,  1.405,  1.410   median  1.410s   spread 0.007s
  processes (ProcessPoolExecutor 4)  runs:  0.488,  0.504,  0.490   median  0.490s   spread 0.016s
  asyncio (one thread, one loop)     runs:  1.432,  1.440,  1.451   median  1.440s   spread 0.020s

Threads: 12.2x on waiting work, 1.01x on computing work. Same code shape, opposite result. That is the lab.

The race, which is the other thing worth quoting:

1. the naive counter at the interpreter's DEFAULT switch interval
   run 1: 400,000   lost 0
2. the same code with the switch interval at 1e-06 s
   run 1: 111,226   lost 288,774
3. the same counter, one lock
   run 1: 400,000   lost 0

Read expected-output/FIELDS.md before comparing anything: it lists exactly which values must match on your machine (the prime counts, the counter totals, the scheduler's interleaving order) and which are expected to differ (every elapsed time, every speed-up ratio's precise value, and the number of increments the unsafe counter loses).

Validation steps

  1. bash tests/run_tests.sh ends with 58 checks, 0 failure(s). and exits 0.
  2. Waiting work: threads and asyncio are each at least 4x faster than sequential, and all three return 20 well-formed bodies in input order.
  3. Computing work: threads are below 1.5x sequential, processes are at least 1.5x, and processes beat threads by at least a further 1.4x.
  4. Every approach in step 3 still answers 41538 — a fast wrong answer is not an answer.
  5. Gathering five blocking coroutines takes the serial time (at least 0.9 s for 5 x 0.2 s), and both repairs are at least 2.5x faster.
  6. The blocked loop starves an unrelated heartbeat by at least 3x the healthy gap, while all three versions return identical correct results.
  7. The unprotected counter loses more than 1000 increments; the locked and queued versions total exactly 400000.
  8. Two locks taken in opposite orders deadlock; taken in one order they do not.
  9. The generator scheduler interleaves 3, 2 and 1 step tasks as alpha beta gamma alpha beta alpha, and a task that never yields runs all four of its steps before any other task starts.
  10. A timeout fires at the caller's 0.15 s budget rather than the work's 0.40 s, and the cancelled task's finally block appears in the log.
  11. gather leaves 2 of 3 tasks finished; TaskGroup cancels 2 siblings.
  12. The starter reports 0 of 8 with a non-zero exit, the reference reports 8 of 8 with exit 0, and a "threaded" answer that is secretly sequential is caught on speed while all its values are correct.
  13. After the harness finishes, no __pycache__ directory and no temporary file survive anywhere in this directory.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

58 checks, exit 0 when they all pass and non-zero otherwise.

Why the tests are written the way they are — this is the part worth reading, because timing tests are usually terrible and these try not to be.

  • No check asserts a duration. Every speed check is a ratio between two things measured in the same run on the same machine, with a margin wide enough to survive a laptop on battery or a loaded CI runner. The captured thread speed-up on waiting work was 12.2x; the assertion is "at least 4x".
  • The race is forced, and the forcing is stated. A naive lost-update loop did not lose a single increment on this interpreter across 20 dedicated trials at the default 5 ms switch interval. Rather than pretend otherwise or write a test that fails one run in fifty, examples/04_race.py drops the switch interval to 1 microsecond, which makes the same race land on every run. Nothing about the buggy code changes — only how often the interpreter considers handing the thread to somebody else. The script prints both results, so you see the honest version first.
  • The suite parses RESULT lines rather than trusting SHAPE lines. The scripts print their own verdicts for a human reader; the tests recompute them.
  • The suite proves it can fail. It sabotages the reference answer to exercise 2 into a sequential loop that still returns every correct value, and asserts the checker rejects it.
  • The GIL check adapts to the build. On a free-threaded interpreter (Py_GIL_DISABLED is 1) the "threads do not help" check would be wrong, so it is skipped with a printed note rather than failed.

Overrides, if your interpreter is somewhere unusual:

PYTHON=/path/to/python3 bash tests/run_tests.sh

Cleanup

find . -type d -name __pycache__ -prune -exec rm -rf -- {} +

tests/run_tests.sh and starter/02_check.sh both set PYTHONDONTWRITEBYTECODE=1 and build everything inside mktemp -d, removed in a trap, so if you only ran those there is nothing to clean up — and the suite asserts as much. The command above matters only if you ran an example script by hand without that variable set.

Nothing else is created. No database, no log file, no socket left listening: the fixture server is shut down and closed in a finally block, and it binds to an ephemeral port so it cannot collide with anything you are running.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • Can't pickle <function ...> from ProcessPoolExecutor — you passed a lambda, a closure or a locally defined function. Child processes import your module to find the target, so it must be defined at module level.
  • RuntimeError: asyncio.run() cannot be called from a running event loop — you called asyncio.run inside a coroutine. It is the boundary between synchronous code and the loop, used once, at the top.
  • A coroutine that never runs — you called it and never awaited it, or you used create_task and never gave the loop a chance to start it. Python warns coroutine ... was never awaited for the first case and says nothing at all for the second.
  • The threaded version is no faster — check what the work is. If it never waits, that is the correct result, and it is exercise 4.
  • The process version is no faster — the tasks may be too small for the start-up and pickling cost, or your machine may have too few cores.
  • 8 of 8 will not appear even though the answers look right — the two fetch exercises and the process exercise are judged on speed as well as correctness. The checker prints the ratio it measured and the one it needs.
  • The unsafe counter does not lose anything for you — that is a real and reportable observation, not a broken lab. See the note in troubleshooting.md.

Security notes

security.md has the full account. In short: nothing here reaches the internet, runs sudo, needs a credential, or installs anything, and the test suite checks each of those rather than promising them.

The two points specific to this day:

  • The only sockets are on the loopback address. examples/labkit.py binds to 127.0.0.1 on an ephemeral port, so the fixture server is not reachable from another machine and cannot collide with a port you are already using. The suite asserts that no URL anywhere in this lab names any host but the loopback address.
  • Concurrency is itself a security surface, and this lab shows two of its edges. A race condition on a shared counter is the same bug as a race condition on a permissions check or a balance — the lost update in 04_race.py is a toy version of a time-of-check-to-time-of-use flaw. And a blocked event loop is a denial of service you inflict on yourself: one slow synchronous call in one handler stops every other request on that worker, which is exactly what 03_blocking_coroutine.py measures.

sys.setswitchinterval is process-wide, so 04_race.py and the starter checker both restore the previous value in a finally block, and the test suite verifies that they do.

Extension exercises

  1. Find the crossover point. examples/02_computing.py uses four tasks of roughly 350 ms each. Shrink the prime limit until ProcessPoolExecutor is slower than sequential code, and find the size at which it breaks even. Write down the number, then work out what it is really measuring — process start-up, argument pickling, or result pickling — by timing a pool that is created once and reused against one created per call.
  2. Make the race land at the default switch interval. The naive counter in 04_race.py lost nothing at 5 ms on the authoring machine. Get it to lose increments without touching sys.setswitchinterval: more threads, a longer read-modify-write, an object with a property, work between the read and the write. Report how many trials you needed, and what that tells you about relying on tests to catch this class of bug.
  3. Give your scheduler a socket. examples/05_scheduler.py jumps its clock forward when everything is asleep. Replace that with a real selectors.DefaultSelector, let a task yield a socket it wants to read from, and block in select() until the operating system says one is ready. That single change turns a toy into the thing asyncio actually is.
  4. Measure asyncio.to_thread's ceiling. It runs on the loop's default executor, which has a bounded number of workers. Raise the number of concurrent to_thread calls until the time stops improving, find the bound, then look up how to change it — and write a paragraph on why the default is not simply "unlimited".
  5. Port exercise 3 to asyncio.Semaphore. Twenty concurrent requests is fine against your own fixture server and rude against somebody else's API. Add a semaphore that allows five at a time, measure the new figure, and check it lands where arithmetic says it should. Then explain why a semaphore is the right tool here and a ThreadPoolExecutor(max_workers=5) would be a different thing that happens to look similar.
  • Previous day: Day 95 — Dates, Times and Time Zones (labs/sections/programming-with-python/day-095-dates-times-and-time-zones/).
  • Next day: Day 97 — Logging and Configuration (labs/sections/programming-with-python/day-097-logging-and-configuration/).
  • Week 14 project: the week's project directory (labs/sections/programming-with-python/projects/week-14/), where the pipeline you build has both a waiting stage and a computing stage, and has to pick correctly for each.

Expected output

FIELDS.md

# What must match, and what is allowed to differ

Every file in this directory was captured from a real run on the authoring
machine on 2026-08-16:

- macOS 26.5.2, Apple Silicon (arm64), 14 logical CPUs
- Python 3.14.0, standard library only
- `Py_GIL_DISABLED` is `0` — this is a normal build **with** the global
  interpreter lock, not a free-threaded one
- default thread switch interval `0.005` s
- bash 3.2.57

This is the most timing-heavy lab in the section, so it needs the clearest
statement of what a "correct" run looks like on somebody else's machine.

## The rule

**No number of seconds in any of these captures is a claim about your
machine.** They are a record of one machine on one day. What must reproduce
is the *shape*: which approach is faster than which, and by roughly how
much. `tests/run_tests.sh` asserts only shapes, with wide margins, which is
why it passes on hardware quite unlike the machine above.

## Must match exactly

| Value | Where | Why it cannot vary |
| --- | --- | --- |
| `41538` primes below 500,000 | `computing.txt` | Arithmetic. A different answer is a bug |
| `33860` primes below 400,000 | `starter-progress.txt` | Same |
| `400,000` for the locked and queued counters | `race.txt` | 8 x 50,000, exactly. Losing even one is the failure |
| `alpha beta gamma alpha beta alpha` | `scheduler.txt` | Round-robin over 3, 2 and 1 steps has one correct order |
| `greedy greedy greedy greedy polite polite polite` | `scheduler.txt` | A task that never yields cannot be interleaved |
| `20 bodies, all well formed: yes` three times | `waiting.txt` | All three approaches must return all twenty |
| `2 of 3 finished` with `gather` | `timeouts.txt` | `return_exceptions=True` has defined behaviour |
| `2 sibling(s) were cancelled` with `TaskGroup` | `timeouts.txt` | Same |
| `0 of 8` then `8 of 8` | `starter-progress.txt` | The checker's two end states |
| `58 checks, 0 failure(s).` | `test-run.txt` | The suite's own result line |

## Must hold, but the numbers will differ

| Shape | Captured here | Margin the tests use |
| --- | --- | --- |
| Threads beat sequential on **waiting** work | 12.2x | at least 4x |
| asyncio beats sequential on **waiting** work | 17.9x | at least 4x |
| Threads do **not** beat sequential on **computing** work | 1.01x | must be below 1.5x |
| Processes **do** beat sequential on computing work | 2.89x | at least 1.5x |
| asyncio does not beat sequential on computing work | 0.98x | must be below 1.5x |
| `await asyncio.sleep` beats a blocking coroutine | 5.1x | at least 2.5x |
| `asyncio.to_thread` beats a blocking coroutine | 4.9x | at least 2.5x |
| A blocked loop starves an unrelated task | 211 ms against 11 ms | at least 3x the healthy gap |
| A timeout fires at the budget, not at the work | 0.1511 s for a 0.15 s budget | below 0.35 s and above 0.10 s |

Note the asyncio row on computing work: it came out at **0.98x**, meaning
marginally *slower* than plain sequential code. That is not an error and it
has not been rounded away. An event loop that never gets an `await` is
sequential execution plus the cost of running a loop.

## Expected to differ, and why

- **Every elapsed time and every `spread` figure.** A slower CPU raises the
  computing numbers; the waiting numbers are pinned near `0.100 s` per
  request by the fixture server's sleep rather than by your hardware.
- **The process speed-up.** Captured at 2.89x with 4 workers on 14 cores. On
  a 2-core machine expect closer to 1.5-2x, which still passes. On a single
  core the comparison is meaningless, and `tests/run_tests.sh` fails early
  with a clear message rather than pretending otherwise.
- **The thread speed-up on waiting work.** Bounded by the pool size (20) and
  by how quickly your machine can start 20 threads and open 20 sockets.
- **`race_lost_at_tight_interval`.** Captured at 290,878 of 400,000 lost. The
  count varies by tens of thousands between runs; only "greater than zero,
  and by a wide margin" is asserted.

## The one that may legitimately differ in KIND

`race_lost_at_default_interval` is **0** in this capture, across three runs
at the interpreter's normal 5 ms switch interval — and it was 0 across 20
further dedicated trials of the identical configuration during authoring. On a busier machine, a different CPython
version, or a machine with fewer cores, you may well see it lose increments.

Both outcomes are correct observations, and neither is asserted by the test
suite. The unsafe counter is broken either way; the default switch interval
merely determines how often the breakage is visible. `examples/04_race.py`
prints whichever you get and explains it, and the lesson's "Examples in
practice" section discusses why.

## Windows

Not tested, and no output is claimed for it. Use WSL and follow the Linux
path. Two things would differ on native Windows even so: the shell scripts
need bash, and `ProcessPoolExecutor` uses the spawn start method there — as
it already does on macOS, which is why the process start-up cost visible in
`computing.txt` is representative rather than optimistic.

blocking-coroutine.txt

Day 096 — a blocking call inside a coroutine
5 tasks, each waiting 0.20s, gathered on one event loop

timings
  async def + time.sleep  (broken)   runs:  1.020,  1.020,  1.021   median  1.020s   spread 0.001s
  async def + await asyncio.sleep    runs:  0.202,  0.203,  0.203   median  0.203s   spread 0.001s
  async def + asyncio.to_thread      runs:  0.212,  0.206,  0.207   median  0.207s   spread 0.006s

what the numbers mean
  5 tasks x 0.20s = 1.00s if nothing overlaps.
  The broken version took 1.020s, which is the serial floor. It ran
  one task at a time while the code said gather. Nothing raised.
  Both repairs took about 0.20s, which is one task's worth of waiting.

correctness — all three produce the same answers, which is the trap
  blocking    [0, 1, 2, 3, 4]  correct: yes
  awaiting    [0, 1, 2, 3, 4]  correct: yes
  to_thread   [0, 1, 2, 3, 4]  correct: yes

collateral damage to an unrelated task on the same loop
  largest heartbeat gap while a coroutine BLOCKED :   211.3 ms
  largest heartbeat gap while a coroutine AWAITED :    11.4 ms
  The heartbeat asked for a tick every 10 ms and had nothing to do with the
  sleeping task. It was starved anyway, because there is only one thread.

RESULT blocking_gathered_s 1.0204
RESULT awaiting_gathered_s 0.2027
RESULT to_thread_gathered_s 0.2069
RESULT blocking_vs_await_speedup 5.0350
RESULT blocking_vs_to_thread_speedup 4.9326
RESULT blocked_heartbeat_gap_ms 211.2716
RESULT healthy_heartbeat_gap_ms 11.3789
SHAPE blocking_in_a_coroutine_serialises_the_loop yes   (took 1.020s against a serial floor of 1.00s)
SHAPE await_asyncio_sleep_repairs_it yes   (awaiting is 5.0x faster; the claim is >= 2.5x)
SHAPE to_thread_repairs_it_for_code_you_cannot_change yes   (to_thread is 4.9x faster; the claim is >= 2.5x)
SHAPE blocking_starves_an_unrelated_task yes   (gap 211.3 ms against 11.4 ms when healthy)

How to find this in code you did not write: any call inside an `async def`
that is not preceded by `await` and is not obviously pure computation is a
suspect. `time.sleep`, `requests.get`, `open(...).read()` on a network mount,
and every synchronous database driver are the usual four.

computing.txt

Day 096 — computing work: 4 prime counts, four ways
python 3.14.0   cpu_count 14   Py_GIL_DISABLED 0
each task counts the primes below 500,000 by trial division; 4 tasks; 3 runs each
This interpreter was built WITH the global interpreter lock (Py_GIL_DISABLED is 0), which is what the numbers below reflect.

timings
  sequential (one at a time)         runs:  1.417,  1.418,  1.413   median  1.417s   spread 0.005s
  threads (ThreadPoolExecutor 4)     runs:  1.412,  1.405,  1.410   median  1.410s   spread 0.007s
  processes (ProcessPoolExecutor 4)  runs:  0.488,  0.504,  0.490   median  0.490s   spread 0.016s
  asyncio (one thread, one loop)     runs:  1.432,  1.440,  1.451   median  1.440s   spread 0.020s

correctness first
  sequential   [41538, 41538, 41538, 41538]  correct: yes
  threads      [41538, 41538, 41538, 41538]  correct: yes
  processes    [41538, 41538, 41538, 41538]  correct: yes
  asyncio      [41538, 41538, 41538, 41538]  correct: yes
  there really are 41,538 primes below 500,000

RESULT computing_sequential_s 1.4172
RESULT computing_threaded_s 1.4097
RESULT computing_processes_s 0.4903
RESULT computing_asyncio_s 1.4397
RESULT computing_threaded_speedup 1.0053
RESULT computing_processes_speedup 2.8905
RESULT computing_asyncio_speedup 0.9844
SHAPE threads_do_not_help_computing_work yes   (threads are 1.01x sequential; the claim is < 1.5x)
SHAPE processes_do_help_computing_work yes   (processes are 2.89x sequential; the claim is >= 1.5x)
SHAPE asyncio_does_not_help_computing_work yes   (asyncio is 0.98x sequential; the claim is < 1.5x)

The one-line rule, earned rather than asserted:
  waiting work  -> threads or an event loop; the waiting overlaps
  computing work-> processes; nothing else has more than one interpreter lock

Note what processes did NOT give you: a 4x speedup from 4 workers. Starting
them, pickling to them and pickling back are real costs, and they are paid
whether or not the work was big enough to deserve them.

race.txt

Day 096 — a counter that loses increments
8 threads x 50,000 increments each; expected total 400,000
default switch interval on this interpreter: 0.005 s

1. the naive counter at the interpreter's DEFAULT switch interval
   run 1: 400,000   lost 0
   run 2: 400,000   lost 0
   run 3: 400,000   lost 0
   Report what you see, not what the textbook says. On this machine the
   read-add-write finishes well inside one thread's 5 ms slice, so the race
   almost never lands. That is a narrow window, not a safe program.

2. the same code with the switch interval at 1e-06 s
   run 1: 111,226   lost 288,774
   run 2: 109,122   lost 290,878
   run 3: 123,945   lost 276,055
   The code did not change. Only the frequency of thread switches did.
   Every increment lost here was an increment that could be lost at the
   default setting too, on a busier machine, under a longer run, one day.

3. the same counter, one lock
   run 1: 400,000   lost 0
   run 2: 400,000   lost 0
   run 3: 400,000   lost 0
   Exact, at the switch interval that broke the version above.

4. the answer that is usually better than a lock: stop sharing
   run 1: 400,000   lost 0
   run 2: 400,000   lost 0
   run 3: 400,000   lost 0
   Each worker counts into a local variable and posts one subtotal to a
   queue. There is no shared mutable state, so there is no lock to forget,
   no lock ordering to get wrong, and nothing to deadlock.

5. deadlock, in eight lines
   two locks taken in opposite orders  -> deadlocked: yes
   the same two taken in the same order -> completed: yes
   Each thread held one lock and waited for the other. Nothing was busy;
   nothing errored; the program simply stopped. The timeout above is a
   detector. The fix is the ordering rule.

RESULT race_lost_at_default_interval 0
RESULT race_lost_at_tight_interval 290878
RESULT locked_total 400000
RESULT queued_total 400000
RESULT expected_total 400000
SHAPE unlocked_counter_loses_increments yes   (lost 290,878 of 400,000)
SHAPE locked_counter_loses_nothing yes   (3 runs, all exactly 400,000)
SHAPE queue_version_loses_nothing yes   (3 runs, all exactly 400,000)
SHAPE opposite_lock_order_deadlocks yes   (detected with a timeout; it would otherwise hang forever)
SHAPE consistent_lock_order_does_not yes   (same locks, same threads, one rule)

scheduler.txt

Day 096 — a cooperative scheduler built from generators

1. three tasks, round-robin
   trace:  alpha:0 beta:1 gamma:2 alpha:3 beta:4 alpha:5
   order:  alpha beta gamma alpha beta alpha
   Each task ran ONE step and then gave the loop back. That interleaving
   is concurrency, and it happened in a single thread with no lock in
   sight — which is why no increment can be lost between two yields.
   returned: alpha did 3 steps
   returned: beta did 2 steps
   returned: gamma did 1 step

2. a task that waits, while the others carry on
   trace:  napper-start:0 worker:1 worker:2 worker:3 worker:4 napper-woke:5 worker:5
   napper yielded a sleep instruction and left the ready queue entirely.
   worker had the loop to itself until napper's tick came round. That is
   exactly what an await on a socket read does in a real event loop.

3. the same loop, with one task that refuses to yield
   trace:  greedy:0 greedy:0 greedy:0 greedy:0 polite:1 polite:2 polite:3
   polite did not run once until greedy had finished all four steps.
   Nothing errored. Nothing was slow. The loop simply never got the
   thread back, because `next(task)` does not return until the task
   reaches a yield. That is the blocking call in 03_blocking_coroutine.py,
   seen from inside the loop rather than from outside it.

RESULT round_robin_order alpha,beta,gamma,alpha,beta,alpha
SHAPE scheduler_interleaves_tasks yes   (expected 'alpha beta gamma alpha beta alpha')
RESULT greedy_order greedy,greedy,greedy,greedy,polite,polite,polite
SHAPE a_task_that_never_yields_starves_the_others yes   (got 'greedy greedy greedy greedy polite polite polite')

Forty lines. A ready queue, a sleeping list and a while loop. Everything
asyncio adds on top of this — socket readiness from the operating system,
cancellation, timeouts, TaskGroups, thread offloading — is machinery around
that same idea, not a different idea.

starter-progress.txt

$ bash starter/02_check.sh
Checking 01_exercises.py
python 3.14.0   default switch interval 0.005 s

  [open] 1. fetch_all_sequentially
         not started
  [open] 2. fetch_all_with_threads
         not started
  [open] 3. fetch_all_with_asyncio
         not started
  [open] 4. count_primes_with_threads
         not started
  [open] 5. count_primes_with_processes
         not started
  [open] 6. wait_without_blocking_the_loop
         not started
  [open] 7. counter_that_loses_nothing
         not started
  [open] 8. round_robin
         not started

0 of 8 exercises complete.
exit code: 1

$ bash starter/02_check.sh examples/07_solutions.py
Checking 07_solutions.py
python 3.14.0   default switch interval 0.005 s

  [ok  ] 1. fetch_all_sequentially
         12 bodies in order, 0.658s
  [ok  ] 2. fetch_all_with_threads
         4.2x faster than sequential (0.156s); needs >= 2.5x
  [ok  ] 3. fetch_all_with_asyncio
         10.6x faster than sequential (0.062s); needs >= 2.5x
  [ok  ] 4. count_primes_with_threads
         counts correct; 1.01x sequential — threads do not help here, and are not required to
  [ok  ] 5. count_primes_with_processes
         counts correct; 2.73x sequential (0.386s); needs >= 1.4x
  [ok  ] 6. wait_without_blocking_the_loop
         0.156s against a serial floor of 0.75s; needs < 0.45s
  [ok  ] 7. counter_that_loses_nothing
         3 runs at a 1e-06s switch interval; all exactly 400,000
  [ok  ] 8. round_robin
         expected ['a', 'b', 'c', 'a', 'b', 'a'], got ['a', 'b', 'c', 'a', 'b', 'a']

8 of 8 exercises complete.
exit code: 0

test-run.txt

Day 096 — Concurrency and async Basics
python3:          3.14.0
cpu_count:        14
Py_GIL_DISABLED:  0
switch interval:  0.005 s
work:             a temporary directory, removed when this script exits

0. The interpreter is the one these measurements assume
  ok: python is 3.11 or newer (asyncio.timeout and TaskGroup are required)
  ok: at least two usable CPUs, or the process comparison cannot mean anything

1. Waiting work: threads and an event loop both collapse it
  ok: 01_waiting.py exits 0
  ok: the sequential baseline really did pay all 20 waits (>= 1.5s of waiting)
  ok: threads are at least 4x faster than sequential on waiting work
  ok: asyncio is at least 4x faster than sequential on waiting work
  ok: all three approaches returned 20 well-formed bodies
  ok: order is preserved: the first body is the one for /item/1

2. Computing work: the answer flips, and that is the whole day
  ok: 02_computing.py exits 0
  ok: threads do NOT meaningfully speed up computing work (< 1.5x)
  ok: processes DO speed up computing work (>= 1.5x)
  ok: asyncio does NOT speed up computing work either (< 1.5x)
  ok: processes beat threads on this workload by a clear margin
  ok: every approach still produced the right answer: 41538 primes below 500,000
  ok: the script reports the GIL status of the interpreter it actually ran on

3. A blocking call inside a coroutine, and its two repairs
  ok: 03_blocking_coroutine.py exits 0
  ok: gathering 5 blocking coroutines takes the SERIAL time (>= 0.9s for 5 x 0.2s)
  ok: await asyncio.sleep makes the same five overlap (>= 2.5x faster)
  ok: asyncio.to_thread does too, for code you cannot rewrite (>= 2.5x faster)
  ok: a blocking coroutine starves an unrelated task on the same loop (3x the gap)
  ok: and the healthy loop kept its 10ms heartbeat roughly on time (< 50ms)
  ok: all three versions returned identical correct results — the failure is silent

4. Shared state: a counter that loses increments, and three fixes
  ok: 04_race.py exits 0
  ok: the counter should reach 400000: 8 threads x 50000
  ok: the UNPROTECTED counter loses increments — and loses a lot of them (>= 1000)
  ok: the LOCKED counter loses none: exactly 400000
  ok: the queue version loses none either: exactly 400000
  ok: two locks taken in opposite orders really deadlock
  ok: the same two locks taken in one consistent order do not
  ok: the script restores the interpreter's switch interval when it is done
  ok: and it says plainly what the DEFAULT switch interval produced on this machine

5. The scheduler built from generators actually interleaves
  ok: 05_scheduler.py exits 0
  ok: three tasks of 3, 2 and 1 steps interleave as a b c a b a
  ok: a task that never yields runs to completion before any other starts
  ok: a sleeping task leaves the ready queue and the other task runs meanwhile
  ok: each task's return value is collected through StopIteration.value

6. Cancellation and timeouts
  ok: 06_timeouts.py exits 0
  ok: the timeout fired at the caller's budget, not at the work's length (< 0.35s)
  ok: and it did fire rather than the request quietly succeeding (>= 0.10s)
  ok: cancellation ran the task's finally block: the socket was closed, not leaked
  ok: the cancelled task saw CancelledError inside itself and re-raised it
  ok: gather(return_exceptions=True): 2 of 3 finish despite one raising
  ok: and the failure came back as a ValueError VALUE, not as a raise
  ok: TaskGroup: the same failure cancels both siblings instead
  ok: the TaskGroup error arrives in an ExceptionGroup, caught with except*

7. The starter reports honest progress, and the reference completes it
  ok: the untouched starter reports 0 of 8 exercises complete
  ok: and exits non-zero, so it cannot be mistaken for finished
  ok: it names the process-pool exercise among the work still to do
  ok: with the reference answers in place it reports 8 of 8
  ok: and exits 0
  ok: a 'threaded' answer that is secretly sequential is caught, not waved through
  ok: and it is caught on SPEED, not on correctness — the bodies were all right

8. Hygiene: offline, no sudo, nothing left behind
  ok: no URL in this lab names any host but the loopback address
  ok: no line in this lab would actually invoke sudo
  ok: every socket this lab opens is bound to 127.0.0.1
  ok: no captured output leaks an absolute home path
  ok: no __pycache__ directory survives inside the lab
  ok: nothing in this lab imports a third-party package

58 checks, 0 failure(s).

timeouts.txt

Day 096 — cancellation and timeouts
the fixture server now sleeps 0.40s; the caller's budget is 0.15s

1. asyncio.timeout around work that will not finish in time
   TimeoutError raised: yes
   gave up after 0.151s, not after 0.40s
   log: report started
   log: report received CancelledError
   log: report cleaned up
   Cancellation is an exception delivered inside the task, so the
   finally block ran and the socket was closed. Nothing leaked.

2. asyncio.gather(return_exceptions=True): one failure, others survive
   [0] result     a ok
   [1] ValueError b could not be fetched
   [2] result     c ok
   log: b about to fail
   log: a finished
   log: c finished
   2 of 3 finished; the failure came back as a value

3. asyncio.TaskGroup: one failure cancels its siblings
   caught in the ExceptionGroup: b could not be fetched
   log: b about to fail
   log: a was cancelled
   log: c was cancelled
   2 sibling(s) were cancelled rather than left running
   Task c asked for half a second and never got it. With gather it
   would have run to completion, doing work nobody was going to use.

RESULT timeout_elapsed_s 0.1511
RESULT timeout_budget_s 0.1500
RESULT gather_survivors 2.0000
RESULT taskgroup_cancelled_siblings 2.0000
SHAPE timeout_fires_at_the_budget_not_at_the_work yes   (gave up after 0.151s with a budget of 0.15s and work of 0.40s)
SHAPE cancellation_runs_the_finally_block yes   (the cancelled task's finally block appears in the log above)
SHAPE gather_lets_siblings_finish yes   (2 of 3 completed despite one raising)
SHAPE taskgroup_cancels_siblings yes   (2 sibling(s) cancelled when one task raised)

Choosing between them: gather when partial success is a real answer,
TaskGroup when it is not. TaskGroup also refuses to let you leak a task,
because the `async with` block does not exit until every child is done.

waiting.txt

Day 096 — waiting work: 20 requests, three ways
fixture server sleeps 0.100s per request; 20 requests; thread pool of 20; 3 runs each

timings
  sequential (one at a time)         runs:  2.101,  2.099,  2.117   median  2.101s   spread 0.017s
  threads (ThreadPoolExecutor 20)    runs:  0.172,  0.142,  0.172   median  0.172s   spread 0.031s
  asyncio (one thread, one loop)     runs:  0.117,  0.114,  0.176   median  0.117s   spread 0.062s

what the numbers mean
  20 requests x 0.100s of waiting = 2.000s of waiting in total.
  Sequential pays all of it end to end. The other two overlap it, so the floor
  becomes roughly one request: 0.100s plus overhead.

correctness first — a fast wrong answer is not an answer
  sequential   20 bodies, all well formed: yes
  threads      20 bodies, all well formed: yes
  asyncio      20 bodies, all well formed: yes
  and the order is preserved: first body is for /item/1

RESULT waiting_sequential_s 2.1008
RESULT waiting_threaded_s 0.1723
RESULT waiting_asyncio_s 0.1170
RESULT waiting_threaded_speedup 12.1911
RESULT waiting_asyncio_speedup 17.9483
SHAPE threads_help_waiting_work yes   (threads are 12.2x faster than sequential; the claim is >= 4x)
SHAPE asyncio_helps_waiting_work yes   (asyncio is 17.9x faster than sequential; the claim is >= 4x)

These seconds are one machine on one day. The SHAPE is what travels:
waiting work overlaps, so both threads and an event loop collapse it.

Source files

examples/01_waiting.py (5320 bytes)
#!/usr/bin/env python3
"""Waiting work: twenty requests, three ways, timed.

Run:  python3 examples/01_waiting.py

Every request goes to a fixture server on 127.0.0.1 that sleeps exactly
0.100 seconds before answering. No network is involved and no rate limit
can distort the result: the work is *waiting*, and the amount of waiting
is a number this script chose.

The question the script answers is the only question that matters when you
are picking a concurrency model: **is this work waiting, or is it
computing?** This half is waiting. Watch what threads do to it.
"""

from __future__ import annotations

import asyncio
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import labkit  # noqa: E402

REQUESTS = labkit.WAITING_REQUESTS
WORKERS = 20
REPEATS = 3


def run_sequential(base: str) -> list[str]:
    """One at a time. The simplest thing, and the slowest by a mile."""
    return [labkit.fetch(url) for url in labkit.urls(base, REQUESTS)]


def run_threaded(base: str) -> list[str]:
    """Twenty threads, each blocked in a socket read.

    A blocked socket read releases the interpreter lock, so nineteen other
    threads are free to run while any one of them waits. That single fact is
    why threads help here and will not help the next script along.

    `executor.map` returns results in the order the inputs were given, not
    the order they finished, which is usually what you wanted anyway.
    """
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        return list(pool.map(labkit.fetch, labkit.urls(base, REQUESTS)))


async def _gather(base: str) -> list[str]:
    async with asyncio.TaskGroup() as group:
        tasks = [group.create_task(labkit.fetch_async(url)) for url in labkit.urls(base, REQUESTS)]
    return [task.result() for task in tasks]


def run_asyncio(base: str) -> list[str]:
    """One thread, one event loop, twenty coroutines suspended at `await`.

    `asyncio.run` creates the loop, runs the coroutine to completion and
    closes the loop. A TaskGroup (Python 3.11 and later) is the modern way
    to say "these run together, and if one raises, cancel the rest".
    """
    return asyncio.run(_gather(base))


def main() -> int:
    print("Day 096 — waiting work: 20 requests, three ways")
    print(f"fixture server sleeps {labkit.DEFAULT_DELAY:.3f}s per request; "
          f"{REQUESTS} requests; thread pool of {WORKERS}; {REPEATS} runs each")
    print()

    with labkit.fixture_server() as base:
        # A first request that is not measured, so that the cost of importing
        # ssl-free urllib machinery and warming the server does not land on
        # the sequential column and flatter the other two.
        labkit.fetch(base + "/warmup")

        sequential_samples, sequential_bodies = labkit.repeat(
            lambda: run_sequential(base), REPEATS
        )
        threaded_samples, threaded_bodies = labkit.repeat(lambda: run_threaded(base), REPEATS)
        asyncio_samples, asyncio_bodies = labkit.repeat(lambda: run_asyncio(base), REPEATS)

    print("timings")
    sequential = labkit.report("sequential (one at a time)", sequential_samples)
    threaded = labkit.report(f"threads (ThreadPoolExecutor {WORKERS})", threaded_samples)
    evented = labkit.report("asyncio (one thread, one loop)", asyncio_samples)
    print()

    floor = REQUESTS * labkit.DEFAULT_DELAY
    print("what the numbers mean")
    print(f"  {REQUESTS} requests x {labkit.DEFAULT_DELAY:.3f}s of waiting = {floor:.3f}s of "
          "waiting in total.")
    print("  Sequential pays all of it end to end. The other two overlap it, so the floor")
    print(f"  becomes roughly one request: {labkit.DEFAULT_DELAY:.3f}s plus overhead.")
    print()

    print("correctness first — a fast wrong answer is not an answer")
    for name, bodies in (
        ("sequential", sequential_bodies),
        ("threads", threaded_bodies),
        ("asyncio", asyncio_bodies),
    ):
        assert isinstance(bodies, list)
        ok = len(bodies) == REQUESTS and all("waited 0.100s" in b for b in bodies)
        print(f"  {name:<12} {len(bodies)} bodies, all well formed: {'yes' if ok else 'no'}")
    print(f"  and the order is preserved: first body is for {sequential_bodies[0].split()[-1]}")
    print()

    labkit.result_line("waiting_sequential_s", sequential)
    labkit.result_line("waiting_threaded_s", threaded)
    labkit.result_line("waiting_asyncio_s", evented)
    labkit.result_line("waiting_threaded_speedup", sequential / threaded)
    labkit.result_line("waiting_asyncio_speedup", sequential / evented)
    labkit.shape_line(
        "threads_help_waiting_work",
        sequential / threaded >= 4.0,
        f"threads are {sequential / threaded:.1f}x faster than sequential; the claim is >= 4x",
    )
    labkit.shape_line(
        "asyncio_helps_waiting_work",
        sequential / evented >= 4.0,
        f"asyncio is {sequential / evented:.1f}x faster than sequential; the claim is >= 4x",
    )
    print()
    print("These seconds are one machine on one day. The SHAPE is what travels:")
    print("waiting work overlaps, so both threads and an event loop collapse it.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/02_computing.py (5852 bytes)
#!/usr/bin/env python3
"""Computing work: four prime counts, four ways, timed.

Run:  python3 examples/02_computing.py

Same code shape as 01_waiting.py. Same number of runs. The only thing that
changed is what the work *is* — and the answer flips completely.

This is the script that costs people weeks when they skip it. Threads made
the waiting work fourteen times faster in the previous script, so threads
get reached for again here, and here they do nothing at all. The reason is
the interpreter lock, and the fix is processes.
"""

from __future__ import annotations

import asyncio
import os
import sys
import sysconfig
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import labkit  # noqa: E402

TASKS = labkit.COMPUTING_TASKS
LIMIT = labkit.DEFAULT_PRIME_LIMIT
REPEATS = 3
LIMITS = [LIMIT] * TASKS


def run_sequential() -> list[int]:
    return [labkit.count_primes(limit) for limit in LIMITS]


def run_threaded() -> list[int]:
    """The same code as the fast version in 01_waiting.py. Watch it not help."""
    with ThreadPoolExecutor(max_workers=TASKS) as pool:
        return list(pool.map(labkit.count_primes, LIMITS))


def run_processes() -> list[int]:
    """One character different from the line above, and a different machine underneath.

    Each worker is a separate operating-system process with its own
    interpreter and its own lock, so four of them genuinely run at once on
    four cores. The price is paid at the edges: starting the processes, and
    pickling the arguments and results across them.
    """
    with ProcessPoolExecutor(max_workers=TASKS) as pool:
        return list(pool.map(labkit.count_primes, LIMITS))


async def _await_them() -> list[int]:
    """Deliberately wrong, and instructive.

    `async def` does not make anything concurrent. There is no `await` inside
    count_primes and there could not be: it never waits for anything. So the
    event loop runs each call to completion before it looks at the next one,
    and asyncio delivers exactly sequential timing with extra ceremony.
    """
    return [labkit.count_primes(limit) for limit in LIMITS]


def run_asyncio() -> list[int]:
    return asyncio.run(_await_them())


def main() -> int:
    free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED")
    print("Day 096 — computing work: 4 prime counts, four ways")
    print(f"python {sys.version.split()[0]}   cpu_count {os.cpu_count()}   "
          f"Py_GIL_DISABLED {free_threaded!r}")
    print(f"each task counts the primes below {LIMIT:,} by trial division; "
          f"{TASKS} tasks; {REPEATS} runs each")
    if not free_threaded:
        print("This interpreter was built WITH the global interpreter lock "
              "(Py_GIL_DISABLED is 0), which is what the numbers below reflect.")
    else:
        print("This interpreter is a free-threaded build (Py_GIL_DISABLED is 1); "
              "the threaded row below is not what a lock-holding build produces.")
    print()

    sequential_samples, sequential_result = labkit.repeat(run_sequential, REPEATS)
    threaded_samples, threaded_result = labkit.repeat(run_threaded, REPEATS)
    process_samples, process_result = labkit.repeat(run_processes, REPEATS)
    asyncio_samples, asyncio_result = labkit.repeat(run_asyncio, REPEATS)

    print("timings")
    sequential = labkit.report("sequential (one at a time)", sequential_samples)
    threaded = labkit.report(f"threads (ThreadPoolExecutor {TASKS})", threaded_samples)
    processes = labkit.report(f"processes (ProcessPoolExecutor {TASKS})", process_samples)
    evented = labkit.report("asyncio (one thread, one loop)", asyncio_samples)
    print()

    print("correctness first")
    expected = [78498 if LIMIT == 1_000_000 else labkit.count_primes(LIMIT)] * TASKS
    for name, got in (
        ("sequential", sequential_result),
        ("threads", threaded_result),
        ("processes", process_result),
        ("asyncio", asyncio_result),
    ):
        print(f"  {name:<12} {got}  correct: {'yes' if got == expected else 'no'}")
    print(f"  there really are {expected[0]:,} primes below {LIMIT:,}")
    print()

    labkit.result_line("computing_sequential_s", sequential)
    labkit.result_line("computing_threaded_s", threaded)
    labkit.result_line("computing_processes_s", processes)
    labkit.result_line("computing_asyncio_s", evented)
    labkit.result_line("computing_threaded_speedup", sequential / threaded)
    labkit.result_line("computing_processes_speedup", sequential / processes)
    labkit.result_line("computing_asyncio_speedup", sequential / evented)
    labkit.shape_line(
        "threads_do_not_help_computing_work",
        sequential / threaded < 1.5,
        f"threads are {sequential / threaded:.2f}x sequential; the claim is < 1.5x",
    )
    labkit.shape_line(
        "processes_do_help_computing_work",
        sequential / processes >= 1.5,
        f"processes are {sequential / processes:.2f}x sequential; the claim is >= 1.5x",
    )
    labkit.shape_line(
        "asyncio_does_not_help_computing_work",
        sequential / evented < 1.5,
        f"asyncio is {sequential / evented:.2f}x sequential; the claim is < 1.5x",
    )
    print()
    print("The one-line rule, earned rather than asserted:")
    print("  waiting work  -> threads or an event loop; the waiting overlaps")
    print("  computing work-> processes; nothing else has more than one interpreter lock")
    print()
    print("Note what processes did NOT give you: a 4x speedup from 4 workers. Starting")
    print("them, pickling to them and pickling back are real costs, and they are paid")
    print("whether or not the work was big enough to deserve them.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/03_blocking_coroutine.py (8578 bytes)
#!/usr/bin/env python3
"""The one rule you must not break, broken on purpose and then repaired.

Run:  python3 examples/03_blocking_coroutine.py

**Never call a blocking function inside a coroutine.**

An event loop is one thread running one callback at a time. A coroutine
gives that thread back at every `await` and at no other moment. So a
coroutine that calls something which blocks — `time.sleep`, a synchronous
HTTP client, a database driver that is not async, a file read on a slow
disk, `requests.get` — does not suspend. It holds the only thread there
is, and every other task on the loop stops dead until it returns.

The failure is quiet. Nothing raises. The program still produces correct
answers. It is simply serial while looking concurrent, which is why this
bug survives code review and is found in production by a latency graph.

The script measures four things:
  1. five blocking sleeps inside coroutines, gathered
  2. the same five with `await asyncio.sleep`, gathered
  3. the same five blocking calls handed to `asyncio.to_thread`
  4. what the blocking coroutine does to an UNRELATED task on the same loop
"""

from __future__ import annotations

import asyncio
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import labkit  # noqa: E402

TASKS = 5
NAP = 0.20


# ---------------------------------------------------------------------------
# 1. Broken: `async def` around a blocking call.
# ---------------------------------------------------------------------------


async def blocking_task(index: int) -> int:
    # time.sleep does not yield to the event loop. It parks the whole thread.
    time.sleep(NAP)
    return index


async def all_blocking() -> list[int]:
    return list(await asyncio.gather(*(blocking_task(i) for i in range(TASKS))))


# ---------------------------------------------------------------------------
# 2. Correct: an awaitable sleep.
# ---------------------------------------------------------------------------


async def awaiting_task(index: int) -> int:
    # asyncio.sleep is a coroutine. `await` is the pause point: the loop takes
    # the thread back here and gives it to whichever task is ready next.
    await asyncio.sleep(NAP)
    return index


async def all_awaiting() -> list[int]:
    return list(await asyncio.gather(*(awaiting_task(i) for i in range(TASKS))))


# ---------------------------------------------------------------------------
# 3. The repair for code you cannot change: push it to a thread.
# ---------------------------------------------------------------------------


async def offloaded_task(index: int) -> int:
    # asyncio.to_thread runs the blocking call in a worker thread and gives
    # you a coroutine to await. The blocking still happens — it just happens
    # somewhere that is allowed to block. This is the fix for a synchronous
    # library you do not own and cannot rewrite.
    await asyncio.to_thread(time.sleep, NAP)
    return index


async def all_offloaded() -> list[int]:
    return list(await asyncio.gather(*(offloaded_task(i) for i in range(TASKS))))


# ---------------------------------------------------------------------------
# 4. The collateral damage: what it does to a task that is not even involved.
# ---------------------------------------------------------------------------


async def heartbeat(stop_after: float) -> list[float]:
    """Tick every 10ms and record when each tick actually happened.

    A healthy loop produces ticks about 10ms apart. A loop with a blocked
    task produces a gap the size of the blockage. This is the shape you look
    for in a real service: not an error, a gap.
    """
    started = time.perf_counter()
    ticks: list[float] = []
    while time.perf_counter() - started < stop_after:
        await asyncio.sleep(0.01)
        ticks.append(time.perf_counter() - started)
    return ticks


async def measure_starvation(block: bool) -> float:
    """Return the largest gap between heartbeats while one task runs.

    The `await asyncio.sleep(0.03)` is load-bearing and is worth a sentence,
    because leaving it out is itself a classic asyncio mistake. `create_task`
    only *schedules* the heartbeat; it does not start it. Nothing on a loop
    starts until the currently running coroutine gives the thread back at an
    `await`. Without that line the heartbeat would not have ticked once
    before the blocking call began, and the gap being measured would not
    exist yet.
    """
    beat = asyncio.create_task(heartbeat(NAP * 2))
    await asyncio.sleep(0.03)
    if block:
        await blocking_task(0)
    else:
        await awaiting_task(0)
    ticks = await beat
    gaps = [second - first for first, second in zip(ticks, ticks[1:])]
    return max(gaps) if gaps else 0.0


def main() -> int:
    print("Day 096 — a blocking call inside a coroutine")
    print(f"{TASKS} tasks, each waiting {NAP:.2f}s, gathered on one event loop")
    print()

    blocking_samples, blocking_result = labkit.repeat(lambda: asyncio.run(all_blocking()), 3)
    awaiting_samples, awaiting_result = labkit.repeat(lambda: asyncio.run(all_awaiting()), 3)
    offload_samples, offload_result = labkit.repeat(lambda: asyncio.run(all_offloaded()), 3)

    print("timings")
    blocking = labkit.report("async def + time.sleep  (broken)", blocking_samples)
    awaiting = labkit.report("async def + await asyncio.sleep", awaiting_samples)
    offloaded = labkit.report("async def + asyncio.to_thread", offload_samples)
    print()

    serial_floor = TASKS * NAP
    print("what the numbers mean")
    print(f"  {TASKS} tasks x {NAP:.2f}s = {serial_floor:.2f}s if nothing overlaps.")
    print(f"  The broken version took {blocking:.3f}s, which is the serial floor. It ran")
    print("  one task at a time while the code said gather. Nothing raised.")
    print(f"  Both repairs took about {NAP:.2f}s, which is one task's worth of waiting.")
    print()

    print("correctness — all three produce the same answers, which is the trap")
    for name, got in (
        ("blocking", blocking_result),
        ("awaiting", awaiting_result),
        ("to_thread", offload_result),
    ):
        print(f"  {name:<11} {got}  correct: {'yes' if got == list(range(TASKS)) else 'no'}")
    print()

    print("collateral damage to an unrelated task on the same loop")
    blocked_gap = asyncio.run(measure_starvation(block=True))
    healthy_gap = asyncio.run(measure_starvation(block=False))
    print(f"  largest heartbeat gap while a coroutine BLOCKED : {blocked_gap * 1000:7.1f} ms")
    print(f"  largest heartbeat gap while a coroutine AWAITED : {healthy_gap * 1000:7.1f} ms")
    print("  The heartbeat asked for a tick every 10 ms and had nothing to do with the")
    print("  sleeping task. It was starved anyway, because there is only one thread.")
    print()

    labkit.result_line("blocking_gathered_s", blocking)
    labkit.result_line("awaiting_gathered_s", awaiting)
    labkit.result_line("to_thread_gathered_s", offloaded)
    labkit.result_line("blocking_vs_await_speedup", blocking / awaiting)
    labkit.result_line("blocking_vs_to_thread_speedup", blocking / offloaded)
    labkit.result_line("blocked_heartbeat_gap_ms", blocked_gap * 1000)
    labkit.result_line("healthy_heartbeat_gap_ms", healthy_gap * 1000)
    labkit.shape_line(
        "blocking_in_a_coroutine_serialises_the_loop",
        blocking >= serial_floor * 0.9,
        f"took {blocking:.3f}s against a serial floor of {serial_floor:.2f}s",
    )
    labkit.shape_line(
        "await_asyncio_sleep_repairs_it",
        blocking / awaiting >= 2.5,
        f"awaiting is {blocking / awaiting:.1f}x faster; the claim is >= 2.5x",
    )
    labkit.shape_line(
        "to_thread_repairs_it_for_code_you_cannot_change",
        blocking / offloaded >= 2.5,
        f"to_thread is {blocking / offloaded:.1f}x faster; the claim is >= 2.5x",
    )
    labkit.shape_line(
        "blocking_starves_an_unrelated_task",
        blocked_gap >= healthy_gap * 3,
        f"gap {blocked_gap * 1000:.1f} ms against {healthy_gap * 1000:.1f} ms when healthy",
    )
    print()
    print("How to find this in code you did not write: any call inside an `async def`")
    print("that is not preceded by `await` and is not obviously pure computation is a")
    print("suspect. `time.sleep`, `requests.get`, `open(...).read()` on a network mount,")
    print("and every synchronous database driver are the usual four.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/04_race.py (10396 bytes)
#!/usr/bin/env python3
"""Shared state: a counter that loses increments, and three ways to keep them.

Run:  python3 examples/04_race.py

The interpreter lock protects the INTERPRETER — its reference counts, its
internal structures — so that a data race can never corrupt Python's own
memory. It does not protect YOUR data structures. `total = total + 1` is a
read, an addition and a write, and if a thread switch lands between the
read and the write, one increment is silently overwritten by another.

There is an honest wrinkle in reproducing that, and this script does not
hide it. Read the output: on the interpreter this lab was written on, the
naive version at the default settings does not lose a single increment.
That is not evidence the bug is gone. It is evidence the window is narrow.
The script therefore narrows nothing and widens nothing about the CODE —
it only shortens the interval at which the interpreter considers switching
threads, from the default 5 milliseconds to 1 microsecond, which makes the
same race land every single run instead of once in a very long while.

A bug you cannot reproduce is still a bug. It is just a worse bug.
"""

from __future__ import annotations

import queue
import sys
import threading
from contextlib import contextmanager

THREADS = 8
PER_THREAD = 50_000
EXPECTED = THREADS * PER_THREAD
TIGHT_INTERVAL = 1e-6


@contextmanager
def switch_interval(seconds: float | None):
    """Temporarily change how often the interpreter considers a thread switch.

    sys.setswitchinterval is process-wide, so it is restored in a finally
    block. The default is 0.005 seconds: a thread that neither blocks nor
    calls out gets roughly five milliseconds before the interpreter asks
    whether somebody else should have a turn.
    """
    previous = sys.getswitchinterval()
    try:
        if seconds is not None:
            sys.setswitchinterval(seconds)
        yield previous
    finally:
        sys.setswitchinterval(previous)


class SharedCounter:
    """A counter shared by every thread. Read, add, write — three steps."""

    def __init__(self) -> None:
        self.value = 0

    def read(self) -> int:
        return self.value

    def write(self, value: int) -> None:
        self.value = value


def bump_unsafely(counter: SharedCounter, times: int) -> None:
    # The read and the write are two separate operations with a gap between
    # them. Whatever another thread does in that gap is lost.
    for _ in range(times):
        counter.write(counter.read() + 1)


def bump_with_lock(counter: SharedCounter, times: int, lock: threading.Lock) -> None:
    # `with lock:` makes read-add-write one indivisible step. Only one thread
    # can be between the two lines at a time, so nothing can be overwritten.
    for _ in range(times):
        with lock:
            counter.write(counter.read() + 1)


def run_threads(target, *args) -> None:
    workers = [threading.Thread(target=target, args=args) for _ in range(THREADS)]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()


def unsafe_total(interval: float | None) -> int:
    counter = SharedCounter()
    with switch_interval(interval):
        run_threads(bump_unsafely, counter, PER_THREAD)
    return counter.value


def locked_total(interval: float | None) -> int:
    counter = SharedCounter()
    lock = threading.Lock()
    with switch_interval(interval):
        run_threads(bump_with_lock, counter, PER_THREAD, lock)
    return counter.value


def queued_total(interval: float | None) -> int:
    """The answer that is usually better than a lock: do not share the state.

    Every worker sends its own subtotal down a queue. One consumer — here,
    the main thread after the join — adds them up. Nothing is shared and
    mutated, so there is nothing to protect and no lock to forget. A
    queue.Queue is internally locked so you do not have to be.
    """
    outbox: queue.Queue[int] = queue.Queue()

    def worker() -> None:
        subtotal = 0
        for _ in range(PER_THREAD):
            subtotal += 1  # a local variable: no other thread can see it
        outbox.put(subtotal)

    with switch_interval(interval):
        workers = [threading.Thread(target=worker) for _ in range(THREADS)]
        for w in workers:
            w.start()
        for w in workers:
            w.join()
    total = 0
    while not outbox.empty():
        total += outbox.get()
    return total


def deadlock_demonstration() -> tuple[bool, bool]:
    """Two locks, two threads, opposite orders. Returns (deadlocked, fixed_ok).

    This is a real deadlock and it would hang forever, so each thread takes
    its second lock with a timeout. The timeout is a detector, not a fix: in
    production it turns a hang into a mysterious slow path. The fix is the
    second half — every thread takes the locks in the SAME order, so a cycle
    of waiting cannot form.
    """
    first, second = threading.Lock(), threading.Lock()
    gate = threading.Barrier(2)
    stuck: list[bool] = []

    def grab(outer: threading.Lock, inner: threading.Lock) -> None:
        with outer:
            gate.wait()  # guarantee both threads hold one lock before either asks for two
            got = inner.acquire(timeout=0.5)
            stuck.append(not got)
            if got:
                inner.release()

    a = threading.Thread(target=grab, args=(first, second))
    b = threading.Thread(target=grab, args=(second, first))  # opposite order: the bug
    a.start()
    b.start()
    a.join()
    b.join()
    deadlocked = any(stuck)

    # Same two locks, same two threads, one rule: always first then second.
    #
    # Note there is no barrier here, and that is not an oversight — it is the
    # second lesson of this function. Adding one would make the FIXED version
    # hang: a thread holding `first` would wait at the barrier for a thread
    # that cannot reach the barrier because it is waiting for `first`. Forcing
    # an interleaving is itself a way to build a deadlock.
    stuck2: list[bool] = []

    def ordered() -> None:
        with first:
            got = second.acquire(timeout=0.5)
            stuck2.append(not got)
            if got:
                second.release()

    c = threading.Thread(target=ordered)
    d = threading.Thread(target=ordered)
    c.start()
    d.start()
    c.join()
    d.join()
    return deadlocked, not any(stuck2)


def main() -> int:
    print("Day 096 — a counter that loses increments")
    print(f"{THREADS} threads x {PER_THREAD:,} increments each; expected total {EXPECTED:,}")
    print(f"default switch interval on this interpreter: {sys.getswitchinterval()} s")
    print()

    print("1. the naive counter at the interpreter's DEFAULT switch interval")
    default_runs = [unsafe_total(None) for _ in range(3)]
    for index, got in enumerate(default_runs, 1):
        print(f"   run {index}: {got:,}   lost {EXPECTED - got:,}")
    print("   Report what you see, not what the textbook says. On this machine the")
    print("   read-add-write finishes well inside one thread's 5 ms slice, so the race")
    print("   almost never lands. That is a narrow window, not a safe program.")
    print()

    print(f"2. the same code with the switch interval at {TIGHT_INTERVAL} s")
    tight_runs = [unsafe_total(TIGHT_INTERVAL) for _ in range(3)]
    for index, got in enumerate(tight_runs, 1):
        print(f"   run {index}: {got:,}   lost {EXPECTED - got:,}")
    print("   The code did not change. Only the frequency of thread switches did.")
    print("   Every increment lost here was an increment that could be lost at the")
    print("   default setting too, on a busier machine, under a longer run, one day.")
    print()

    print("3. the same counter, one lock")
    locked_runs = [locked_total(TIGHT_INTERVAL) for _ in range(3)]
    for index, got in enumerate(locked_runs, 1):
        print(f"   run {index}: {got:,}   lost {EXPECTED - got:,}")
    print("   Exact, at the switch interval that broke the version above.")
    print()

    print("4. the answer that is usually better than a lock: stop sharing")
    queued_runs = [queued_total(TIGHT_INTERVAL) for _ in range(3)]
    for index, got in enumerate(queued_runs, 1):
        print(f"   run {index}: {got:,}   lost {EXPECTED - got:,}")
    print("   Each worker counts into a local variable and posts one subtotal to a")
    print("   queue. There is no shared mutable state, so there is no lock to forget,")
    print("   no lock ordering to get wrong, and nothing to deadlock.")
    print()

    print("5. deadlock, in eight lines")
    deadlocked, ordered_ok = deadlock_demonstration()
    print(f"   two locks taken in opposite orders  -> deadlocked: {'yes' if deadlocked else 'no'}")
    print(f"   the same two taken in the same order -> completed: "
          f"{'yes' if ordered_ok else 'no'}")
    print("   Each thread held one lock and waited for the other. Nothing was busy;")
    print("   nothing errored; the program simply stopped. The timeout above is a")
    print("   detector. The fix is the ordering rule.")
    print()

    worst_default = EXPECTED - min(default_runs)
    worst_tight = EXPECTED - min(tight_runs)
    print(f"RESULT race_lost_at_default_interval {worst_default}")
    print(f"RESULT race_lost_at_tight_interval {worst_tight}")
    print(f"RESULT locked_total {min(locked_runs)}")
    print(f"RESULT queued_total {min(queued_runs)}")
    print(f"RESULT expected_total {EXPECTED}")
    print(f"SHAPE unlocked_counter_loses_increments "
          f"{'yes' if worst_tight > 0 else 'no'}   (lost {worst_tight:,} of {EXPECTED:,})")
    print(f"SHAPE locked_counter_loses_nothing "
          f"{'yes' if all(r == EXPECTED for r in locked_runs) else 'no'}   "
          f"(3 runs, all exactly {EXPECTED:,})")
    print(f"SHAPE queue_version_loses_nothing "
          f"{'yes' if all(r == EXPECTED for r in queued_runs) else 'no'}   "
          f"(3 runs, all exactly {EXPECTED:,})")
    print(f"SHAPE opposite_lock_order_deadlocks {'yes' if deadlocked else 'no'}   "
          "(detected with a timeout; it would otherwise hang forever)")
    print(f"SHAPE consistent_lock_order_does_not "
          f"{'yes' if ordered_ok else 'no'}   (same locks, same threads, one rule)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/05_scheduler.py (8022 bytes)
#!/usr/bin/env python3
"""An event loop in forty lines, so `async`/`await` stops being magic.

Run:  python3 examples/05_scheduler.py

Strip asyncio of its socket handling, its thread pools, its cancellation
machinery and its exception groups, and what is left is this: a queue of
functions that can pause, and a loop that keeps taking the next one.

Python has had functions that can pause since generators arrived in 2001.
A generator runs until `yield`, hands control back to whoever called
`next()` on it, and remembers exactly where it was. That is the entire
mechanism underneath a coroutine. `async def` and `await` are a dedicated
syntax for the same idea with a nicer set of rules; PEP 492 added them in
Python 3.5, and coroutines were built on generators before that.

So: `yield` is our `await`, a generator function is our `async def`, and
the class below is our event loop. Once you have written the loop, the
question "why did my whole server freeze?" answers itself — look at the
loop and ask what happens if `next(task)` takes two seconds to return.
"""

from __future__ import annotations

import sys
from collections import deque
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))


# ---------------------------------------------------------------------------
# The scheduler. This is the whole thing.
# ---------------------------------------------------------------------------

PAUSE = None  # a task yields this to say "give somebody else a turn"


def sleep(ticks: int) -> tuple[str, int]:
    """A task yields this to say "wake me in N ticks". Our asyncio.sleep."""
    return ("sleep", ticks)


class Scheduler:
    """A ready queue, a sleeping list, a clock, and a loop. Nothing else."""

    def __init__(self) -> None:
        self.ready: deque[tuple[str, object]] = deque()
        self.sleeping: list[tuple[int, str, object]] = []
        self.tick = 0
        self.trace: list[str] = []
        self.results: dict[str, object] = {}

    def spawn(self, name: str, task) -> None:
        """Schedule a generator. Note it does not start: it is only queued."""
        self.ready.append((name, task))

    def _wake(self) -> None:
        due = [entry for entry in self.sleeping if entry[0] <= self.tick]
        self.sleeping = [entry for entry in self.sleeping if entry[0] > self.tick]
        for _wake_at, name, task in sorted(due, key=lambda e: e[0]):
            self.ready.append((name, task))

    def run(self) -> dict[str, object]:
        while self.ready or self.sleeping:
            if not self.ready:
                # Everybody is waiting. A real loop would block in select()
                # here until a socket became readable; we jump the clock.
                self.tick = min(entry[0] for entry in self.sleeping)
                self._wake()
            name, task = self.ready.popleft()
            try:
                instruction = next(task)  # resume it; it runs until its next yield
            except StopIteration as finished:
                self.results[name] = finished.value
                continue
            if instruction is PAUSE:
                self.ready.append((name, task))  # straight to the back of the queue
            else:
                _kind, ticks = instruction
                self.sleeping.append((self.tick + ticks, name, task))
            self.tick += 1
            self._wake()
        return self.results

    def step(self, name: str) -> None:
        """Record that a task did one unit of visible work."""
        self.trace.append(f"{name}:{self.tick}")


# ---------------------------------------------------------------------------
# Tasks written against it. `yield` is the pause point.
# ---------------------------------------------------------------------------


def counting_task(loop: Scheduler, name: str, steps: int):
    for _ in range(steps):
        loop.step(name)
        yield PAUSE  # <- this is the `await`. Control goes back to the loop.
    return f"{name} did {steps} step" + ("" if steps == 1 else "s")


def sleeping_task(loop: Scheduler, name: str, nap: int):
    loop.step(f"{name}-start")
    yield sleep(nap)  # <- like `await asyncio.sleep(nap)`
    loop.step(f"{name}-woke")
    return f"{name} slept {nap}"


def greedy_task(loop: Scheduler, name: str, steps: int):
    """The bug, in a form you can see. It never yields until it is finished."""
    for _ in range(steps):
        loop.step(name)
    yield PAUSE
    return f"{name} hogged {steps} steps"


def main() -> int:
    print("Day 096 — a cooperative scheduler built from generators")
    print()

    print("1. three tasks, round-robin")
    loop = Scheduler()
    loop.spawn("alpha", counting_task(loop, "alpha", 3))
    loop.spawn("beta", counting_task(loop, "beta", 2))
    loop.spawn("gamma", counting_task(loop, "gamma", 1))
    results = loop.run()
    order = [entry.split(":")[0] for entry in loop.trace]
    print(f"   trace:  {' '.join(loop.trace)}")
    print(f"   order:  {' '.join(order)}")
    print("   Each task ran ONE step and then gave the loop back. That interleaving")
    print("   is concurrency, and it happened in a single thread with no lock in")
    print("   sight — which is why no increment can be lost between two yields.")
    for name in sorted(results):
        print(f"   returned: {results[name]}")
    print()

    print("2. a task that waits, while the others carry on")
    loop = Scheduler()
    loop.spawn("napper", sleeping_task(loop, "napper", 4))
    loop.spawn("worker", counting_task(loop, "worker", 5))
    loop.run()
    print(f"   trace:  {' '.join(loop.trace)}")
    print("   napper yielded a sleep instruction and left the ready queue entirely.")
    print("   worker had the loop to itself until napper's tick came round. That is")
    print("   exactly what an await on a socket read does in a real event loop.")
    print()

    print("3. the same loop, with one task that refuses to yield")
    loop = Scheduler()
    loop.spawn("greedy", greedy_task(loop, "greedy", 4))
    loop.spawn("polite", counting_task(loop, "polite", 3))
    loop.run()
    print(f"   trace:  {' '.join(loop.trace)}")
    print("   polite did not run once until greedy had finished all four steps.")
    print("   Nothing errored. Nothing was slow. The loop simply never got the")
    print("   thread back, because `next(task)` does not return until the task")
    print("   reaches a yield. That is the blocking call in 03_blocking_coroutine.py,")
    print("   seen from inside the loop rather than from outside it.")
    print()

    expected_round_robin = "alpha beta gamma alpha beta alpha"
    loop = Scheduler()
    loop.spawn("alpha", counting_task(loop, "alpha", 3))
    loop.spawn("beta", counting_task(loop, "beta", 2))
    loop.spawn("gamma", counting_task(loop, "gamma", 1))
    loop.run()
    actual = " ".join(entry.split(":")[0] for entry in loop.trace)
    print(f"RESULT round_robin_order {actual.replace(' ', ',')}")
    print(f"SHAPE scheduler_interleaves_tasks "
          f"{'yes' if actual == expected_round_robin else 'no'}   "
          f"(expected '{expected_round_robin}')")

    loop = Scheduler()
    loop.spawn("greedy", greedy_task(loop, "greedy", 4))
    loop.spawn("polite", counting_task(loop, "polite", 3))
    loop.run()
    hogged = " ".join(entry.split(":")[0] for entry in loop.trace)
    print(f"RESULT greedy_order {hogged.replace(' ', ',')}")
    print(f"SHAPE a_task_that_never_yields_starves_the_others "
          f"{'yes' if hogged.startswith('greedy greedy greedy greedy') else 'no'}   "
          f"(got '{hogged}')")
    print()
    print("Forty lines. A ready queue, a sleeping list and a while loop. Everything")
    print("asyncio adds on top of this — socket readiness from the operating system,")
    print("cancellation, timeouts, TaskGroups, thread offloading — is machinery around")
    print("that same idea, not a different idea.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/06_timeouts.py (7025 bytes)
#!/usr/bin/env python3
"""Cancellation, timeouts, and what gather does that TaskGroup does not.

Run:  python3 examples/06_timeouts.py

Starting concurrent work is the easy half. Stopping it is where the bugs
are. Four things are measured here, all against the fixture server so the
"slow" request is slow by arrangement rather than by luck:

  1. asyncio.timeout around a request that will not finish in time
  2. what cancellation actually is — a CancelledError raised INSIDE the
     task at its next await, which means cleanup in a finally block runs
  3. asyncio.gather with return_exceptions, where one failure does not
     stop the others and you get the exception back as a value
  4. asyncio.TaskGroup, where one failure DOES cancel its siblings

The difference in 3 and 4 is a design decision, not a style preference.
"Fetch nine things and tell me which ones worked" wants gather. "Do these
four things or do none of them" wants a TaskGroup.
"""

from __future__ import annotations

import asyncio
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import labkit  # noqa: E402

SLOW = 0.40
BUDGET = 0.15


async def slow_fetch(base: str, tag: str, log: list[str]) -> str:
    """A request that takes longer than the caller's patience.

    The finally block is the part worth studying. Cancellation in asyncio is
    an exception raised inside the coroutine at its next suspension point,
    so ordinary Python cleanup works: finally runs, context managers exit,
    connections close. It is not a kill.
    """
    log.append(f"{tag} started")
    try:
        return await labkit.fetch_async(f"{base}/slow/{tag}")
    except asyncio.CancelledError:
        log.append(f"{tag} received CancelledError")
        raise  # re-raise: swallowing it is how you get a task that will not die
    finally:
        log.append(f"{tag} cleaned up")


async def failing_task(tag: str, log: list[str]) -> str:
    await asyncio.sleep(0.02)
    log.append(f"{tag} about to fail")
    raise ValueError(f"{tag} could not be fetched")


async def steady_task(tag: str, seconds: float, log: list[str]) -> str:
    try:
        await asyncio.sleep(seconds)
        log.append(f"{tag} finished")
        return f"{tag} ok"
    except asyncio.CancelledError:
        log.append(f"{tag} was cancelled")
        raise


async def demo_timeout(base: str) -> tuple[bool, list[str], float]:
    log: list[str] = []
    timed_out = False
    start = asyncio.get_running_loop().time()
    try:
        async with asyncio.timeout(BUDGET):
            await slow_fetch(base, "report", log)
    except TimeoutError:
        timed_out = True
    return timed_out, log, asyncio.get_running_loop().time() - start


async def demo_gather() -> tuple[list[object], list[str]]:
    log: list[str] = []
    results = await asyncio.gather(
        steady_task("a", 0.05, log),
        failing_task("b", log),
        steady_task("c", 0.10, log),
        return_exceptions=True,
    )
    return list(results), log


async def demo_taskgroup() -> tuple[list[str], list[str]]:
    log: list[str] = []
    messages: list[str] = []
    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(steady_task("a", 0.05, log))
            group.create_task(failing_task("b", log))
            group.create_task(steady_task("c", 0.50, log))
    except* ValueError as group_error:
        messages = [str(error) for error in group_error.exceptions]
    return messages, log


async def main_async() -> int:
    with labkit.fixture_server(delay=SLOW) as base:
        print("Day 096 — cancellation and timeouts")
        print(f"the fixture server now sleeps {SLOW:.2f}s; the caller's budget is "
              f"{BUDGET:.2f}s")
        print()

        print("1. asyncio.timeout around work that will not finish in time")
        timed_out, timeout_log, elapsed = await demo_timeout(base)
        print(f"   TimeoutError raised: {'yes' if timed_out else 'no'}")
        print(f"   gave up after {elapsed:.3f}s, not after {SLOW:.2f}s")
        for line in timeout_log:
            print(f"   log: {line}")
        print("   Cancellation is an exception delivered inside the task, so the")
        print("   finally block ran and the socket was closed. Nothing leaked.")
        print()

        print("2. asyncio.gather(return_exceptions=True): one failure, others survive")
        gathered, gather_log = await demo_gather()
        for index, item in enumerate(gathered):
            kind = type(item).__name__ if isinstance(item, BaseException) else "result"
            print(f"   [{index}] {kind:<10} {item}")
        for line in gather_log:
            print(f"   log: {line}")
        survivors = sum(1 for item in gathered if not isinstance(item, BaseException))
        print(f"   {survivors} of {len(gathered)} finished; the failure came back as a value")
        print()

        print("3. asyncio.TaskGroup: one failure cancels its siblings")
        messages, group_log = await demo_taskgroup()
        for message in messages:
            print(f"   caught in the ExceptionGroup: {message}")
        for line in group_log:
            print(f"   log: {line}")
        cancelled = [line for line in group_log if "cancelled" in line]
        print(f"   {len(cancelled)} sibling(s) were cancelled rather than left running")
        print("   Task c asked for half a second and never got it. With gather it")
        print("   would have run to completion, doing work nobody was going to use.")
        print()

        labkit.result_line("timeout_elapsed_s", elapsed)
        labkit.result_line("timeout_budget_s", BUDGET)
        labkit.result_line("gather_survivors", float(survivors))
        labkit.result_line("taskgroup_cancelled_siblings", float(len(cancelled)))
        labkit.shape_line(
            "timeout_fires_at_the_budget_not_at_the_work",
            timed_out and elapsed < SLOW,
            f"gave up after {elapsed:.3f}s with a budget of {BUDGET:.2f}s and "
            f"work of {SLOW:.2f}s",
        )
        labkit.shape_line(
            "cancellation_runs_the_finally_block",
            any("cleaned up" in line for line in timeout_log),
            "the cancelled task's finally block appears in the log above",
        )
        labkit.shape_line(
            "gather_lets_siblings_finish",
            survivors == 2,
            f"{survivors} of 3 completed despite one raising",
        )
        labkit.shape_line(
            "taskgroup_cancels_siblings",
            len(cancelled) >= 1,
            f"{len(cancelled)} sibling(s) cancelled when one task raised",
        )
        print()
        print("Choosing between them: gather when partial success is a real answer,")
        print("TaskGroup when it is not. TaskGroup also refuses to let you leak a task,")
        print("because the `async with` block does not exit until every child is done.")
    return 0


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main_async()))
examples/07_solutions.py (5250 bytes)
"""Reference answers to the eight exercises in starter/01_exercises.py.

Read this AFTER you have tried. Every function has the same name and
signature as the starter's, so the checker can be pointed at either:

    bash starter/02_check.sh examples/07_solutions.py

Each answer is the shortest one that is actually correct, with a note on
the mistake that version exists to avoid.
"""

from __future__ import annotations

import asyncio
import sys
import threading
import time
from collections import deque
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import labkit  # noqa: E402


def fetch_all_sequentially(urls: list[str]) -> list[str]:
    """The baseline. Correct, obvious, and pays every wait end to end."""
    return [labkit.fetch(url) for url in urls]


def fetch_all_with_threads(urls: list[str], workers: int) -> list[str]:
    """Threads help because a blocked socket read releases the interpreter lock.

    `pool.map` returns results in INPUT order. `as_completed` would return
    them in finish order, which is what you want for a progress bar and not
    what you want when the caller expects a list lined up with its input.
    """
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(labkit.fetch, urls))


def fetch_all_with_asyncio(urls: list[str]) -> list[str]:
    """One thread, one loop, N coroutines suspended at await.

    `asyncio.gather` preserves argument order in its result list. Note this
    is an ordinary def: `asyncio.run` is the boundary between synchronous
    code and the loop, and calling it from inside a running loop is an error.
    """

    async def gather_all() -> list[str]:
        return list(await asyncio.gather(*(labkit.fetch_async(url) for url in urls)))

    return asyncio.run(gather_all())


def count_primes_with_threads(limits: list[int], workers: int) -> list[int]:
    """Identical to the fast version above, and it will not be faster.

    Nothing here waits, so nothing releases the interpreter lock for long
    enough to matter. The answers are right; the wall clock is unmoved.
    """
    with ThreadPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(labkit.count_primes, limits))


def count_primes_with_processes(limits: list[int], workers: int) -> list[int]:
    """One word changed, and now there are four interpreter locks.

    `labkit.count_primes` is a module-level function, which is why the child
    processes can find it. A lambda, a closure, or a locally defined function
    raises a pickling error here — the most common first failure with
    ProcessPoolExecutor, and one worth meeting deliberately.
    """
    with ProcessPoolExecutor(max_workers=workers) as pool:
        return list(pool.map(labkit.count_primes, limits))


def wait_without_blocking_the_loop(naps: list[float]) -> list[float]:
    """The repair for a synchronous library you cannot rewrite.

    `asyncio.to_thread(fn, *args)` returns a coroutine that runs fn in a
    worker thread. The blocking still happens; it just happens somewhere
    that is allowed to block, so the loop keeps its thread.
    """

    async def nap(seconds: float) -> float:
        await asyncio.to_thread(time.sleep, seconds)
        return seconds

    async def all_naps() -> list[float]:
        return list(await asyncio.gather(*(nap(seconds) for seconds in naps)))

    return asyncio.run(all_naps())


def counter_that_loses_nothing(threads: int, per_thread: int) -> int:
    """The better answer: do not share the mutable state at all.

    Each worker counts into a local variable that no other thread can see,
    and posts one subtotal at the end. There is exactly one shared mutation
    per thread instead of `per_thread` of them, and it is under a lock.

    The direct answer — one shared counter with `with lock:` around every
    read-add-write — is also correct and also passes. It is roughly an order
    of magnitude slower here, because it takes and releases a lock several
    hundred thousand times to protect an addition.
    """
    lock = threading.Lock()
    total = 0

    def worker() -> None:
        nonlocal total
        subtotal = 0
        for _ in range(per_thread):
            subtotal += 1
        with lock:
            total += subtotal

    workers = [threading.Thread(target=worker) for _ in range(threads)]
    for thread in workers:
        thread.start()
    for thread in workers:
        thread.join()
    return total


def round_robin(tasks: list[tuple[str, object]]) -> list[str]:
    """An event loop, minus everything that is not the loop.

    A ready queue, take the front one, advance it exactly one step, put it
    at the back unless it has finished. That is scheduling. Everything
    asyncio adds is about deciding WHEN a task becomes ready again.
    """
    ready = deque(tasks)
    order: list[str] = []
    while ready:
        name, task = ready.popleft()
        try:
            next(task)  # type: ignore[arg-type]
        except StopIteration:
            continue  # finished: do not put it back
        order.append(name)
        ready.append((name, task))
    return order
examples/labkit.py (8328 bytes)
"""Shared kit for the Day 096 lab: something that waits, something that
computes, and an honest way to time both.

Standard library only. Nothing here reaches the internet. The "waiting"
work is a small HTTP server bound to the loopback address 127.0.0.1 on an
ephemeral port the operating system chooses, which sleeps a fixed time
before answering. That makes waiting *real* — a socket, a kernel, a
genuine blocked thread — and *reproducible*, because the delay is a
number you set rather than whatever the internet felt like today.

The CPU-bound work is a prime count by trial division. It was chosen for
three properties: it is pure Python, so it holds the interpreter lock; it
is deterministic, so a wrong answer is detectable; and it takes long
enough that process start-up cost does not swamp the measurement.

Every function here is used by at least two of the example scripts, the
starter checker and the test suite.
"""

from __future__ import annotations

import asyncio
import statistics
import threading
import time
import urllib.parse
import urllib.request
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# One tenth of a second per request. Long enough that the waiting dominates
# the measurement, short enough that twenty sequential requests still finish
# in about two seconds.
DEFAULT_DELAY = 0.10

# Trial division below this limit. Calibrated on the authoring machine so a
# single call takes a few hundred milliseconds — long enough that starting a
# process pool is not the thing being measured, short enough that the whole
# test suite still finishes in under a minute. See requirements/README.md.
DEFAULT_PRIME_LIMIT = 500_000

# How many of each kind of unit of work the example scripts use.
WAITING_REQUESTS = 20
COMPUTING_TASKS = 4


# ---------------------------------------------------------------------------
# The waiting half: a fixture server that sleeps, then answers.
# ---------------------------------------------------------------------------


class _WaitHandler(BaseHTTPRequestHandler):
    """Answers any GET after sleeping. The sleep is the whole point."""

    # HTTP/1.0 means the server closes the connection when it has finished
    # writing, so a client can simply read to end-of-stream. That keeps the
    # raw-socket asyncio client in 01_waiting.py down to a dozen lines.
    protocol_version = "HTTP/1.0"

    def do_GET(self) -> None:  # noqa: N802 - name fixed by the base class
        delay = self.server.delay  # type: ignore[attr-defined]
        time.sleep(delay)
        body = f"waited {delay:.3f}s for {self.path}\n".encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: object) -> None:
        """Silence the per-request log line; the measurements are the output."""


class _WaitServer(ThreadingHTTPServer):
    """A threading server, so it can actually answer requests in parallel.

    This matters more than it looks. If the fixture server handled one
    request at a time, every client would measure the SERVER's serialisation
    rather than its own concurrency, and the whole lab would prove nothing.
    """

    daemon_threads = True
    delay = DEFAULT_DELAY


@contextmanager
def fixture_server(delay: float = DEFAULT_DELAY):
    """Run the fixture server for the duration of the block.

    Yields the base URL. Binding to port 0 asks the operating system for a
    free ephemeral port, so two copies of this lab can run at once and
    neither needs a privileged port or a firewall rule.
    """
    server = _WaitServer(("127.0.0.1", 0), _WaitHandler)
    server.delay = delay
    thread = threading.Thread(target=server.serve_forever, name="fixture-server", daemon=True)
    thread.start()
    host, port = server.server_address[0], server.server_address[1]
    try:
        yield f"http://{host}:{port}"
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5.0)


def fetch(url: str, timeout: float = 30.0) -> str:
    """Fetch one URL and block until it answers. This is a BLOCKING call."""
    with urllib.request.urlopen(url, timeout=timeout) as response:  # noqa: S310
        return response.read().decode("utf-8")


async def fetch_async(url: str) -> str:
    """Fetch one URL without blocking the event loop.

    Written against raw sockets on purpose. There is no HTTP client in the
    standard library that speaks asyncio, and writing the twelve lines makes
    the point that `await` is where this coroutine gives the loop its turn:
    at the connect, at the drain, and at the read.
    """
    parts = urllib.parse.urlsplit(url)
    host = parts.hostname or "127.0.0.1"
    port = parts.port or 80
    path = parts.path or "/"
    reader, writer = await asyncio.open_connection(host, port)
    request = f"GET {path} HTTP/1.0\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
    writer.write(request.encode("ascii"))
    await writer.drain()
    raw = await reader.read()
    writer.close()
    await writer.wait_closed()
    _headers, _sep, body = raw.partition(b"\r\n\r\n")
    return body.decode("utf-8")


def paths(count: int) -> list[str]:
    """The request paths used everywhere in this lab, so counts line up."""
    return [f"/item/{n}" for n in range(1, count + 1)]


def urls(base: str, count: int) -> list[str]:
    return [base + p for p in paths(count)]


# ---------------------------------------------------------------------------
# The computing half: a function that burns CPU and holds the lock.
# ---------------------------------------------------------------------------


def count_primes(limit: int = DEFAULT_PRIME_LIMIT) -> int:
    """Count the primes below `limit` by trial division.

    Deliberately not a sieve. A sieve would be fast and mostly memory-bound;
    this is a tight arithmetic loop in pure Python, which is exactly the
    shape of work that cannot overlap while one interpreter lock exists.
    """
    if limit <= 2:
        return 0
    count = 1  # 2 is prime and is the only even one
    for number in range(3, limit, 2):
        factor = 3
        while factor * factor <= number:
            if number % factor == 0:
                break
            factor += 2
        else:
            count += 1
    return count


# ---------------------------------------------------------------------------
# Measuring, honestly.
# ---------------------------------------------------------------------------


def timed(function, *args, **kwargs) -> tuple[float, object]:
    """Return (elapsed seconds, result). perf_counter is monotonic."""
    start = time.perf_counter()
    result = function(*args, **kwargs)
    return time.perf_counter() - start, result


def repeat(function, times: int = 3) -> tuple[list[float], object]:
    """Run the same thing `times` over and keep every sample.

    One measurement is an anecdote. Three is still a small sample, but it
    shows you the spread, and the spread is the part most benchmarks hide.
    """
    samples: list[float] = []
    result: object = None
    for _ in range(times):
        elapsed, result = timed(function)
        samples.append(elapsed)
    return samples, result


def report(label: str, samples: list[float]) -> float:
    """Print every sample and the median, and return the median.

    The median is the headline because it is the sample least disturbed by
    one unlucky run, and because a mean over three samples with one outlier
    is a lie with a decimal point on it.
    """
    median = statistics.median(samples)
    spread = max(samples) - min(samples)
    runs = ", ".join(f"{s:6.3f}" for s in samples)
    print(f"  {label:<34} runs: {runs}   median {median:6.3f}s   spread {spread:5.3f}s")
    return median


def result_line(name: str, value: float) -> None:
    """One machine-readable line the test suite can parse."""
    print(f"RESULT {name} {value:.4f}")


def shape_line(name: str, holds: bool, detail: str) -> None:
    """The claim that has to survive on another machine: a SHAPE, not a time."""
    print(f"SHAPE {name} {'yes' if holds else 'no'}   ({detail})")
metadata.yml (1221 bytes)
lesson_id: D096
day: 96
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-096-concurrency-and-async-basics
  - python3 --version
  - 'python3 -c "import sys, sysconfig; print(sys.version); print(sysconfig.get_config_var(''Py_GIL_DISABLED''))"'
  - 'python3 -c "import os; print(os.cpu_count(), ''logical CPUs'')"'
run_commands:
  - bash tests/run_tests.sh
  - bash starter/02_check.sh
  - bash starter/02_check.sh examples/07_solutions.py
  - python3 examples/01_waiting.py
  - python3 examples/02_computing.py
  - python3 examples/03_blocking_coroutine.py
  - python3 examples/04_race.py
  - python3 examples/05_scheduler.py
  - python3 examples/06_timeouts.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 35
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64, 14 logical CPUs), Python 3.14.0 with Py_GIL_DISABLED=0, default switch interval 0.005 s, bash 3.2.57 — bash tests/run_tests.sh -> 58 checks, 0 failure(s), exit 0'
requirements/README.md (5171 bytes)
# Requirements — Day 096

## What you need

| Tool | Minimum | Used on the authoring machine | Why the minimum is what it is |
| --- | --- | --- | --- |
| `python3` | 3.11 | 3.14.0 | `asyncio.TaskGroup`, `asyncio.timeout` and `except*` all arrived in 3.11. Earlier versions raise a `SyntaxError` on `except*` |
| `bash` | 3.2 | 3.2.57 | `tests/run_tests.sh` and `starter/02_check.sh`. Written for bash 3.2 so the macOS system bash works unmodified |

Two or more logical CPUs. The machine used here had 14.

## What you do not need

`requirements.txt` in this directory lists no packages, and that is
deliberate rather than an oversight. Every concurrency model this lab
compares ships with Python:

| Model | Module | In the standard library since |
| --- | --- | --- |
| Threads | `threading` | Long enough that the documentation no longer records a version; it predates every other row here |
| Processes | `multiprocessing` | Python 2.6, 2008 |
| Uniform pool interface | `concurrent.futures` | Python 3.2, 2011 |
| Event loop and coroutines | `asyncio` | Python 3.4, 2014 |
| `async` / `await` syntax | language | Python 3.5, 2015 |
| Structured concurrency | `asyncio.TaskGroup` | Python 3.11, 2022 |

Also used, all standard library: `queue`, `time`, `urllib.request`,
`urllib.parse`, `http.server`, `socket` (indirectly, through `asyncio` and
`http.server`), `collections`, `statistics`, `sysconfig`, `importlib.util`,
`contextlib`, `pathlib`, `ast`.

`tests/run_tests.sh` parses every import in every `.py` file in this lab
against `sys.stdlib_module_names` and fails if anything outside it appears.
So this claim is checked on each run rather than asserted here.

## The libraries this lab talks about but does not install

The lesson's Alternatives section covers `trio`, `anyio`, `gevent` and
Celery. None of them is installed, none is imported, and **no output from any
of them is reproduced anywhere in this lab or lesson** — they are described
from their documentation and labelled as such. If you want to try them, they
belong in a virtual environment of your own, outside this directory.

## How the workload sizes were chosen

Both are calibrated, and the reasoning matters because a badly sized workload
makes a measurement lie.

**Waiting work** — 20 requests at 0.100 s each. The delay is a `time.sleep`
inside the fixture server, so it is exact and identical on every machine. It
is long enough that the waiting dominates the per-request overhead of urllib
and of opening a socket, and short enough that the sequential baseline
finishes in about two seconds. The result is a sequential run pinned near
2.0 s on any hardware, which is what makes the ratios comparable across
machines.

**Computing work** — 4 tasks, each counting the primes below 500,000 by
trial division, measured on the authoring machine at 0.364 s per call. The
full calibration, captured from the command below:

| Limit | Primes below it | Time on the authoring machine |
| --- | --- | --- |
| 120,000 | 11,301 | 0.046 s |
| 400,000 | 33,860 | 0.260 s |
| 500,000 | 41,538 | 0.364 s |
| 700,000 | 56,543 | 0.594 s |
| 1,000,000 | 78,498 | 1.014 s |

500,000 was chosen as the smallest size that is comfortably larger than the
cost of starting a process pool — on macOS, `ProcessPoolExecutor` uses the
spawn start method, so each of the four children re-imports the module before
doing any work. Too small a task and that start-up cost is the entire
measurement; too large and the test suite becomes tedious to run.

Trial division was chosen over a sieve on purpose: it is a tight arithmetic
loop in pure Python that holds the interpreter lock, which is precisely the
shape of work the comparison is about. A sieve would spend much of its time
in list operations and would muddy the result.

The starter checker uses 400,000 rather than 500,000, and 12 requests rather
than 20, so that a learner re-running it after every edit is not waiting
around.

## Reproducing the calibration

```bash
python3 - <<'PY'
import sys, time
sys.path.insert(0, "examples")
import labkit
for limit in (120_000, 400_000, 500_000, 700_000, 1_000_000):
    start = time.perf_counter()
    count = labkit.count_primes(limit)
    print(f"{limit:>9,}  {count:>7,} primes  {time.perf_counter() - start:.3f}s")
PY
```

Run it from the lab directory. Your times will differ; the prime counts will
not, and are checkable against any table of the prime-counting function.

## What is deliberately absent

- **No `pytest`.** The harness is a bash assert script, as everywhere else in
  this course, so the lab has no dependency at all.
- **No benchmarking library.** `time.perf_counter` and three repetitions with
  the spread printed is the right amount of machinery for teaching the shape
  of a result. A serious benchmark of a serious system needs more, and the
  lesson says so rather than pretending three runs is rigorous.
- **No async HTTP client.** There is none in the standard library. The lab
  writes twelve lines of raw `asyncio.open_connection` instead, which is more
  instructive than importing one would have been: you can see exactly which
  three lines are the `await` points.
requirements/requirements.txt (869 bytes)
# Day 096 — Concurrency and async Basics
#
# This file is intentionally empty of packages.
#
# Every concurrency model this lab compares ships with Python itself:
# threading, multiprocessing, concurrent.futures, asyncio and queue are all
# standard library. There is nothing to install and nothing to keep up to
# date, which is why this lab runs offline on a fresh machine.
#
# tests/run_tests.sh parses every import in every .py file here against
# sys.stdlib_module_names and fails if anything outside it appears, so this
# is checked on every run rather than merely claimed.
#
# The alternatives the lesson discusses — trio, anyio, gevent and Celery —
# are described from their documentation and are NOT installed. No output
# from any of them is reproduced anywhere in this lab. If you want to try
# them, use a virtual environment outside this directory.
starter/_progress.py (9253 bytes)
"""The progress checker behind `bash starter/02_check.sh`.

You do not need to edit this file, but reading it is worthwhile: it is the
same measure-do-not-believe discipline the lesson argues for, applied to
your own code. Nothing here inspects how you wrote a function. Every check
either compares a real answer against the right one, or times two versions
of the same work and compares the ratio.

Speed checks are stated as ratios with generous margins — "at least three
times faster", never "under 200 milliseconds" — so that a slow laptop, a
busy machine or a different operating system does not fail work that is
correct. The ratio is the claim that travels.

Usage:  python3 starter/_progress.py [path-to-module]
"""

from __future__ import annotations

import importlib.util
import sys
import time
from pathlib import Path

LAB = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(LAB / "examples"))

import labkit  # noqa: E402

TOTAL = 8
REQUESTS = 12
REQUEST_DELAY = 0.05
PRIME_LIMIT = 400_000
PRIME_TASKS = 4
PRIME_ANSWER = 33860  # the number of primes below 400,000
NAPS = [0.15] * 5
RACE_THREADS = 8
RACE_PER_THREAD = 50_000
TIGHT_INTERVAL = 1e-6

results: list[tuple[int, str, bool, str]] = []


def record(number: int, title: str, passed: bool, detail: str) -> None:
    results.append((number, title, passed, detail))


def load(path: Path):
    spec = importlib.util.spec_from_file_location("under_test", path)
    if spec is None or spec.loader is None:
        raise ImportError(f"cannot import {path}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def call(module, name: str, *args):
    """Call an exercise. Returns (elapsed, value) or (None, reason-string)."""
    function = getattr(module, name, None)
    if function is None:
        return None, f"no function named {name}"
    start = time.perf_counter()
    try:
        value = function(*args)
    except NotImplementedError:
        return None, "not started"
    except Exception as error:  # noqa: BLE001 - report anything, do not crash
        return None, f"{type(error).__name__}: {error}"
    return time.perf_counter() - start, value


def bodies_ok(value: object, count: int) -> bool:
    return (
        isinstance(value, list)
        and len(value) == count
        and all(isinstance(body, str) and "waited" in body for body in value)
        and all(f"/item/{n}" in value[n - 1] for n in range(1, count + 1))
    )


def check_waiting(module) -> float | None:
    """Exercises 1-3. Returns the sequential baseline, or None if it failed."""
    with labkit.fixture_server(delay=REQUEST_DELAY) as base:
        labkit.fetch(base + "/warmup")
        targets = labkit.urls(base, REQUESTS)

        elapsed, value = call(module, "fetch_all_sequentially", targets)
        if elapsed is None:
            record(1, "fetch_all_sequentially", False, str(value))
            baseline = None
        elif not bodies_ok(value, REQUESTS):
            record(1, "fetch_all_sequentially", False, "wrong bodies or wrong order")
            baseline = None
        else:
            baseline = elapsed
            record(1, "fetch_all_sequentially", True, f"{REQUESTS} bodies in order, {elapsed:.3f}s")

        for number, name, args in (
            (2, "fetch_all_with_threads", (targets, REQUESTS)),
            (3, "fetch_all_with_asyncio", (targets,)),
        ):
            elapsed, value = call(module, name, *args)
            if elapsed is None:
                record(number, name, False, str(value))
                continue
            if not bodies_ok(value, REQUESTS):
                record(number, name, False, "wrong bodies or wrong order")
                continue
            if baseline is None:
                record(number, name, False, "cannot judge speed: exercise 1 is not working yet")
                continue
            ratio = baseline / elapsed
            record(
                number,
                name,
                ratio >= 2.5,
                f"{ratio:.1f}x faster than sequential ({elapsed:.3f}s); needs >= 2.5x",
            )
    return baseline


def check_computing(module) -> None:
    """Exercises 4 and 5."""
    limits = [PRIME_LIMIT] * PRIME_TASKS
    expected = [PRIME_ANSWER] * PRIME_TASKS
    start = time.perf_counter()
    sequential_answer = [labkit.count_primes(limit) for limit in limits]
    baseline = time.perf_counter() - start
    assert sequential_answer == expected, "the checker's own baseline is wrong"

    elapsed, value = call(module, "count_primes_with_threads", limits, PRIME_TASKS)
    if elapsed is None:
        record(4, "count_primes_with_threads", False, str(value))
    elif value != expected:
        record(4, "count_primes_with_threads", False, f"wrong counts: {value}")
    else:
        record(
            4,
            "count_primes_with_threads",
            True,
            f"counts correct; {baseline / elapsed:.2f}x sequential — threads do not help here, "
            "and are not required to",
        )

    elapsed, value = call(module, "count_primes_with_processes", limits, PRIME_TASKS)
    if elapsed is None:
        record(5, "count_primes_with_processes", False, str(value))
    elif value != expected:
        record(5, "count_primes_with_processes", False, f"wrong counts: {value}")
    else:
        ratio = baseline / elapsed
        record(
            5,
            "count_primes_with_processes",
            ratio >= 1.4,
            f"counts correct; {ratio:.2f}x sequential ({elapsed:.3f}s); needs >= 1.4x",
        )


def check_offloading(module) -> None:
    """Exercise 6."""
    elapsed, value = call(module, "wait_without_blocking_the_loop", NAPS)
    if elapsed is None:
        record(6, "wait_without_blocking_the_loop", False, str(value))
        return
    if value != NAPS:
        record(6, "wait_without_blocking_the_loop", False, f"expected {NAPS}, got {value}")
        return
    serial = sum(NAPS)
    record(
        6,
        "wait_without_blocking_the_loop",
        elapsed < serial * 0.6,
        f"{elapsed:.3f}s against a serial floor of {serial:.2f}s; needs < {serial * 0.6:.2f}s",
    )


def check_counter(module) -> None:
    """Exercise 7, run at a switch interval that breaks unprotected code."""
    function = getattr(module, "counter_that_loses_nothing", None)
    if function is None:
        record(7, "counter_that_loses_nothing", False, "no function of that name")
        return
    expected = RACE_THREADS * RACE_PER_THREAD
    previous = sys.getswitchinterval()
    seen: list[int] = []
    try:
        sys.setswitchinterval(TIGHT_INTERVAL)
        for _ in range(3):
            try:
                seen.append(function(RACE_THREADS, RACE_PER_THREAD))
            except NotImplementedError:
                record(7, "counter_that_loses_nothing", False, "not started")
                return
            except Exception as error:  # noqa: BLE001
                record(7, "counter_that_loses_nothing", False, f"{type(error).__name__}: {error}")
                return
    finally:
        sys.setswitchinterval(previous)
    exact = all(value == expected for value in seen)
    worst = min(seen)
    record(
        7,
        "counter_that_loses_nothing",
        exact,
        f"3 runs at a {TIGHT_INTERVAL}s switch interval; "
        + (f"all exactly {expected:,}" if exact else f"lost up to {expected - worst:,} increments"),
    )


def make_task(steps: int):
    def task():
        for _ in range(steps):
            yield

    return task()


def check_scheduler(module) -> None:
    """Exercise 8."""
    tasks = [("a", make_task(3)), ("b", make_task(2)), ("c", make_task(1))]
    elapsed, value = call(module, "round_robin", tasks)
    if elapsed is None:
        record(8, "round_robin", False, str(value))
        return
    expected = ["a", "b", "c", "a", "b", "a"]
    record(
        8,
        "round_robin",
        value == expected,
        f"expected {expected}, got {value}",
    )


def main() -> int:
    target = Path(sys.argv[1]) if len(sys.argv) > 1 else LAB / "starter" / "01_exercises.py"
    if not target.is_absolute():
        target = (Path.cwd() / target).resolve()
    if not target.exists():
        print(f"No such file: {target}")
        return 2

    print(f"Checking {target.name}")
    print(f"python {sys.version.split()[0]}   "
          f"default switch interval {sys.getswitchinterval()} s")
    print()

    try:
        module = load(target)
    except Exception as error:  # noqa: BLE001
        print(f"The module would not import: {type(error).__name__}: {error}")
        print()
        print(f"0 of {TOTAL} exercises complete.")
        return 1

    check_waiting(module)
    check_computing(module)
    check_offloading(module)
    check_counter(module)
    check_scheduler(module)

    passed = 0
    for number, title, ok, detail in sorted(results):
        mark = "ok  " if ok else "open"
        passed += 1 if ok else 0
        print(f"  [{mark}] {number}. {title}")
        print(f"         {detail}")
    print()
    print(f"{passed} of {TOTAL} exercises complete.")
    return 0 if passed == TOTAL else 1


if __name__ == "__main__":
    raise SystemExit(main())
starter/00_brief.md (4418 bytes)
# The brief — Waiting Versus Computing

Read this before you write anything. It takes three minutes and it is the
whole point of the day.

## The situation

You have inherited a small service. It does two things.

It **fetches** twenty records from an upstream system, one HTTP request each,
and each request takes about a tenth of a second because the upstream is slow.
And it **computes** a checksum over each batch, which is a few hundred
milliseconds of arithmetic per batch with no I/O at all.

Both are slow. Somebody suggests "make it concurrent". That instruction is
not actionable, because the right answer for the first half is the wrong
answer for the second half, and the wrong answer looks like it worked
because the code compiles, the tests pass and the timings do not move.

## The one question

Before you choose a tool, answer this about the work in front of you:

> **Is this work waiting, or is it computing?**

- **Waiting** — for a socket, a disk, a database, a lock, a subprocess, a
  human. The CPU is idle. Threads help. An event loop helps more, at the
  cost of every library in the call path having to cooperate.
- **Computing** — arithmetic, parsing, encoding, compression, model
  inference on the CPU. The CPU is busy. Threads do not help, because
  CPython lets only one thread execute Python bytecode at a time. Processes
  help, because each process has its own interpreter.

That is the decision. Everything else on this day is you proving it to
yourself rather than taking it on trust.

## What you are going to build

Eight functions in `starter/01_exercises.py`. Run
`bash starter/02_check.sh` at any point to see where you stand; it starts by
saying `0 of 8 exercises complete.` and names each one that is still open.

| # | Function | The point |
| --- | --- | --- |
| 1 | `fetch_all_sequentially` | The baseline. Everything is measured against it |
| 2 | `fetch_all_with_threads` | Waiting work + threads. Must be at least 2.5x faster |
| 3 | `fetch_all_with_asyncio` | Waiting work + event loop. Must be at least 2.5x faster |
| 4 | `count_primes_with_threads` | Computing work + threads. Correct, and **not** faster |
| 5 | `count_primes_with_processes` | Computing work + processes. Must be at least 1.4x faster |
| 6 | `wait_without_blocking_the_loop` | A blocking call rescued with `asyncio.to_thread` |
| 7 | `counter_that_loses_nothing` | A race the checker forces to happen, and your fix |
| 8 | `round_robin` | An event loop of your own, in about a dozen lines |

Exercise 4 is the one people skip because it looks like a repeat of exercise
2. It is the opposite of exercise 2, and the checker will print the ratio so
you can see it.

## What the requests actually talk to

Nothing outside your machine. `examples/labkit.py` starts a small HTTP
server bound to `127.0.0.1` on a port the operating system picks, and that
server sleeps for a fixed number of milliseconds before answering. So the
waiting is genuine — a real socket, a really blocked thread — and it is
also exactly reproducible, which the internet never is. Nothing in this lab
needs a network connection, a key or an account.

## How you will be judged

By behaviour and by ratios, never by a stopwatch reading. The checker asks
"is the threaded version at least two and a half times faster than the
sequential one on this machine, right now?" — never "did it finish in under
200 milliseconds". A millisecond figure is a fact about the machine that
produced it. A ratio is a fact about the program.

Hold yourself to the same rule when you report a performance result to
anybody: say what you measured, say how many times you ran it, say what the
spread was, and say which machine on which day.

## The order to work in

1. Exercise 1, then 2, then 3, and **look at the ratios** the checker prints.
2. Exercise 4, then 5, and look at those ratios too. This is the lesson.
3. Exercise 6 — the mistake that makes an async service serve one request at
   a time while looking busy.
4. Exercise 7 — shared state. Read `examples/04_race.py` afterwards, because
   it contains a result that contradicts what most books say about this, and
   the reason it does is worth knowing.
5. Exercise 8 — write the loop. After this, `await` is not magic.

Then run the six example scripts in order and read them. They are the
narrated version of everything you just did, with the measurements printed.
starter/01_exercises.py (7443 bytes)
"""Day 096 starter — eight exercises. This is where YOUR work happens.

Read `starter/00_brief.md` first. Then run:

    bash starter/02_check.sh

It will say `0 of 8 exercises complete.` and tell you which. Work down the
list, re-running the checker as you go. The checker never looks at how you
wrote a function — only at whether it behaves correctly and, where the
point of the exercise is speed, whether it is actually faster.

Everything you need is in the standard library and in `examples/labkit.py`,
which is imported for you below. The pieces of labkit you will want:

    labkit.fetch(url)              blocking fetch, returns the body as str
    labkit.fetch_async(url)        coroutine fetch, must be awaited
    labkit.count_primes(limit)     CPU-bound, returns an int
    labkit.urls(base, count)       build the list of request URLs

Do not change any function's name or signature: the checker calls them.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples"))

import labkit  # noqa: E402,F401  (imported for you; every exercise below uses it)


# ---------------------------------------------------------------------------
# Exercise 1 — the baseline. Fetch every URL, one after another.
#
# Use labkit.fetch on each URL in turn and return the bodies in the same
# order as the URLs. A list comprehension is enough. This is deliberately
# the slow one: you need it to measure the others against.
# ---------------------------------------------------------------------------
def fetch_all_sequentially(urls: list[str]) -> list[str]:
    raise NotImplementedError("Exercise 1: return [labkit.fetch(u) for u in urls]")


# ---------------------------------------------------------------------------
# Exercise 2 — the same work with threads, and it must actually be faster.
#
# Use concurrent.futures.ThreadPoolExecutor with max_workers=workers, and
# its .map method, which returns results in INPUT order rather than
# completion order. Wrap the executor in a `with` block so the pool shuts
# down cleanly.
#
# The checker requires this to be at least 2.5x faster than exercise 1 on
# the same URLs — a real speed-up, not just a different spelling.
# ---------------------------------------------------------------------------
def fetch_all_with_threads(urls: list[str], workers: int) -> list[str]:
    raise NotImplementedError("Exercise 2: use ThreadPoolExecutor(max_workers=workers).map")


# ---------------------------------------------------------------------------
# Exercise 3 — the same work on one thread with an event loop.
#
# This is an ordinary def, not an async def. Inside it, define a coroutine
# (or use asyncio.gather directly) that awaits labkit.fetch_async for every
# URL, and drive it with asyncio.run. Return the bodies in URL order —
# asyncio.gather preserves the order of its arguments.
#
# The checker requires this to be at least 2.5x faster than exercise 1.
# ---------------------------------------------------------------------------
def fetch_all_with_asyncio(urls: list[str]) -> list[str]:
    raise NotImplementedError("Exercise 3: asyncio.run over asyncio.gather(*fetch_async(u))")


# ---------------------------------------------------------------------------
# Exercise 4 — CPU-bound work with threads. It will NOT be faster.
#
# Same shape as exercise 2, but map labkit.count_primes over `limits`.
# Return the counts in input order.
#
# The checker only requires the ANSWERS to be right here. It then measures
# the speed-up and prints it, so you can see for yourself that threads did
# nothing for work that never waits.
# ---------------------------------------------------------------------------
def count_primes_with_threads(limits: list[int], workers: int) -> list[int]:
    raise NotImplementedError("Exercise 4: ThreadPoolExecutor over labkit.count_primes")


# ---------------------------------------------------------------------------
# Exercise 5 — the same CPU work with processes, and this one must be faster.
#
# Change ThreadPoolExecutor to ProcessPoolExecutor. That is the whole edit.
# It works because labkit.count_primes is a module-level function, so the
# child processes can import it; a lambda or a closure would fail to pickle.
#
# The checker requires this to be at least 1.4x faster than the sequential
# version of the same work.
# ---------------------------------------------------------------------------
def count_primes_with_processes(limits: list[int], workers: int) -> list[int]:
    raise NotImplementedError("Exercise 5: ProcessPoolExecutor over labkit.count_primes")


# ---------------------------------------------------------------------------
# Exercise 6 — repair a blocking call inside a coroutine.
#
# You are given a list of nap lengths in seconds and must wait all of them
# CONCURRENTLY, using only the blocking time.sleep — imagine it is a
# synchronous database driver you do not own.
#
# Writing `async def nap(n): time.sleep(n)` and gathering them does not
# work: it takes sum(naps), because time.sleep never gives the loop back.
# Use `await asyncio.to_thread(time.sleep, n)` instead, gather those, and
# return the nap lengths in input order.
#
# The checker requires the whole thing to finish in well under sum(naps).
# ---------------------------------------------------------------------------
def wait_without_blocking_the_loop(naps: list[float]) -> list[float]:
    raise NotImplementedError("Exercise 6: gather asyncio.to_thread(time.sleep, n) for each nap")


# ---------------------------------------------------------------------------
# Exercise 7 — a counter that loses nothing.
#
# Start `threads` threads. Each increments one shared counter `per_thread`
# times. Return the final value, which must be exactly threads * per_thread.
#
# The checker runs your function with the interpreter's thread-switch
# interval turned down to a microsecond, which makes an unprotected
# read-add-write lose increments on every single run. A threading.Lock held
# around the read and the write is the direct answer. Accumulating into a
# local variable per thread and adding the subtotals at the end is the
# better one, and also passes.
# ---------------------------------------------------------------------------
def counter_that_loses_nothing(threads: int, per_thread: int) -> int:
    raise NotImplementedError("Exercise 7: protect the read-add-write, or stop sharing it")


# ---------------------------------------------------------------------------
# Exercise 8 — write the event loop.
#
# `tasks` is a list of (name, generator) pairs. Each generator yields to
# pause and eventually stops. Run them round-robin: take the first task,
# advance it exactly one step with next(), and if it has not finished, put
# it at the BACK of the queue. Return the list of names in the order they
# were stepped.
#
# collections.deque with popleft() and append() is the natural structure.
# A generator that has finished raises StopIteration from next(); catch it
# and drop that task.
#
# With three tasks of 3, 2 and 1 steps the answer is:
#   ['a', 'b', 'c', 'a', 'b', 'a']
# ---------------------------------------------------------------------------
def round_robin(tasks: list[tuple[str, object]]) -> list[str]:
    raise NotImplementedError("Exercise 8: a deque, popleft, next(task), append unless finished")
starter/02_check.sh (1074 bytes)
#!/usr/bin/env bash
# How far through the eight exercises are you?
#
#   bash starter/02_check.sh                          # checks your work
#   bash starter/02_check.sh examples/07_solutions.py  # checks the reference
#
# Exits 0 only when all eight are complete, so it cannot be mistaken for
# finished. Every check is a real value or a real speed ratio; none of them
# looks at how you wrote anything.
set -u

starter_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
lab_dir="$(cd "${starter_dir}/.." && pwd)"

python_bin="${PYTHON:-$(command -v python3 || true)}"
if [ -z "${python_bin}" ] || [ ! -x "${python_bin}" ]; then
  echo "python3 was not found. Install Python 3.11 or newer, or set PYTHON=/path/to/python3."
  exit 2
fi

# Keep the lab directory clean: no __pycache__ left behind by the import.
export PYTHONDONTWRITEBYTECODE=1

if [ "$#" -ge 1 ]; then
  target="$1"
  case "${target}" in
    /*) ;;
    *) target="${lab_dir}/${target}" ;;
  esac
else
  target="${starter_dir}/01_exercises.py"
fi

"${python_bin}" "${starter_dir}/_progress.py" "${target}"
tests/run_tests.sh (19810 bytes)
#!/usr/bin/env bash
# Tests for the Day 096 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# WHAT THIS SUITE ASSERTS, AND WHAT IT DELIBERATELY DOES NOT
#
# It never asserts a number of milliseconds. A millisecond figure is a fact
# about one machine on one day; it is flaky everywhere else and it teaches
# nothing. What this suite asserts is the SHAPE of the result, with margins
# wide enough to survive a slow laptop, a busy CI runner or a different
# operating system:
#
#   * waiting work gets much faster with threads, and with an event loop
#   * computing work does NOT get meaningfully faster with threads
#   * computing work DOES get faster with processes
#   * a blocking call inside a coroutine collapses the loop to serial, and
#     asyncio.to_thread repairs it
#   * an unprotected shared counter loses increments; a protected one does not
#   * two locks taken in opposite orders deadlock; taken in one order they do not
#   * the hand-written generator scheduler really interleaves its tasks
#
# The example scripts print machine-readable `RESULT name value` lines. This
# suite parses those and applies its own thresholds, rather than trusting the
# scripts' own `SHAPE` verdicts — a test that asks the code under test whether
# it passed is not a test.
#
# Nothing here touches the network: the only sockets are on 127.0.0.1. Nothing
# needs sudo. Everything is built in a temporary directory removed in a trap.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work=""
checks=0
failures=0

cleanup() {
  [ -n "${work}" ] && [ -d "${work}" ] && rm -rf "${work}"
  find "${lab_dir}" -type d -name __pycache__ -prune -exec rm -rf -- {} + 2>/dev/null
  return 0
}
trap cleanup EXIT INT TERM

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

check_eq() {
  local label="$1" expected="$2" actual="$3"
  checks=$((checks + 1))
  if [ "${expected}" = "${actual}" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    echo "        expected: ${expected}"
    echo "        actual:   ${actual}"
    failures=$((failures + 1))
  fi
}

python_bin="${PYTHON:-$(command -v python3 || true)}"
if [ -z "${python_bin}" ] || [ ! -x "${python_bin}" ]; then
  echo "python3 was not found. Install Python 3.11+ or set PYTHON=/path/to/python3."
  exit 1
fi

export PYTHONDONTWRITEBYTECODE=1
work="$(mktemp -d)"

# Clear any bytecode cache left by a hand-run of one of the scripts, so that
# the "nothing left behind" check at the end is an assertion about THIS suite
# rather than about whatever happened in this directory earlier.
find "${lab_dir}" -type d -name __pycache__ -prune -exec rm -rf -- {} + 2>/dev/null

# metric FILE NAME — pull one `RESULT name value` line out of a captured run.
metric() {
  grep "^RESULT $2 " "$1" 2>/dev/null | awk '{print $3}' | head -1
}

# ratio_at_least VALUE FLOOR — arithmetic in Python, because bash has no floats.
ratio_at_least() {
  "${python_bin}" -c '
import sys
try:
    print("yes" if float(sys.argv[1]) >= float(sys.argv[2]) else "no")
except (ValueError, IndexError):
    print("no")
' "${1:-}" "${2:-}"
}

ratio_below() {
  "${python_bin}" -c '
import sys
try:
    print("yes" if float(sys.argv[1]) < float(sys.argv[2]) else "no")
except (ValueError, IndexError):
    print("no")
' "${1:-}" "${2:-}"
}

cores="$("${python_bin}" -c 'import os; print(os.cpu_count() or 1)')"
gil_disabled="$("${python_bin}" -c 'import sysconfig; print(sysconfig.get_config_var("Py_GIL_DISABLED"))')"

echo "Day 096 — Concurrency and async Basics"
echo "python3:          $("${python_bin}" -c 'import sys; print(sys.version.split()[0])')"
echo "cpu_count:        ${cores}"
echo "Py_GIL_DISABLED:  ${gil_disabled}"
echo "switch interval:  $("${python_bin}" -c 'import sys; print(sys.getswitchinterval())') s"
echo "work:             a temporary directory, removed when this script exits"
echo

# ---------------------------------------------------------------------------
echo "0. The interpreter is the one these measurements assume"
# ---------------------------------------------------------------------------
check "python is 3.11 or newer (asyncio.timeout and TaskGroup are required)" \
  "$("${python_bin}" -c 'import sys; print("yes" if sys.version_info >= (3, 11) else "no")')"
check "at least two usable CPUs, or the process comparison cannot mean anything" \
  "$([ "${cores}" -ge 2 ] && echo yes || echo no)"
# This is a fact about the build, reported rather than assumed. On a
# free-threaded build (PEP 703) the threads-do-not-help check below is
# expected NOT to hold, and the suite says so instead of failing silently.
if [ "${gil_disabled}" = "1" ]; then
  echo "  note: this is a free-threaded build. The 'threads do not help computing"
  echo "        work' check is skipped, because on this build it should not hold."
fi

# ---------------------------------------------------------------------------
echo
echo "1. Waiting work: threads and an event loop both collapse it"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/01_waiting.py" > "${work}/waiting.txt" 2>&1
waiting_status=$?
check_eq "01_waiting.py exits 0" "0" "${waiting_status}"

seq_s="$(metric "${work}/waiting.txt" waiting_sequential_s)"
thr_speedup="$(metric "${work}/waiting.txt" waiting_threaded_speedup)"
aio_speedup="$(metric "${work}/waiting.txt" waiting_asyncio_speedup)"

check "the sequential baseline really did pay all 20 waits (>= 1.5s of waiting)" \
  "$(ratio_at_least "${seq_s}" 1.5)"
check "threads are at least 4x faster than sequential on waiting work" \
  "$(ratio_at_least "${thr_speedup}" 4.0)"
check "asyncio is at least 4x faster than sequential on waiting work" \
  "$(ratio_at_least "${aio_speedup}" 4.0)"
check "all three approaches returned 20 well-formed bodies" \
  "$([ "$(grep -c '20 bodies, all well formed: yes' "${work}/waiting.txt")" = "3" ] && echo yes || echo no)"
check "order is preserved: the first body is the one for /item/1" \
  "$(grep -q 'first body is for /item/1' "${work}/waiting.txt" && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "2. Computing work: the answer flips, and that is the whole day"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/02_computing.py" > "${work}/computing.txt" 2>&1
computing_status=$?
check_eq "02_computing.py exits 0" "0" "${computing_status}"

cpu_thr="$(metric "${work}/computing.txt" computing_threaded_speedup)"
cpu_proc="$(metric "${work}/computing.txt" computing_processes_speedup)"
cpu_aio="$(metric "${work}/computing.txt" computing_asyncio_speedup)"

if [ "${gil_disabled}" = "1" ]; then
  check "skipped on a free-threaded build: threads may legitimately help here" yes
else
  check "threads do NOT meaningfully speed up computing work (< 1.5x)" \
    "$(ratio_below "${cpu_thr}" 1.5)"
fi
check "processes DO speed up computing work (>= 1.5x)" \
  "$(ratio_at_least "${cpu_proc}" 1.5)"
check "asyncio does NOT speed up computing work either (< 1.5x)" \
  "$(ratio_below "${cpu_aio}" 1.5)"
check "processes beat threads on this workload by a clear margin" \
  "$("${python_bin}" -c '
import sys
print("yes" if float(sys.argv[1]) >= float(sys.argv[2]) * 1.4 else "no")
' "${cpu_proc}" "${cpu_thr}")"
check "every approach still produced the right answer: 41538 primes below 500,000" \
  "$([ "$(grep -c 'correct: yes' "${work}/computing.txt")" = "4" ] && echo yes || echo no)"
check "the script reports the GIL status of the interpreter it actually ran on" \
  "$(grep -q "Py_GIL_DISABLED ${gil_disabled}" "${work}/computing.txt" && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "3. A blocking call inside a coroutine, and its two repairs"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/03_blocking_coroutine.py" > "${work}/blocking.txt" 2>&1
blocking_status=$?
check_eq "03_blocking_coroutine.py exits 0" "0" "${blocking_status}"

blocked_s="$(metric "${work}/blocking.txt" blocking_gathered_s)"
await_speedup="$(metric "${work}/blocking.txt" blocking_vs_await_speedup)"
thread_speedup="$(metric "${work}/blocking.txt" blocking_vs_to_thread_speedup)"
blocked_gap="$(metric "${work}/blocking.txt" blocked_heartbeat_gap_ms)"
healthy_gap="$(metric "${work}/blocking.txt" healthy_heartbeat_gap_ms)"

check "gathering 5 blocking coroutines takes the SERIAL time (>= 0.9s for 5 x 0.2s)" \
  "$(ratio_at_least "${blocked_s}" 0.9)"
check "await asyncio.sleep makes the same five overlap (>= 2.5x faster)" \
  "$(ratio_at_least "${await_speedup}" 2.5)"
check "asyncio.to_thread does too, for code you cannot rewrite (>= 2.5x faster)" \
  "$(ratio_at_least "${thread_speedup}" 2.5)"
check "a blocking coroutine starves an unrelated task on the same loop (3x the gap)" \
  "$("${python_bin}" -c '
import sys
print("yes" if float(sys.argv[1]) >= float(sys.argv[2]) * 3 else "no")
' "${blocked_gap}" "${healthy_gap}")"
check "and the healthy loop kept its 10ms heartbeat roughly on time (< 50ms)" \
  "$(ratio_below "${healthy_gap}" 50)"
check "all three versions returned identical correct results — the failure is silent" \
  "$([ "$(grep -c 'correct: yes' "${work}/blocking.txt")" = "3" ] && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "4. Shared state: a counter that loses increments, and three fixes"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/04_race.py" > "${work}/race.txt" 2>&1
race_status=$?
check_eq "04_race.py exits 0" "0" "${race_status}"

expected_total="$(metric "${work}/race.txt" expected_total)"
lost_tight="$(metric "${work}/race.txt" race_lost_at_tight_interval)"
locked="$(metric "${work}/race.txt" locked_total)"
queued="$(metric "${work}/race.txt" queued_total)"

check_eq "the counter should reach 400000: 8 threads x 50000" "400000" "${expected_total}"
check "the UNPROTECTED counter loses increments — and loses a lot of them (>= 1000)" \
  "$(ratio_at_least "${lost_tight}" 1000)"
check_eq "the LOCKED counter loses none: exactly 400000" "400000" "${locked}"
check_eq "the queue version loses none either: exactly 400000" "400000" "${queued}"
check "two locks taken in opposite orders really deadlock" \
  "$(grep -q 'opposite_lock_order_deadlocks yes' "${work}/race.txt" && echo yes || echo no)"
check "the same two locks taken in one consistent order do not" \
  "$(grep -q 'consistent_lock_order_does_not yes' "${work}/race.txt" && echo yes || echo no)"
check "the script restores the interpreter's switch interval when it is done" \
  "$("${python_bin}" - "${lab_dir}" <<'PY'
import importlib.util
import sys
from pathlib import Path

lab = Path(sys.argv[1])
sys.path.insert(0, str(lab / "examples"))
spec = importlib.util.spec_from_file_location("race", lab / "examples" / "04_race.py")
race = importlib.util.module_from_spec(spec)
spec.loader.exec_module(race)

before = sys.getswitchinterval()
race.unsafe_total(race.TIGHT_INTERVAL)   # runs at 1e-6 internally
after = sys.getswitchinterval()
print("yes" if after == before else f"no: left it at {after}")
PY
)"
check "and it says plainly what the DEFAULT switch interval produced on this machine" \
  "$(grep -q 'RESULT race_lost_at_default_interval' "${work}/race.txt" && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "5. The scheduler built from generators actually interleaves"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/05_scheduler.py" > "${work}/scheduler.txt" 2>&1
scheduler_status=$?
check_eq "05_scheduler.py exits 0" "0" "${scheduler_status}"

check_eq "three tasks of 3, 2 and 1 steps interleave as a b c a b a" \
  "alpha,beta,gamma,alpha,beta,alpha" \
  "$(metric "${work}/scheduler.txt" round_robin_order)"
check_eq "a task that never yields runs to completion before any other starts" \
  "greedy,greedy,greedy,greedy,polite,polite,polite" \
  "$(metric "${work}/scheduler.txt" greedy_order)"
check "a sleeping task leaves the ready queue and the other task runs meanwhile" \
  "$(grep -q 'napper-start:0 worker:1 worker:2 worker:3 worker:4 napper-woke:5' "${work}/scheduler.txt" && echo yes || echo no)"
check "each task's return value is collected through StopIteration.value" \
  "$(grep -q 'returned: gamma did 1 step$' "${work}/scheduler.txt" && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "6. Cancellation and timeouts"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/06_timeouts.py" > "${work}/timeouts.txt" 2>&1
timeouts_status=$?
check_eq "06_timeouts.py exits 0" "0" "${timeouts_status}"

timeout_s="$(metric "${work}/timeouts.txt" timeout_elapsed_s)"
survivors="$(metric "${work}/timeouts.txt" gather_survivors)"
cancelled="$(metric "${work}/timeouts.txt" taskgroup_cancelled_siblings)"

check "the timeout fired at the caller's budget, not at the work's length (< 0.35s)" \
  "$(ratio_below "${timeout_s}" 0.35)"
check "and it did fire rather than the request quietly succeeding (>= 0.10s)" \
  "$(ratio_at_least "${timeout_s}" 0.10)"
check "cancellation ran the task's finally block: the socket was closed, not leaked" \
  "$(grep -q 'report cleaned up' "${work}/timeouts.txt" && echo yes || echo no)"
check "the cancelled task saw CancelledError inside itself and re-raised it" \
  "$(grep -q 'report received CancelledError' "${work}/timeouts.txt" && echo yes || echo no)"
check_eq "gather(return_exceptions=True): 2 of 3 finish despite one raising" "2.0000" "${survivors}"
check "and the failure came back as a ValueError VALUE, not as a raise" \
  "$(grep -q '\[1\] ValueError b could not be fetched' "${work}/timeouts.txt" && echo yes || echo no)"
check_eq "TaskGroup: the same failure cancels both siblings instead" "2.0000" "${cancelled}"
check "the TaskGroup error arrives in an ExceptionGroup, caught with except*" \
  "$(grep -q 'caught in the ExceptionGroup: b could not be fetched' "${work}/timeouts.txt" && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "7. The starter reports honest progress, and the reference completes it"
# ---------------------------------------------------------------------------
before="$(bash "${lab_dir}/starter/02_check.sh" 2>&1)"
before_status=$?
check "the untouched starter reports 0 of 8 exercises complete" \
  "$(printf '%s' "${before}" | grep -q '^0 of 8 exercises complete\.$' && echo yes || echo no)"
check_eq "and exits non-zero, so it cannot be mistaken for finished" "incomplete" \
  "$([ "${before_status}" -ne 0 ] && echo incomplete || echo "exit ${before_status}")"
check "it names the process-pool exercise among the work still to do" \
  "$(printf '%s' "${before}" | grep -q 'count_primes_with_processes' && echo yes || echo no)"

solved="${work}/solved"
mkdir -p "${solved}"
cp -R "${lab_dir}/examples" "${lab_dir}/starter" "${solved}/"
cp "${solved}/examples/07_solutions.py" "${solved}/starter/01_exercises.py"
after="$(bash "${solved}/starter/02_check.sh" 2>&1)"
after_status=$?
check "with the reference answers in place it reports 8 of 8" \
  "$(printf '%s' "${after}" | grep -q '^8 of 8 exercises complete\.$' && echo yes || echo no)"
check_eq "and exits 0" "0" "${after_status}"

# The suite must be able to fail, or it proves nothing. Break exercise 2 so it
# does the work sequentially, keeping every answer correct, and confirm the
# checker refuses it on speed alone.
"${python_bin}" - "${solved}" <<'PY'
import pathlib, sys
target = pathlib.Path(sys.argv[1]) / "starter" / "01_exercises.py"
source = target.read_text(encoding="utf-8")
broken = source.replace(
    "    with ThreadPoolExecutor(max_workers=workers) as pool:\n"
    "        return list(pool.map(labkit.fetch, urls))",
    "    return [labkit.fetch(url) for url in urls]",
    1,
)
assert broken != source, "the sabotage did not apply — the reference file changed shape"
target.write_text(broken, encoding="utf-8")
PY
sabotaged="$(bash "${solved}/starter/02_check.sh" 2>&1)"
check "a 'threaded' answer that is secretly sequential is caught, not waved through" \
  "$(printf '%s' "${sabotaged}" | grep -q '^8 of 8 exercises complete\.$' && echo no || echo yes)"
check "and it is caught on SPEED, not on correctness — the bodies were all right" \
  "$(printf '%s' "${sabotaged}" | grep -q 'fetch_all_with_threads' && \
     printf '%s' "${sabotaged}" | grep -q 'needs >= 2.5x' && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "8. Hygiene: offline, no sudo, nothing left behind"
# ---------------------------------------------------------------------------
"${python_bin}" - "${lab_dir}" > "${work}/hygiene.txt" <<'PY'
import re
import sys
from pathlib import Path

root = Path(sys.argv[1])
hosts, sudo_lines = set(), []
comment = re.compile(r"^\s*#")
for path in sorted(root.rglob("*")):
    if not path.is_file() or path.suffix not in {".py", ".sh"}:
        continue
    for number, line in enumerate(
        path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1
    ):
        for host in re.findall(r"https?://([^/\s\"')]+)", line):
            # A literal loopback address, or an f-string placeholder that can
            # only ever be filled in from server_address, is fine. Anything
            # else is a URL this lab has no business containing.
            if host != "127.0.0.1" and not host.startswith("{"):
                hosts.add(f"{path.name}:{number} {host}")
        if re.search(r"(^|[;|&(]\s*)sudo\s", line) and not comment.match(line):
            sudo_lines.append(f"{path.name}:{number}")
print("HOSTS " + " ".join(sorted(hosts)))
print("SUDO " + " ".join(sudo_lines))
PY
check_eq "no URL in this lab names any host but the loopback address" \
  "HOSTS" \
  "$(grep '^HOSTS ' "${work}/hygiene.txt" | sed 's/ *$//')"
check_eq "no line in this lab would actually invoke sudo" "SUDO" \
  "$(grep '^SUDO ' "${work}/hygiene.txt" | sed 's/ *$//')"
check "every socket this lab opens is bound to 127.0.0.1" \
  "$(grep -q '"127.0.0.1", 0' "${lab_dir}/examples/labkit.py" && echo yes || echo no)"
check "no captured output leaks an absolute home path" \
  "$(grep -rl '/Users/\|/home/' "${lab_dir}/expected-output" >/dev/null 2>&1 && echo no || echo yes)"
check "no __pycache__ directory survives inside the lab" \
  "$(find "${lab_dir}" -type d -name __pycache__ | grep -q . && echo no || echo yes)"
check "nothing in this lab imports a third-party package" \
  "$("${python_bin}" - "${lab_dir}" <<'PY'
import ast
import sys
from pathlib import Path

STDLIB = set(sys.stdlib_module_names)
LOCAL = {"labkit"}
bad = []
for path in sorted(Path(sys.argv[1]).rglob("*.py")):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            names = [alias.name.split(".")[0] for alias in node.names]
        elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
            names = [node.module.split(".")[0]]
        else:
            continue
        bad += [n for n in names if n not in STDLIB and n not in LOCAL]
print("yes" if not bad else "no: " + ", ".join(sorted(set(bad))))
PY
)"

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 096

Grouped by the message you actually see, or by the symptom when there is no message. The last section is the one to read if your numbers disagree with the captured ones, because on this day that is often correct rather than broken.

Errors with a message

Can't pickle <function <lambda> at 0x...> / AttributeError: Can't get attribute

From ProcessPoolExecutor. Child processes do not inherit your function — they import the module it lives in and look the name up. So the target must be a module-level function with a real name. A lambda, a closure, a function defined inside another function, and a method of an object that itself cannot be pickled will all fail here.

This is why examples/labkit.py defines count_primes at module level, and why exercise 5 works by changing one word rather than restructuring anything.

RuntimeError: asyncio.run() cannot be called from a running event loop

asyncio.run is the boundary between synchronous code and the loop. It creates a loop, runs one coroutine to completion, and closes the loop. Call it once, from ordinary synchronous code, at the top.

Inside a coroutine you already have a loop, so you await instead. If you need to run a coroutine from synchronous code that is itself running on a loop's thread, you have an architecture problem rather than a syntax one.

RuntimeWarning: coroutine 'fetch_async' was never awaited

You called a coroutine function and did nothing with the result. Calling fetch_async(url) does not fetch anything: it builds a coroutine object. The work happens when it is awaited, or when it is handed to asyncio.gather, asyncio.TaskGroup.create_task or asyncio.create_task.

This warning is one of the friendlier things asyncio does. The silent version of the same mistake is described under "Symptoms with no message" below.

TypeError: object list can't be used in 'await' expression

You awaited something that is not awaitable. Common cause: await in front of a list comprehension of coroutines rather than in front of asyncio.gather(*coroutines).

SyntaxError on async with asyncio.timeout(...) or on except*

Your interpreter predates 3.11. asyncio.timeout, asyncio.TaskGroup and except* all arrived in Python 3.11. Check with python3 --version and, if you have a newer one installed elsewhere:

PYTHON=/path/to/python3 bash tests/run_tests.sh

OSError: [Errno 48] Address already in use

Should not happen here: the fixture server binds to port 0, which asks the operating system for a free ephemeral port. If you see it, you have modified fixture_server to use a fixed port. Put the 0 back.

at least two usable CPUs — the suite exits early

The process comparison cannot mean anything on a single-CPU machine, so the suite says so instead of reporting a ratio that would be noise. Everything else in the lab still runs and still teaches; run the individual example scripts directly.

Symptoms with no message

A coroutine never runs, and nothing warns you

You used asyncio.create_task(...) and then never awaited anything before the function returned. create_task schedules; it does not start. Nothing on a loop starts until the currently running coroutine gives the thread back at an await.

This exact bug is in examples/03_blocking_coroutine.py as a commented demonstration — the await asyncio.sleep(0.03) in measure_starvation is there for this reason, and the comment says so. It was also a real bug during the writing of this lab: the first version of that function measured no starvation at all because the heartbeat had not started yet.

Your async code is correct and exactly as slow as the sequential version

Something in the call path is blocking. Symptoms: total time equals the sum of the parts rather than the maximum, and CPU usage is near zero throughout.

Look inside every async def for a call that is not preceded by await. The usual four are time.sleep, requests.get (or any synchronous HTTP client), a synchronous database driver, and a file read from a slow or network filesystem. Wrap the offender in await asyncio.to_thread(...).

The threaded version is no faster than the sequential one

If the work never waits, this is the correct result, and reproducing it is exercise 4. Threads overlap waiting. They cannot overlap Python bytecode execution, because only one thread holds the interpreter lock at a time. Use processes.

If the work does wait and threads still do not help, check that the thing being waited on can actually serve more than one caller at once. A fixture server that handled one request at a time would serialise every client — this is why labkit._WaitServer extends ThreadingHTTPServer, and there is a comment there saying so.

The process version is no faster, or is slower

Three usual causes, in order of likelihood:

  1. The tasks are too small. Starting a process and pickling arguments to it costs real milliseconds, and on macOS and Windows the spawn start method re-imports your module in every child. If each task takes 2 ms, the overhead is the entire measurement. Extension exercise 1 finds the crossover point deliberately.
  2. The arguments or results are large. Everything crossing a process boundary is pickled and copied. Sending a large array to a worker and getting a large array back can cost more than the computation.
  3. Too few cores. Check python3 -c "import os; print(os.cpu_count())".

8 of 8 will not appear even though every answer looks right

Exercises 2, 3 and 5 are judged on speed as well as correctness, because being correct was never the hard part. The checker prints both the ratio it measured and the ratio it needs:

[open] 2. fetch_all_with_threads
       1.0x faster than sequential (0.658s); needs >= 2.5x

A ratio near 1.0 means your "concurrent" version is doing the work in sequence. This is exactly the sabotage the test suite performs on the reference answer to prove the checker is not vacuous.

The unsafe counter does not lose any increments for you

This is a real observation, not a broken lab, and it is worth understanding rather than working around.

On the authoring machine — CPython 3.14.0, macOS, arm64 — the unprotected read-add-write counter lost zero increments in 20 dedicated trials at the interpreter's default 5 ms thread switch interval. The race is genuinely there; the window is simply narrower than one thread's time slice, so the switch rarely lands inside it.

That is why examples/04_race.py reports the default-interval result first and honestly, and only then drops the switch interval to 1 microsecond, at which point the same unchanged code loses roughly 70% of its increments on every single run. Nothing about the bug changed — only how often the interpreter considers handing the thread to somebody else.

The lesson to take is not "races are rare". It is that a concurrency bug's visibility is a property of timing, not of correctness, so you cannot test your way to confidence about one. You reason about it, or you remove the shared mutable state so there is nothing to reason about.

If you want to make it land at the default interval, that is extension exercise 2.

The suite passes but takes much longer than 40 seconds

Expected on a slower machine, and not a failure: the sequential baselines are real waiting and real computing. The CPU-bound section is the slowest part — four prime counts, four ways, three times each. Nothing in the suite has a wall-clock deadline, precisely so that a slow machine cannot fail it.

A __pycache__ directory keeps appearing

Both harness scripts set PYTHONDONTWRITEBYTECODE=1, so this only happens if you ran an example by hand without it. The suite clears any pre-existing one at the start and asserts that none exists at the end, so it is testing its own behaviour rather than your shell history. To clear it:

find . -type d -name __pycache__ -prune -exec rm -rf -- {} +

The deadlock demonstration reports deadlocked: no

The two threads did not overlap, so the cycle never formed. 04_race.py uses a threading.Barrier to guarantee both threads hold one lock before either asks for the second, which makes it reliable — if you have modified that part, put the barrier back.

Note the barrier belongs only in the broken version. Adding one to the fixed version makes it hang, because a thread holding the first lock would wait at the barrier for a thread that cannot reach the barrier until it gets that same first lock. That is a genuine deadlock introduced by the act of trying to force an interleaving, and the comment in the file explains it.

When your numbers disagree with the captured ones

Read expected-output/FIELDS.md first. It lists exactly which values must match on every machine — the prime counts, the counter totals, the scheduler's interleaving order, the number of surviving tasks — and which are expected to differ, which is every single elapsed time.

If a shape disagrees — threads speeding up CPU-bound work, or processes not speeding it up — check Py_GIL_DISABLED first:

python3 -c "import sysconfig; print(sysconfig.get_config_var('Py_GIL_DISABLED'))"

A 1 means you are on a free-threaded build (PEP 703), where threads genuinely can execute Python bytecode in parallel and the "threads do not help" result is expected not to hold. The suite detects this and skips that one check with a printed note. Every other result in the lab stands.

Security notes

Security notes — Day 096

What this lab does to your machine

Action Does this lab do it? Evidence
Reach the internet No requires_network: false. The suite asserts no URL in any file names a host other than the loopback address
Open a listening socket Yes — on 127.0.0.1 only examples/labkit.py binds ("127.0.0.1", 0); the suite asserts that literal is present
Run sudo No The suite scans every .py and .sh file for a line that would invoke it
Install anything No requirements/requirements.txt lists no packages; the suite parses every import and fails on any non-standard-library name
Need a credential or key No requires_api_key: false. Nothing here authenticates to anything
Write outside its own directory No Everything goes into mktemp -d, removed in a trap
Leave files behind No PYTHONDONTWRITEBYTECODE=1 is set by both harness scripts, and the suite asserts no __pycache__ survives
Start processes Yes — its own worker processes ProcessPoolExecutor with a bounded pool, shut down by its with block
Change process-wide interpreter state Yes — briefly sys.setswitchinterval, always restored in a finally; the suite verifies the restoration

These are checks the suite performs, not promises this file makes. Run bash tests/run_tests.sh and read section 8 of its output.

The fixture server

The only network activity in this lab is a small HTTP server that examples/labkit.py starts, uses and shuts down inside a context manager.

  • It binds to 127.0.0.1, the loopback address, so it is reachable only from your own machine. It is not exposed on your local network, and a firewall prompt is not expected.
  • It binds to port 0, which asks the operating system for a free ephemeral port. It therefore cannot collide with a service you are already running, and two copies of this lab can run at the same time.
  • It answers every path identically, after a sleep. It reads nothing from disk, executes nothing, and stores nothing. There is no path handling to get wrong, and therefore no directory-traversal surface.
  • It is shut down and closed in a finally block, and its thread is joined, so no socket is left listening after the script exits.
  • Its per-request log line is suppressed, which is a readability decision rather than a security one, and is stated in the code.

http.server is documented by Python as not recommended for production because it implements only basic security checks. That is the correct use of it here — a disposable fixture on the loopback address for the duration of one script — and it is the reason this lab does not suggest exposing it.

sys.setswitchinterval is process-wide

examples/04_race.py and starter/_progress.py both lower the interpreter's thread switch interval to 1 microsecond in order to make a latent race land reliably. This is a global setting, not a scoped one.

Both restore the previous value in a finally block, so an exception cannot leave your interpreter in that state, and tests/run_tests.sh asserts that the value is unchanged after the race function has run. If you copy this technique into your own diagnostics, copy the finally with it: leaving an interpreter at a 1 microsecond switch interval makes every threaded program in that process dramatically slower.

Concurrency as a security surface

This is the part specific to today, and it is worth more than the checklist above.

A race condition is a security bug, not only a correctness bug. The lost update in examples/04_race.py is a toy: two threads read a counter, both add one, one write wins, one increment vanishes. Replace "counter" with "account balance", "remaining quota", "number of licences in use" or "has this token already been redeemed?", and the same shape becomes a time-of-check-to-time-of-use flaw. The pattern to recognise is any sequence of check, then act where the state can change in between:

if user.has_permission(document):   # the check
    send(document)                  # the act — permission may have been revoked

The lab's own demonstration is the honest version of why this class of bug is so dangerous: on the authoring machine the unprotected counter lost nothing at all in 20 trials at the default settings, and lost 70% of its increments when the thread switch interval changed. The bug was equally present in both cases. Only its visibility changed. You cannot test your way to confidence here; you either reason about the invariant, or you remove the shared mutable state.

A blocked event loop is a denial of service you inflict on yourself. examples/03_blocking_coroutine.py measures an unrelated task being starved for 211 milliseconds by one coroutine that called time.sleep. In a real service that is one slow synchronous call in one request handler stopping every other request on that worker. No error is raised, no alert fires, and the metric that shows it is tail latency rather than an error rate. An attacker who finds the one endpoint that blocks does not need a botnet.

Timeouts are not optional on anything that waits. examples/06_timeouts.py shows a request cancelled at the caller's budget rather than the work's length. Without one, a slow or hostile upstream decides how long your service holds a connection, a thread, or a slot in a pool. Note that cancellation in asyncio is an exception delivered inside the task, so finally blocks run and resources are released — which is why the pattern in that file re-raises CancelledError rather than swallowing it. Swallowing it produces a task that cannot be stopped, which is its own availability problem.

Data

There is none. No personal data, no credentials, no fixtures containing anything about a real person. The fixture server's responses are of the form waited 0.100s for /item/7. The only inputs are integers and paths this lab generated itself.

What this lab does not cover

  • Securing a multi-process application. Process pools here run trusted local code. Anything that unpickles data from an untrusted source is a different subject with a much sharper edge — pickle executes code during deserialisation by design.
  • Distributed task queues. Celery and its relatives are named in the lesson's Alternatives section and are not run here. Their security model — the broker, its credentials, and what a worker will accept as a task — is substantial and is not taught by this lab.
  • Thread-safety of third-party libraries. No third-party package is used here, and the suite enforces that. When you do use one, "is this object safe to share between threads?" is a question with a documented answer far more often than people check.