Files
http-latency-prober/docs/explanations/2026-07-02-13-10-hxprobe-concurrency.md
Jan Novak f487a4b1bd docs: hxprobe plans, summaries, explanations, usage, and changelog
Plans (docs/plans/):
- 2026-07-01-23-47-py-hxprobe-httpx.md — initial httpx probe design
- 2026-07-02-09-32 through 14-05 — standalone project, toolchain, usage
  doc + Makefile, file input (-f), simplification pass, run-summary footer

Summaries (docs/summaries/): one per completed feature, recording what
was actually built, deviations from the plan, and verification steps

Explanations (docs/explanations/): two deep-dives written during review —
hxprobe concurrency model and worst-exit-code + render-loop analysis

Usage (docs/usage/hxprobe.md): overview with pointer to hxprobe/USAGE.md
for the full runnable reference

Walkthrough (docs/py-latprobe-walkthrough.md): narrative tour of the
latprobe Python package for interview / code-review context

CHANGELOG.md: entries for all hxprobe features (toolchain, usage doc,
file input, simplification, run-summary footer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:22:59 +02:00

3.2 KiB

How does concurrency work in hxprobe?

Question

Selection in hxprobe/hxprobe/cli.py:448-449:

with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
    all_results = list(ex.map(_probe, urls))

explain how concurrency works in hxprobe

Answer

There are two independent levels of repetition:

  1. Across URLs — parallel, via a thread pool.
  2. Across samples of the same URL (-n/--count) — sequential, within a single thread.

Worker count

cli.py:440-443

workers = ns.concurrency
if workers <= 0:
    workers = min(len(urls), 8)
workers = max(1, min(workers, len(urls)))

-c/--concurrency picks the pool size; 0 (default) means "auto" → min(len(urls), 8). The final clamp guarantees 1 ≤ workers ≤ len(urls) — never more threads than there are URLs to probe, never zero.

The pool itself

cli.py:445-449

def _probe(url: str) -> tuple[list[Result], list[Result]]:
    return _run_samples(url, count, opts)

with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
    all_results = list(ex.map(_probe, urls))

One task per URL is submitted to the pool. _probe delegates to _run_samples, cli.py:64-69:

def _run_samples(url: str, count: int, opts: Options) -> tuple[list[Result], list[Result]]:
    succeeded, failed = [], []
    for _ in range(count):
        r = measure(url, opts)
        (failed if r.err else succeeded).append(r)
    return succeeded, failed

So within one URL's thread, the count samples run one at a time — never concurrently. This is deliberate: if N samples fired at the same host in parallel, they'd contend for the same TCP/TLS handshake path and connection setup, and the resulting min/avg/max per phase would reflect that contention rather than the host's actual latency. Keeping samples sequential is what makes -n's statistics meaningful.

Why threads work here despite the GIL

measure() does blocking socket/SSL I/O — socket.connect(), .recv(), ssl.wrap_socket(), etc. (see the custom _TimingBackend/_TimingStream in probe.py). CPython releases the GIL around blocking syscalls, so N threads genuinely overlap in wall-clock time waiting on the network, even though only one thread executes Python bytecode at once. This is I/O-bound concurrency, not CPU parallelism — threads are the right tool, not asyncio or multiprocessing.

Ordering guarantee

ex.map(_probe, urls) returns results in the same order as the input urls, regardless of which thread finishes first — that's a documented property of Executor.map. That's why list(ex.map(...)) can be zipped directly against urls afterward (cli.py:454) to produce deterministic text/JSON output order, even though the underlying probes complete out of order.

No shared mutable state / no locks needed

Each thread's _probe call returns its own (succeeded, failed) tuple; nothing is written to a shared structure until back in the main thread after the with block exits (which also blocks until every submitted task completes, since ThreadPoolExecutor.__exit__ calls shutdown(wait=True)). The worst-exit-code accumulation and JSON building (cli.py:451-472) then run single-threaded over all_results.