Head-of-line blocking: one queue versus two
Measure your machineNeeds Python
Problem
A shared queue serves jobs in order. When long jobs occupy every worker, short jobs behind them wait for the long ones to finish, however little work they need: head-of-line blocking. The machine looks idle while it happens, because the workers are waiting (or sleeping, or on I/O), not computing. The metric that reveals it is not utilisation but the age of the oldest waiting job.
Create a thread pool with 4 workers and one shared queue. Submit "big" jobs (sleep 5 s) followed by 200 "small" jobs (sleep 1 ms), and record each small job's completion time. Then give the big jobs their own pool, or cut them into 50 chunks of 100 ms, and repeat. Also record the age of the oldest waiting job.
The question. How long do the small jobs wait behind the big ones, and what does each fix change?
Predict
Expect the small jobs to wait up to about 5 s in the first run while CPU stays near zero, and to finish in a few milliseconds in the second. The age of the oldest waiting job is the metric that shows the problem.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
head-of-line-blocking-one-queue.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Head-of-line blocking: one queue, mixed jobs. A pool of 4 workers and one shared queue: 4 big jobs (5 s each) followed by 200 small ones (1 ms). Then the big jobs on their own pool, and cut into 100 ms chunks. The wait of the small jobs, and the age of the oldest waiting job, which is the metric that shows the problem. """ import argparse, json, queue, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--big-s", type=float, default=5.0, help="Seconds per big job.") p.add_argument("--small", type=int, default=200, help="Small jobs.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def pool(n): q = queue.Queue(); waits = []; lock = threading.Lock() def worker(): while True: t0, cost, small = q.get() if small: with lock: waits.append(time.time() - t0) time.sleep(cost); q.task_done() for _ in range(n): threading.Thread(target=worker, daemon=True).start() return q, waits def oldest(q): with q.mutex: items = list(q.queue) return max((time.time() - t0 for t0, _, _ in items), default=0.0) rows = [] for label in ("one shared queue", "big jobs on their own pool", "big jobs cut into 100 ms chunks"): q, waits = pool(4) if label == "one shared queue": for _ in range(4): q.put((time.time(), args.big_s, False)) elif label == "big jobs on their own pool": bq, _ = pool(4) for _ in range(4): bq.put((time.time(), args.big_s, False)) else: n = int(args.big_s / 0.1) for c in range(n): # interleave chunks with the small jobs for _ in range(4): q.put((time.time(), 0.1, False)) if c == 0: for _ in range(args.small): q.put((time.time(), 0.001, True)) if label != "big jobs cut into 100 ms chunks": time.sleep(0.05) for _ in range(args.small): q.put((time.time(), 0.001, True)) time.sleep(0.5); age = oldest(q) while len(waits) < args.small: time.sleep(0.05) rows.append({"setup": label, "max_small_wait_s": max(waits), "median_small_wait_s": sorted(waits)[len(waits) // 2], "oldest_age_at_0_5s": age}) print(f"{label:<34} small jobs: max wait {max(waits):5.2f} s, median {rows[-1]['median_small_wait_s']:5.3f} s oldest waiting job after 0.5 s: {age:4.2f} s") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"setups": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run head-of-line-blocking-one-queue.py uv run head-of-line-blocking-one-queue.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --big-s5 | Seconds per big job. |
| --small200 | Small jobs. |
Our run
Small-job waits
| Setup | Max wait | Median wait | Oldest job age at 0.5 s |
|---|---|---|---|
| one shared queue | 5.03 s | 4.99 s | 506 ms |
| big jobs on their own pool | 73.7 ms | 37.6 ms | 0 µs |
| big jobs cut into 100 ms chunks | 176 ms | 140 ms | 503 ms |
one shared queue small jobs: max wait 5.03 s, median 4.989 s oldest waiting job after 0.5 s: 0.51 s
big jobs on their own pool small jobs: max wait 0.07 s, median 0.038 s oldest waiting job after 0.5 s: 0.00 s
big jobs cut into 100 ms chunks small jobs: max wait 0.18 s, median 0.140 s oldest waiting job after 0.5 s: 0.50 sWhat we found
With one shared queue the small jobs waited up to 5.03 s (median 4.99 s), and half a second in, the oldest waiting job was already 506 ms old. A separate pool for the big jobs cut the worst wait to 73.7 ms. Cutting the big jobs into 100 ms chunks cut it to 176 ms: small jobs now interleave with chunks, so each waits at most a chunk or two.
Read it
Isolation and preemption are the two fixes, and they have different costs: a separate pool needs capacity reserved for each class, while chunking needs work that can be split and resumed. Both are visible only in the right metric. Utilisation said nothing was wrong; the oldest job's age said so within half a second.