# Exercise 06 — The semaphore that limits concurrency and nothing else

**Failure mode:** thread explosion / oversubscription
**Language:** Swift 6, Dispatch + Mach · **Runtime:** about 35 seconds · **Difficulty:** the one with the trap in it

---

## The prompt

> An importer reads 128 records through a synchronous, blocking API. Someone saw the
> thread count climbing in Activity Monitor and added a `DispatchSemaphore(value: 4)` "to
> limit concurrency to 4".
>
> The thread count did not change. The semaphore is right there in the code, the limit is
> correct, and concurrency really is 4. Explain what the semaphore is actually doing, and
> fix the thread count without changing the limit.

Answer before you run anything: **when a Dispatch work item blocks, what does libdispatch
do about it?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/ingest.swift` | The starting point. The permit is inside the work item. |
| `fixed/ingest.swift` | The repair, plus a structured-concurrency version measured beside it. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Proves the explosion and the bound. |

---

## Reproduce the failure

```bash
cd exercises/06-thread-explosion
swiftc -swift-version 6 -O broken/ingest.swift -o /tmp/ingest_broken
swiftc -swift-version 6 -O fixed/ingest.swift  -o /tmp/ingest_fixed
/tmp/ingest_broken
/tmp/ingest_fixed
```

Expected (Apple M4 Pro, 14 cores, macOS 26.3):

```
EX06 build=broken items=128 limit=4 blockMs=120 cores=14 baselineThreads=3 \
     peakThreads=73 threadsPerCore=5.2 permitHolders=4 elapsedMs=5890

EX06 build=fixed  items=128 limit=4 blockMs=120 cores=14 baselineThreads=3 \
     peakThreads=8  threadsPerCore=0.6 permitHolders=4 elapsedMs=5975
EX06 build=fixed variant=structured items=128 limit=4 blockMs=120 cores=14 \
     structuredPeakThreads=8 structuredThreadsPerCore=0.6 structuredElapsedMs=4027
```

**Timeout and safety.** Fixed item count, fixed 120 ms block, and a 120-second watchdog
that calls `_exit(75)`. Both builds exit 0. The broken build will briefly create dozens of
kernel threads; that is the measurement, and it is over in six seconds.

---

## The evidence to collect

`peakThreads` is sampled every 10 ms from `task_threads()` — the same number Activity
Monitor's **Threads** column shows. Watch **two** numbers together, because either one
alone tells you the wrong story:

| | broken | fixed |
| --- | --- | --- |
| `permitHolders` — how many items were inside the critical region at once | **4** | **4** |
| `peakThreads` — live kernel threads | **73** | **8** |

The semaphore was doing its job the whole time. Concurrency really was limited to 4. What
it never limited was **how much of the system the unstarted work was holding on to**.

In Instruments, the System Trace **Thread State** view shows the same thing: a wall of
threads in a blocked state, each one created because the previous one stopped making
progress.

---

## The mechanism to be able to explain

A Dispatch global queue is **overcommitting**. When a work item blocks, libdispatch cannot
tell the difference between "blocked" and "slow", so to keep the queue's width occupied it
brings up another thread. Blocking work items therefore convert directly into threads,
each with its own stack and its own share of the scheduler.

Taking the permit *inside* the work item means all 128 items are already on the queue,
already started, already holding threads — and only then waiting. The permit is the last
thing they do before the work, when the damage is already done.

The Swift concurrency **cooperative pool** is deliberately not overcommitting: it is sized
to the core count, and an `await` suspends the *task* while the thread moves on. That is
why blocking inside a `Task` is a correctness problem rather than a style preference — and
Swift 6 will not even compile `DispatchSemaphore.wait()` in an async context ("unavailable
from asynchronous contexts"). Both files keep the Dispatch path in a synchronous function
for exactly that reason.

---

## Success criteria

- [ ] The broken build still explodes — you have not edited it.
- [ ] Your fixed build's `peakThreads` is at most **24** on two consecutive runs.
- [ ] `permitHolders` is still **4**. If your fix changed the concurrency limit, you
      changed the behaviour, not the resource cost.
- [ ] Peak threads dropped by at least **3×** against the broken build.
- [ ] You can explain why the wall-clock time barely moved, and why that is the right
      outcome.

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

---

## Hints

<details>
<summary>Hint 1 — count what is already in flight</summary>

Before the first permit is even taken, how many of the 128 closures have been handed to
the queue? How many has libdispatch started? Each started closure is on a thread. The
permit controls what happens *after* that point.
</details>

<details>
<summary>Hint 2 — move the decision, not the primitive</summary>

The semaphore is the right tool and 4 is the right number. The question is **which thread
should block**: the one doing the work, or the one deciding to create the work? Work that
has not been admitted should not exist yet.
</details>

<details>
<summary>Hint 3 — mind which side returns the permit</summary>

If you move `wait()` to the submission site, be careful where `signal()` goes. Returning
the permit at submission time bounds nothing. It has to be returned when the work
**finishes**, which means it stays inside the closure even though its partner moved out.
</details>

---

## Solution

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

### The repair

```swift
for _ in 0..<items {
    gate.wait()                                 // throttles the SUBMITTING thread
    DispatchQueue.global(qos: .utility).async(group: group) {
        Thread.sleep(forTimeInterval: 0.120)    // the blocking API
        gate.signal()                           // a permit frees only on COMPLETION
    }
}
```

The diff is two lines moving. Measured effect: **73 → 8** peak threads, with concurrency
still capped at 4.

### Why it works

`wait()` now blocks the **submitting** thread — one thread, the one you already had.
Unadmitted work is not on the queue, has not started, and holds nothing. At most `limit`
items are ever in flight, so at most `limit` worker threads are ever tied up by this
workload.

Note the asymmetry: `wait()` moved out of the closure, `signal()` stayed in. The permit
must be released when the work *completes*, not when it is *submitted* — otherwise you are
counting submissions, and the queue fills up again.

### Why the wall time barely changed

Both builds took about six seconds, because both were limited to 4 concurrent items. That
is the correct outcome and worth saying out loud: **this repair is about resource cost, not
throughput.** The broken version was paying for 73 threads' worth of stacks, scheduler
pressure and context switches to achieve exactly the same rate of progress. It was the
worst of both worlds — fully serialised *and* fully oversubscribed.

If an interviewer asks "so what did you actually gain?", the answer is memory, scheduler
contention, and not being the process that starves everything else on the machine.

### The structured version

The fixed file also measures the same bounded workload written with a task group:

```swift
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<min(limit, items) { group.addTask { await work() } }
    while submitted < items {
        await group.next()          // wait for one to finish...
        group.addTask { await work() }
        submitted += 1
    }
}
```

Peak threads: also 8, and it finished faster (4.0 s vs 6.0 s) because a suspension costs
far less than a blocked thread. The bound is visible in the control flow rather than
encoded in a counter somewhere else in the file.

**But this version is only available if the blocking call can become `async`.** Putting
`Thread.sleep` — or any synchronous I/O — inside that task group would block a
cooperative-pool thread, and since that pool is sized to the core count, a handful of such
tasks can stall every other task in the process. That is the forward-progress violation
Swift's concurrency model is built to prevent, and it is strictly worse than the Dispatch
version, because Dispatch would at least have spawned more threads.

So the decision is:

| Situation | Do this |
| --- | --- |
| The API is synchronous and you cannot change it | Admission **before** submission, on Dispatch |
| The API is or can become `async` | Bounded task group; never block inside it |
| The API is synchronous but must be called from async code | Hop to a dedicated queue or executor; do not block the cooperative pool |

### What the interviewer is listening for

- That you explained **overcommit** rather than saying "Dispatch makes too many threads".
- That you noticed the semaphore was working correctly and said so, instead of blaming it.
- That you volunteered that wall time did not improve, rather than hoping nobody checked.
- That you knew structured concurrency is not automatically the answer when the underlying
  call still blocks.

</details>

---

## Limitations

- `peakThreads` is a **high-water mark sampled every 10 ms**. It can miss a briefer spike,
  so treat it as a floor on the true peak rather than an exact figure.
- 73 is close to libdispatch's own thread ceiling on this machine, so a larger item count
  does not produce a proportionally larger number — the explosion saturates. The point is
  the ratio to the core count, not the absolute value.
- The fixed build's ceiling of 24 in the bundled check is generous. The measured value is
  8, set by the concurrency limit plus this process's baseline threads, and is essentially
  independent of how many cores the machine has.
- `Thread.sleep` stands in for a blocking API. A real blocking call may also hold a lock,
  which turns oversubscription into contention as well.
