Repository navigation
Conversation
TimeoutReader started a goroutine, a channel and a context timer for every Read. On a fast download this is the dominant per-read cost. Use one time.AfterFunc timer per reader instead. It is armed when Read starts and stopped before it returns, so the timeout still applies to each Read and not to caller work between reads. When it fires it closes the response body, which unblocks the pending Read; that Read and all later ones return context.DeadlineExceeded as before. This also removes a data race: a timed-out Read used to return while its goroutine could still write into the caller's buffer, which the connection reuses for the next request. Read now only returns once the underlying Read has returned.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1597 +/- ##
==========================================
+ Coverage 72.30% 72.46% +0.15%
==========================================
Files 102 102
Lines 15683 15697 +14
==========================================
+ Hits 11340 11375 +35
+ Misses 3109 3094 -15
+ Partials 1234 1228 -6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks for the optimization and the additional tests! Reusing a timer and keeping the underlying Could you change the timeout action to cancel the HTTP request, rather than closing the response body from the timer callback? There is a race in the current close-based approach: the callback can unblock the underlying I reproduced this on Go 1.25.4 with the existing req With the added gzip case repeated 30 times, the old The approach I'd suggest is:
This retains the allocation/goroutine improvements and the buffer-lifetime fix, while letting the HTTP transport interrupt network I/O without a second concurrent |
…body Closing the response body from the timer callback raced with the caller's own Close: the callback could unblock the pending Read before its Close returned, so the caller's deferred Body.Close ran concurrently with it. With a decompressing body (a gzip stream that stalls in its header or after some data) the race detector flags both Close calls writing the reader's sticky error. Give every request its own cancellable context, created before it is sent, and pass its cancel func to TimeoutReader. On timeout the timer only cancels the request; the transport interrupts the pending Read, which TimeoutReader still waits for before reporting context.DeadlineExceeded. Other cancellation errors are returned unchanged, and closing the body is left to the caller. The Resolve request keeps its cancel func next to the kept-open response, since that body outlives Resolve. Add gzip cases (incomplete header, body stalling after data) and a caller-cancellation case on the native and req transports over HTTP/1.1 and HTTP/2.
Cancel the caller's context once the first body bytes have been read instead of on a fixed 200 ms timer, so the request is always past client.Do and the cancelled Read is the one blocked on the stalled body.
|
@monkeyWie thanks for the detailed repro, you're right about the race. Updated as you suggested:
I also reran the CPU numbers with a cleaner harness (server in a separate process, interleaved runs) and updated the table in the description. The gain over #1588 is smaller than I first reported: about 6% on loopback and 17% throttled, with |
|
Thanks for making the cancellation changes and adding the gzip coverage! The updated timeout-reader tests passed locally with GitHub currently reports merge conflicts with |
…out-watchdog-v2 # Conflicts: # internal/protocol/http/fetcher.go
|
@monkeyWie thanks for testing it. Merged the latest After the merge, |
Follow-up to #1588, where I mentioned the TimeoutReader overhead.
TimeoutReaderstarts a goroutine, a channel and acontext.WithTimeoutfor everyRead. With the 64 KiB buffers in place, that's the main per-read cost left on fast downloads.This uses one
time.AfterFunctimer per reader instead, armed whenReadstarts and stopped before it returns, so the timeout still applies to each read and not to time spent between reads (theWriteAtunderfileMu).Timeout action: cancel the request. Every request now gets its own cancellable context, created before it is sent, and its cancel func is passed to the
TimeoutReaderfor that response. When the timer fires it only cancels the request, and the transport interrupts the pending read. The reader never closes the body; that stays with the caller'sdefer resp.Body.Close(), so there is no secondCloseracing it. The Resolve request keeps its cancel func next to the kept-open response, because that body outlivesResolve(prefetch or the first connection reads it).Errors: the timed-out read and every later one return
context.DeadlineExceeded, same as before. The reader usesTimer.Stop()to tell whether the timer fired during the read. Any other error, includingcontext.Canceledfrom the caller's context (pause or stop), is passed through unchanged.Race fix: a timed-out
Readused to return while its goroutine could still write into the caller's buffer, which the connection reuses for the retry.Readnow only returns after the underlying read has, because cancelling doesn't prove the read has finished.TestTimeoutReader_TimedOutReadDoesNotOutliveCallercovers this; its first version failed on main.CPU per GB downloaded. This measures client-process CPU only, with the server in a separate process: 8 connections, runs interleaved across the three builds, median with min–max in brackets. Loopback had 7 runs per build and throttled had 5.
TimeoutReader.Readmicrobenchmark: ~885 ns, 9 allocs, 584 B per call on #1588, against ~56 ns and 0 allocs for both timer versions. The only added cost is onecontext.WithCancelper request, not per read.An earlier measurement of the close version showed a larger gain, but it didn't reproduce on rerun.
Tests:
GzipReader.Close(req) and, on Go 1.25.4, inhttp2gzipReader.Close(native). This version is race-free over 30 separate-raceruns on Go 1.25.4 and 1.27.context.Canceled, notDeadlineExceeded, on all four clientsgo test ./internal/... ./pkg/...passes.go test -race ./internal/protocol/http/shows the same two pre-existing failures as main (TestFetcher_DownloadResponseBodyReadTimeoutandTestFetcher_DownloadWithProxy) with the same race sites, none intimeout_reader.go.A nil cancel func can't interrupt the read; it still reports the timeout once its
Readreturns. Every request the fetcher sends passes one.Note: #1451 removes the prefetch's TimeoutReader, probably because the old one could return before its read finished. With this change that no longer happens, so prefetch can keep
responseReaderand its stall timeout when #1451 is rebased.