perf: recycle gzip readers across responses - #23
Conversation
Every gzipped response body allocated a fresh gzip.Reader on the first Read. A gzip.Reader owns a 32KB decompression window plus Huffman decoding tables, so on a client that reads many compressed bodies this is the single largest source of allocation: in a heap profile of a production scraper it accounted for a third of all bytes allocated, and the resulting GC work was the largest CPU consumer in the process. Recycle the readers through a sync.Pool instead. gzip.Reader.Reset preserves the decompressor, so a pooled reader reuses the window and the Huffman tables and only the empty-pool case allocates them. A reader is recycled when its stream reaches io.EOF, not when the body is closed. The body types under gzipReader (bodyEOFSignal, cancelTimerBody, the http2 transport response body) all allow Close to be called concurrently with a blocked Read in order to unblock it, so recycling on Close could hand a reader that is still in use to another response. At io.EOF the reader is provably finished with the stream, including any concatenated ones, and a fully read body is the common case. Bodies abandoned before EOF are simply not recycled. A body that cannot be read at all, such as an empty one, fails in Reset. That reader is returned to the pool rather than dropped, since the next Reset reassigns all of its state; dropping it would let a stream of unreadable bodies empty the pool one reader at a time. BenchmarkGzipReader: -50% ns/op, -90% B/op (44.4KiB to 4.2KiB), 10 allocs to 5. Adds tests for reuse across bodies, reuse of a reader left mid-stream, concatenated streams, reads after EOF, an abandoned body not being recycled, a truncated body not being recycled, unreadable bodies keeping the pool intact and their errors sticky, Close racing a blocked Read, and concurrent responses never receiving each other's data.
763981d to
3bc2f1a
Compare
|
Added the regression test that was missing for one part of this change. The reader is returned to the pool when The new test seeds the pool with a known reader, reads an empty body so that Verified both ways: fails every run against the dropping version, passes 20 consecutive runs and |
Summary
Every gzipped response body allocates a fresh
gzip.Readeron the firstRead. Agzip.Readerowns a 32KB decompression window plus Huffman decoding tables, so on a client that reads many compressed bodies this is the single largest source of allocation - in a heap profile of a production scraper it was a third of all bytes allocated.This recycles the readers through a
sync.Pool.gzip.Reader.Resetpreserves the decompressor, so a pooled reader reuses the window and the tables and only the empty-pool case allocates them.Recycling at EOF rather than at Close
A reader is returned to the pool when its stream reaches
io.EOF, not when the body is closed. The body types underneathgzipReader-bodyEOFSignal,cancelTimerBody, the http2 transport response body - are all written to tolerateClosebeing called concurrently with a blockedReadin order to unblock it.bodyEOFSignal.Closetakes a mutex for exactly that reason. Recycling onClosewould therefore be able to hand a reader that is still being read to an unrelated response, which corrupts that response's data rather than failing loudly.At
io.EOFthe reader is provably finished with the stream, including any concatenated ones, and a fully read body is the common case. Bodies abandoned before EOF simply are not recycled.A body that cannot be read at all, such as an empty one, fails in
Reset. That reader is returned to the pool rather than dropped, since the nextResetreassigns all of its state - dropping it would let a stream of unreadable bodies drain the pool one reader at a time.Tests
Reuse across bodies, reuse of a reader left mid-stream, concatenated streams, reads after EOF, an abandoned body not being recycled, a truncated body not being recycled, unreadable bodies keeping the pool intact with their errors sticky,
Closeracing a blockedRead, and concurrent responses never receiving each other's data. Clean under-race.Measurements
BenchmarkGzipReader: -50% ns/op, -90% B/op (44.4KiB to 4.2KiB), 10 allocs to 5.In production the
flate.(*dictDecoder).initallocation went from 26.7% of all bytes allocated to 0.02%. Worth being precise about the CPU effect though: the 32KB window is a pointer-free byte slice that the collector never scans, so the saving is in allocation and zeroing rather than marking, and it came to under 1% of total CPU rather than anything proportional to the allocation share.Note
The same per-response allocation pattern exists in
brReader,zstdReaderand the deflate readers, and the same treatment would apply. They are left alone here: zstd in particular needs different handling, since its decoder owns goroutines that are only released byClose, so a pooled decoder must never be closed while an abandoned one still must be.TestTransportGzipand eleven sibling gzip tests fail on master, before and after this change: they assertAccept-Encoding: gzipwhile the fork sendsgzip, deflate, br. The test expectations were never updated when that default changed.