Skip to content

transport: Pool read buffers used by the HTTP/2 framer#9032

Merged
arjan-bal merged 3 commits into
grpc:masterfrom
arjan-bal:h2-read-buffer-pool
Apr 22, 2026
Merged

transport: Pool read buffers used by the HTTP/2 framer#9032
arjan-bal merged 3 commits into
grpc:masterfrom
arjan-bal:h2-read-buffer-pool

Conversation

@arjan-bal

@arjan-bal arjan-bal commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Problem

The HTTP/2 framer in gRPC uses a bufio.Reader with a 32KB buffer by default. When there are a large number of transports, these buffers consume significant memory, even when the transport is idle.

Solution

#8964 added a ReadyReader interface that allows non-memory-pinning reads. This PR replaces the standard bufio.Reader with a custom io.Reader implementation that uses pooled buffers and releases the buffer once all data is consumed.

To defer the re-allocation of the read buffer, the reader calls ReadOnReady on the underlying io.Reader. For this to work, the underlying io.Reader must implement either the ReadyReader interface or syscall.RawConn. If neither condition is met, the framer gracefully falls back to using the regular bufio.Reader.

Additional Changes:

  • The ALTS connection has been refactored to implement the ReadyReader interface.
  • The write buffer pools used by the framer are updated to use the mem.BufferPool interface, allowing the pools to be shared across both read and write operations.
  • Use syscall.Read instead of unix.Read to avoid triggering the race detector, see comment for details.
  • Add environment variable protection for the changes to allow fast rollback.

Benchmarks

In a real-world benchmark, where a GCS directpath client downloads a file in a loop, the average "in use" memory falls from 28.3MB to 21.3MB (-24%).

Local Benchmarks show no significant difference

❯ go run benchmark/benchresult/main.go streaming-before streaming-after                 
streaming-networkMode_Local-bufConn_false-keepalive_false-benchTime_2m0s-trace_false-latency_0s-kbps_0-MTU_0-maxConcurrentCa
lls_120-reqSize_1024B-respSize_1024B-compressor_off-channelz_false-preloader_false-clientReadBufferSize_-1-clientWriteBuffer
Size_-1-serverReadBufferSize_-1-serverWriteBufferSize_-1-sleepBetweenRPCs_0s-connections_1-recvBufferPool_simple-sharedWrite
Buffer_true
               Title       Before        After Percentage
            TotalOps     29981273     29966908    -0.05%
             SendOps            0            0      NaN%
             RecvOps            0            0      NaN%
            Bytes/op      4971.06      4971.41     0.00%
           Allocs/op        19.79        19.79     0.00%
             ReqT/op 2046721570.13 2045740919.47    -0.05%
            RespT/op 2046721570.13 2045740919.47    -0.05%
            50th-Lat    461.523µs    460.906µs    -0.13%
            90th-Lat    654.435µs    655.327µs     0.14%
            99th-Lat   1.225856ms   1.240984ms     1.23%
             Avg-Lat    478.845µs    479.553µs     0.15%
           GoVersion     go1.25.0     go1.25.0
         GrpcVersion   1.81.0-dev   1.81.0-dev

RELEASE NOTES:

  • transport: Pool HTTP/2 framer read buffers to reduce idle memory consumption. Currently limited to Linux for ALTS and non-encrypted transports (TCP, Unix). To disable, set GRPC_GO_EXPERIMENTAL_HTTP_FRAMER_READ_BUFFER_POOLING=false and report any issues.

@arjan-bal arjan-bal changed the title H2 read buffer pool transport: Pool read buffers in the HTTP/2 framer Apr 1, 2026
@codecov

codecov Bot commented Apr 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.00000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.25%. Comparing base (f6304e9) to head (fc172d0).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
credentials/alts/internal/conn/record.go 75.00% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9032      +/-   ##
==========================================
+ Coverage   80.55%   82.25%   +1.69%     
==========================================
  Files         413      413              
  Lines       33541    42322    +8781     
==========================================
+ Hits        27020    34810    +7790     
- Misses       4305     6105    +1800     
+ Partials     2216     1407     -809     
Files with missing lines Coverage Δ
internal/envconfig/envconfig.go 100.00% <ø> (+25.00%) ⬆️
internal/transport/http_util.go 95.87% <100.00%> (-2.06%) ⬇️
internal/transport/readyreader/raw_conn_linux.go 100.00% <100.00%> (ø)
internal/transport/readyreader/ready_reader.go 85.48% <100.00%> (+1.97%) ⬆️
credentials/alts/internal/conn/record.go 79.90% <75.00%> (-3.67%) ⬇️

... and 361 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@arjan-bal arjan-bal modified the milestone: 1.81 Release Apr 1, 2026
@arjan-bal arjan-bal added Type: Performance Performance improvements (CPU, network, memory, etc) Area: Transport Includes HTTP/2 client/server and HTTP server handler transports and advanced transport features. labels Apr 1, 2026
@arjan-bal arjan-bal force-pushed the h2-read-buffer-pool branch from 24267f8 to b32d8ba Compare April 2, 2026 05:40
@arjan-bal arjan-bal closed this Apr 7, 2026
@arjan-bal

Copy link
Copy Markdown
Contributor Author

I'm splitting this PR into smaller PRs. I'll re-open this when the child PRs are merged.

arjan-bal added a commit that referenced this pull request Apr 8, 2026
### Background
In #9032, we will transition from standard `net.Conn.Read` methods to
`syscall` UNIX APIs to enable non-memory-pinning reads. Due to this
change, the Go race detector beings failing on tests that share state
between client and server goroutines without standard synchronization
primitives (mutexes, channels, etc.).

Because these tests rely on the network request itself as a memory
barrier, they are logically safe but technically racy from the Go
runtime's perspective. The standard `net.Conn` leverages Go's internal
network poller, which inadvertently provides the "happens-before" edges
the race detector looks for. Dropping down to raw syscalls bypasses
this, causing the detector to flag the accesses. (Minimal repro:
https://go.dev/play/p/yvEtBmLTOJ2)

### Solution
Introduce explicit synchronization to the affected tests. Tests now
properly coordinate shared state access between clients and servers
without relying on socket I/O timing.

RELEASE NOTES: N/A
arjan-bal added a commit that referenced this pull request Apr 14, 2026
…ll connection handling (#9035)

This PR eliminates per-call heap allocations in
`nonBlockingReader.ReadOnReady()`.

Previously, calling
[`RawConn.Read`](https://pkg.go.dev/syscall#RawConn.Read) with an inline
closure caused captured variables (and the closure itself) to escape to
the heap. To resolve this, we moved the closure's required state and
return values into fields on the `nonBlockingReader` struct. The state
is set before execution, and the results are read afterward. Because the
closure now only relies on the receiver's fields, we instantiate it
exactly once during `nonBlockingReader` construction and reuse it,
completely avoiding allocations on the hot path.

Additionally, this change updates the types for which non-blocking reads
are performed to the following:
* Unwrapped types created by `net.Dial`. This avoid reading encrypted
data from
[credentials.syscallConns](https://github.com/grpc/grpc-go/blob/74b3acd1a801570e1cefb28cf61620a4ef7c8ee2/internal/credentials/syscallconn.go#L37-L42).
* Types that already implement the `ReadyReader` interface themselves to
support encrypted connections.
 
These changes are a prerequisite for #9032.

## Benchmarks
#9032 uses `nonBlockingReader` instead of standard `net.Conn.Read` and
confirms zero increase in heap allocations for streaming RPCs.

RELEASE NOTES: N/A

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
arjan-bal added a commit that referenced this pull request Apr 16, 2026
This PR introduces a buffered `io.Reader` that automatically releases
its read buffer when empty. To optimize memory usage, the reader defers
buffer reallocation until data is available in the underlying
`readyreader.Reader` (achieved by calling `ReadOnReady`).

In a follow-up PR (#9032), the HTTP/2 framer will be updated to utilize
this new buffered reader whenever the underlying reader implements the
`readyreader.Reader` interface.

The implementation and associated tests are based on the [standard
library's](https://cs.opensource.google/go/go/+/refs/tags/go1.26.2:src/bufio/bufio.go;l=35).

RELEASE NOTES: N/A

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@arjan-bal arjan-bal reopened this Apr 21, 2026
@arjan-bal arjan-bal force-pushed the h2-read-buffer-pool branch from c16297c to 8b8d538 Compare April 21, 2026 10:51
@arjan-bal

Copy link
Copy Markdown
Contributor Author

/gemini review

@arjan-bal arjan-bal changed the title transport: Pool read buffers in the HTTP/2 framer transport: Pool read buffers use by the HTTP/2 framer Apr 21, 2026
@arjan-bal arjan-bal requested a review from easwars April 21, 2026 11:03

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements read buffer pooling for the HTTP framer and ALTS record protocol to reduce memory usage, particularly when subchannels are idle. It introduces a new environment variable GRPC_GO_EXPERIMENTAL_HTTP_FRAMER_READ_BUFFER_POOLING to control this feature. Key changes include updating the ALTS conn to support ReadOnReady, switching to syscall in readyreader to avoid race detector issues, and refactoring buffer pool management in http_util. Feedback was provided regarding the ALTS record protocol to ensure the returned pooled buffer's slice header is correctly updated to match the decrypted data length.

Comment thread credentials/alts/internal/conn/record.go
@arjan-bal arjan-bal changed the title transport: Pool read buffers use by the HTTP/2 framer transport: Pool read buffers used by the HTTP/2 framer Apr 21, 2026
Comment thread internal/transport/readyreader/ready_reader_ext_test.go Outdated
Comment thread internal/transport/readyreader/ready_reader_ext_test.go Outdated
Comment thread internal/transport/http_util.go
Comment thread internal/transport/http_util.go Outdated
Comment on lines +420 to +427
if !envconfig.EnableHTTPFramerReadBufferPooling {
return bufio.NewReaderSize(r, bufSize)
}
if rr := readyreader.NewNonBlocking(r); rr != nil {
readPool := getIOBufferPool(bufSize)
return readyreader.NewBuffered(rr, bufSize, readPool)
}
return bufio.NewReaderSize(r, bufSize)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can we simplify this as:

- If env var is enabled **and** `r` supports non-blocking reads, create a `readyreader.NewBuffered` and return
- Fall though and create a regular bufio.Reader using `bufio.NewReaderSize`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To check if r supports non-blocking reads, we have to call NewNonBlocking. When using feature flags, we should generally avoid invoking the protected code at all to prevent unexpected side effects (e.g., panics). Since Go evaluates if initialization statements before the condition, we cannot safely combine the assignment and the flag check on a single line. We would have to nest the conditionals to ensure the flag is evaluated first:

if envconfig.EnableHTTPFramerReadBufferPooling {
	if rr := readyreader.NewNonBlocking(r); rr != nil {
		readPool := getIOBufferPool(bufSize)
		return readyreader.NewBuffered(rr, bufSize, readPool)
	}
}

To avoid this nesting and keep the code flat, I opted to use an early return pattern instead. I'm fine the nesting style also.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nesting is what I meant, but I just wrote it as a single conditional in the pseudo code.

The only reason I ask for the nesting is because currently we have two code paths that do the same return bufio.NewReaderSize(r, bufSize). With the nesting, there will just be one of them. But it's not a big deal. Will leave it to you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to nested style.

Comment thread internal/transport/http_util.go Outdated
@easwars easwars removed their assignment Apr 21, 2026
@arjan-bal arjan-bal merged commit 29665f4 into grpc:master Apr 22, 2026
21 of 22 checks passed
@arjan-bal arjan-bal deleted the h2-read-buffer-pool branch April 22, 2026 06:02
arjan-bal added a commit that referenced this pull request Apr 27, 2026
Original PRs: #9055, #9032

RELEASE NOTES:
* transport: Pool HTTP/2 framer read buffers to reduce idle memory
consumption. Currently limited to Linux for ALTS and non-encrypted
transports (TCP, Unix). To disable, set
`GRPC_GO_EXPERIMENTAL_HTTP_FRAMER_READ_BUFFER_POOLING=false` and report
any issues.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: Transport Includes HTTP/2 client/server and HTTP server handler transports and advanced transport features. Type: Performance Performance improvements (CPU, network, memory, etc)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants