Skip to content

perf: recycle gzip readers across responses - #23

Open
burruplambert wants to merge 1 commit into
bogdanfinn:masterfrom
burruplambert:perf/pool-gzip-readers
Open

perf: recycle gzip readers across responses#23
burruplambert wants to merge 1 commit into
bogdanfinn:masterfrom
burruplambert:perf/pool-gzip-readers

Conversation

@burruplambert

Copy link
Copy Markdown

Summary

Every gzipped response body allocates 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 was a third of all bytes allocated.

This recycles the readers through a sync.Pool. gzip.Reader.Reset preserves 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 underneath gzipReader - bodyEOFSignal, cancelTimerBody, the http2 transport response body - are all written to tolerate Close being called concurrently with a blocked Read in order to unblock it. bodyEOFSignal.Close takes a mutex for exactly that reason. Recycling on Close would 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.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 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 next Reset reassigns 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, Close racing a blocked Read, 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).init allocation 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, zstdReader and 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 by Close, so a pooled decoder must never be closed while an abandoned one still must be.

TestTransportGzip and eleven sibling gzip tests fail on master, before and after this change: they assert Accept-Encoding: gzip while the fork sends gzip, deflate, br. The test expectations were never updated when that default changed.

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.
@burruplambert
burruplambert force-pushed the perf/pool-gzip-readers branch from 763981d to 3bc2f1a Compare August 2, 2026 13:01
@burruplambert

Copy link
Copy Markdown
Author

Added the regression test that was missing for one part of this change.

The reader is returned to the pool when Reset fails rather than dropped, so that a run of unreadable bodies does not empty the pool one reader at a time. The existing tests covered that such bodies stay correct - sticky error, pool not poisoned, later bodies fine - but all of them passed against the dropping version too, so none of them actually pinned that behavior.

The new test seeds the pool with a known reader, reads an empty body so that Reset fails, then checks whether the next body picks that same reader up. sync.Pool never promises a Put value is visible to a later Get, so one attempt can legitimately miss; the test loops, which makes the signal reliable in both directions - when the reader is returned some attempt observes it, and when it is dropped no attempt can.

Verified both ways: fails every run against the dropping version, passes 20 consecutive runs and -race against this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant