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>
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:
- Across URLs — parallel, via a thread pool.
- 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.