# Exercise 03 — The idle pipeline that drains the battery

**Failure mode:** busy-wait — a thread that is never blocked and never useful
**Language:** C, pthreads · **Runtime:** about 5 seconds · **Difficulty:** the one where the answer is already correct

---

## The prompt

> An ingest pipeline takes frames from a capture device at 200 frames per second and hashes
> each one on a pool of worker threads, one per core.
>
> The field reports: "the fans spin up and the battery drains even when almost nothing is
> arriving. Activity Monitor shows us pinned near 100% of several cores while we are only
> handling 200 frames a second."
>
> Every frame is processed exactly once and the checksum is right. Find the cost, remove
> it, and tell me the one case where the original shape would have been the better choice.

Answer before you run anything: **what is the difference between a thread that is blocked
and a thread that is runnable, and which one costs a core?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/pipeline.c` | The starting point. Workers spin on an atomic. |
| `fixed/pipeline.c` | One correct repair. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Asserts CPU-per-wall-core in both builds, twice each. |

---

## Reproduce the measurement

```bash
cd exercises/03-spin-wait
clang -O2 -g -Wall -Wextra -pthread broken/pipeline.c -o /tmp/pipeline_broken
clang -O2 -g -Wall -Wextra -pthread fixed/pipeline.c  -o /tmp/pipeline_fixed
/tmp/pipeline_broken
/tmp/pipeline_fixed
```

Observed on an Apple M4 Pro (14 cores), macOS 26.3 (25D125):

```
                       broken        fixed
processed                 200          200      identical
checksum       11966739960003344344   (same)    identical
wallMs                 1359.4       1228.1      the fixed build is not slower
cpuSeconds             17.184        0.003      5,700x less CPU
cpuPerWallCore          12.64         0.00      12.6 cores held, versus none
involuntarySwitches     23437          433
```

**Reproduce these; do not quote them.**

---

## The evidence to collect

1. **`cpuPerWallCore = 12.64`.** The program held the equivalent of 12.6 cores continuously
   to process 200 small frames. This single number is the bug. Wall time does not show it,
   because the pipeline is paced by the producer in both builds.

2. **`involuntarySwitches = 23,437`.** The scheduler preempted these threads 23,000 times in
   1.4 seconds. They were always runnable and never had anything to do, so the scheduler
   kept cycling them through the cores.

3. **What the kernel calls the state.** `thread_info(..., THREAD_BASIC_INFO)` reports
   `TH_STATE_RUNNING` for a spinning thread and `TH_STATE_WAITING` for a blocked one.
   Critically, it reports the **same `TH_STATE_WAITING`** whether the thread is waiting on a
   lock, on a timer, or on I/O — so the state alone never tells you *what* it is waiting
   for. You always need the stack as well. That is a useful thing to know before an
   interviewer asks how you would tell a hang from a spin.

4. **A metric that does NOT work here.** `getrusage(2)`'s `ru_nvcsw` — voluntary context
   switches — reports **0 on macOS 26.3 for both builds**, so it cannot be used to show that
   the fixed build blocks. `cpuSeconds` is the honest measurement. The check script says so
   in its header rather than quietly not using it.

5. **In Instruments**, the same story appears in **System Trace**'s thread-state lane as
   solid running bands with no blocked time, and in **CPU Profiler** as a large sample count
   inside the spin loop itself. The spin loop is the hottest function in the program, which
   is a strong hint given it computes nothing.

---

## Success criteria

- [ ] The broken build still burns cores — you have not edited it.
- [ ] Your fixed build's `cpuPerWallCore` is at most **0.25**.
- [ ] Your fixed build's `processed` equals `frames`, and its `checksum` equals the broken
      build's, exactly.
- [ ] Both bounds hold on two consecutive runs.
- [ ] Your wait is inside a `while` loop testing the predicate, and you can say why an `if`
      would be a bug even on a platform that never spuriously wakes.
- [ ] You can name the situation in which spinning *is* the right answer, with a rough
      figure for the crossover.

Run `./check.sh` to have all of that checked for you.

---

## Hints

<details>
<summary>Hint 1 — what does the worker do when there is no work?</summary>

Read the inner `for (;;)` loop in `worker()`. Ask what instruction it executes when
`published == claimed`. Now ask what the scheduler is supposed to do with a thread that is
always ready to run.
</details>

<details>
<summary>Hint 2 — you need two things, not one</summary>

Leaving the CPU means asking the kernel to stop scheduling you until a condition becomes
true. That needs a way to *say* the condition became true, and a way to test it without
racing against the announcement. One primitive does not give you both; a matched pair does.
</details>

<details>
<summary>Hint 3 — the predicate and the lock must be the same lock</summary>

Whatever you wait on has to release, atomically, the thing that guards the state you are
testing — otherwise you can test "no work", and then have work published, and then go to
sleep, and never be woken. That is a lost wakeup, and it is much harder to debug than the
problem you started with.
</details>

---

## Solution

<details>
<summary>Reveal the solution</summary>

### The repair

```c
static pthread_mutex_t m    = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t  work = PTHREAD_COND_INITIALIZER;

/* worker */
pthread_mutex_lock(&m);
while (claimed >= published && !shutting_down)
    pthread_cond_wait(&work, &m);
...
pthread_mutex_unlock(&m);

/* producer */
pthread_mutex_lock(&m);
published = f;
pthread_cond_signal(&work);
pthread_mutex_unlock(&m);
```

`pthread_cond_wait` releases the mutex and parks the thread in the kernel **atomically**.
The thread leaves every run queue: it now costs a stack and a scheduler slot and no CPU.
The kernel makes it runnable again when the producer signals.

### The three details that are not optional

1. **`while`, not `if`.** A wakeup is a hint that the predicate *may* hold, not a promise
   that it does. Several waiters can be woken for one item; a spurious wakeup is permitted.
   Re-testing the predicate after waking is the only correct shape.
2. **The mutex guards the predicate.** `published` and `claimed` moved from atomics to
   plain variables under `m`, because the condition variable's atomicity guarantee is
   defined in terms of *that* mutex. Testing a predicate the mutex does not protect
   reintroduces the lost-wakeup window.
3. **`signal` for one item, `broadcast` for shutdown.** One frame can only be consumed by
   one worker, so waking one is correct and waking all is a thundering herd. Shutdown
   concerns every waiter, so it broadcasts.

### What the fix costs

A mutex acquisition per frame and a kernel round trip per wakeup — a handful of
microseconds each, against 5,000 microseconds of idle time per frame. Here it is free.
The wall time actually *improved* slightly, because 14 spinning threads were competing with
the producer for cores.

### When spinning is right

When the expected wait is **shorter than the cost of parking and waking a thread** —
roughly a few microseconds on this machine, where a kernel-mediated round trip measures
about 4.5 µs. That is why `os_unfair_lock` and friends spin briefly before blocking: they
are betting on a short critical section, and they *fall back* to blocking when the bet
loses. An unbounded spin with no fallback, waiting on work that arrives every 5,000 µs, is
the bet nobody should take.

If you do write a bounded spin, say what bound you chose and how you would measure whether
it was right.
</details>

---

## Going further

- Raise `FRAME_GAP_US` to 50,000 (20 fps) and re-measure. The broken build's CPU cost goes
  *up*, because there is more idle time to burn. Any metric that gets worse as the workload
  gets lighter is measuring the wrong thing, and this is a compact demonstration of that.
- Replace the condition variable with a semaphore and argue which is the better fit here.
  A semaphore counts permits; a condition variable tests a predicate. The pipeline has a
  monotonically increasing sequence number, which is a predicate, not a count.
