Probe multiple URLs in parallel with a bounded worker pool; the N samples of each URL remain sequential to preserve accurate min/avg/max statistics. Default auto-concurrency is min(numURLs, 8); -c 1 restores serial mode. Output is always buffered and printed in original input order. Verified clean with go test -race. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.9 KiB
Plan: Concurrency (Go, Step 7)
Context
Large probe runs are slow. The example matrices in docs/custom-usage-examples.md
are 15 URLs × 10 samples = 150 sequential HTTP requests, dominated by summed
network round-trips, so a single run takes minutes. Execution is currently
strictly sequential: run() loops over URLs and, for each, runSamples() loops
-n times calling probe.Measure().
We want to cut wall-clock time by probing multiple URLs in parallel, while keeping measurement honest. Concurrency and latency accuracy are in tension: firing many requests at once makes them contend for the local NIC/CPU/resolver and inflates timings. The decision (confirmed with the user) is to parallelize only across URLs — the samples of a single URL stay sequential so each URL's min/avg/max remains a clean, self-consistent measurement.
Confirmed decisions:
- Granularity: across URLs only; samples sequential per URL. Unit of work =
one URL with its full
-nsample set. - Default concurrency: auto when
-cis not given →min(numURLs, 8).-c 1forces fully sequential (most accurate);-c Nsets an explicit cap. - Output: buffered and printed in original input order (deterministic; identical layout to today, for both text and JSON).
Files to modify
go/main.go — core change
-
New flag (mirror the
-n/--countpattern at lines 78–79):conc := fs.Int("concurrency", 0, "max URLs probed in parallel (0 = auto)") fs.IntVar(conc, "c", 0, "max URLs probed in parallel (shorthand)")Add
-c, --concurrencytousageText(around lines 22–27). -
Resolve effective concurrency after the empty-URL check (line 96):
const defaultMaxConc = 8 workers := *conc if workers <= 0 { workers = min(len(urls), defaultMaxConc) // auto } workers = min(workers, len(urls)) // never exceed work units if workers < 1 { workers = 1 }(Go 1.26 has builtin
min.) -
Split
run()into measure phase (concurrent) + render phase (sequential). Introduce a small result holder:type urlResult struct { url string succeeded, failed []probe.Result }Measure phase — bounded worker pool via a semaphore channel +
sync.WaitGroup, each goroutine writing only its own preallocated slot (no shared mutable state, so no mutex / data race):results := make([]urlResult, len(urls)) sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, rawURL := range urls { wg.Add(1) go func(i int, rawURL string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() s, f := runSamples(rawURL, *count, opts) results[i] = urlResult{rawURL, s, f} }(i, rawURL) } wg.Wait()Render phase — the existing per-URL loop body (lines 102–127), unchanged in behaviour, now reading from
results[i]instead of callingrunSamplesinline.worstCodeaccumulation, JSON entry building, and ordered text printing all stay identical. JSON encode block (129–136) andreturn worstCodeunchanged.runSamples,printURL,buildJSONEntry, and all output helpers stay as-is.
Concurrency safety: probe.Measure uses http.DefaultClient, which is safe for
concurrent use; distinct URLs hit distinct hosts so the shared transport pool is
not a correctness concern. Output order is deterministic because rendering reads
the indexed results slice after wg.Wait().
go/run_test.go — add coverage
TestRunConcurrentOrder: spin up severalhttptestservers (reuse existingstatusSrvhelper), pass them with-clarger than 1, and assert the printed blocks appear in input order and the exit code matches the sequential run.- Add a mixed success/failure case under high
-cto confirmworstCodeaggregation is unaffected by parallelism.
Makefile + docs/usage/makefile.md
- Add
go-test-race:go -C $(GO_DIR) test -race ./...(with##help comment, added to.PHONY). Document it in the Go targets table. Leavecheckas-is (race run kept as an explicit opt-in target).
Docs & conventions
docs/usage/step-7-concurrency.md(new): explain the-c/--concurrencyflag, the auto default (min(numURLs, 8)), the across-URLs-only model, the accuracy trade-off (use-c 1for the most precise numbers), and a worked example with before/after timing.README.md: add-c, --concurrencyto the Usage flags block; add Step 7 — Concurrency row (✅) to the Go roadmap table.CHANGELOG.md: new top entry,2026-07-01 01:23 — Concurrency (Go, Step 7).docs/custom-usage-examples.md: note near the top that runs now parallelize across URLs by default and that-c 1restores serial timing; revise the Example 2/3/9 runtime estimates to reflect the speedup.docs/plans/2026-07-01-01-23-concurrency.md: copy of this plan (per CLAUDE.md).
Verification
make build
# Auto concurrency (defaults to min(numURLs,8)) — should be much faster than before:
time ./go/latprobe -n 5 https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# Forced serial for comparison / accuracy:
time ./go/latprobe -c 1 -n 5 https://www.google.com https://www.bbc.co.uk \
https://www.lemonde.fr https://www.spiegel.de https://www.abc.net.au
# Output order is deterministic regardless of -c:
./go/latprobe -c 8 https://www.google.com https://www.bbc.co.uk https://www.abc.net.au
# JSON array order also matches input order:
./go/latprobe --json -c 8 https://www.google.com https://www.bbc.co.uk | jq '.[].url'
make test # full suite incl. new ordering tests
make go-test-race # data-race detector must report clean
make check # fmt + vet + test gate
Confirm: identical output (modulo timing numbers) between -c 1 and default for
the same URL set; exit codes unchanged; -race clean.