Skip to content
About
Main content

Head-of-line blocking: one queue versus two

Measure your machineNeeds Python

Problem

A shared 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: . 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

With one shared queue behind 5-second jobs, how long does a small job wait at the median?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    head-of-line-blocking-one-queue.py61 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--big-s5 Seconds per big job.
--small200 Small jobs.

Our run

Small-job waits

Small-job waits
SetupMax waitMedian waitOldest job age at 0.5 s
one shared queue5.03 s4.99 s506 ms
big jobs on their own pool73.7 ms37.6 ms0 µs
big jobs cut into 100 ms chunks176 ms140 ms503 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run head-of-line-blocking-one-queue.py
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 s
macOS 26.6.2 · Python 3.13.3 · run 2026-10-25

What 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 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.