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>
57 lines
3.2 KiB
Markdown
57 lines
3.2 KiB
Markdown
# How does concurrency work in hxprobe?
|
|
|
|
## Question
|
|
|
|
Selection in `hxprobe/hxprobe/cli.py:448-449`:
|
|
```python
|
|
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`
|
|
```python
|
|
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`
|
|
```python
|
|
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`:
|
|
```python
|
|
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`.
|