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

## 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](https://github.com/arjan-bal/custom-go-client-benchmark/tree/retry-dp),
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.
diff --git a/credentials/alts/internal/conn/record.go b/credentials/alts/internal/conn/record.go
index 0ad5a1e..89bf226 100644
--- a/credentials/alts/internal/conn/record.go
+++ b/credentials/alts/internal/conn/record.go
@@ -27,8 +27,9 @@
 	"net"
 
 	core "google.golang.org/grpc/credentials/alts/internal"
-	"google.golang.org/grpc/internal/mem"
+	imem "google.golang.org/grpc/internal/mem"
 	"google.golang.org/grpc/internal/transport/readyreader"
+	"google.golang.org/grpc/mem"
 )
 
 // ALTSRecordCrypto is the interface for gRPC ALTS record protocol.
@@ -75,16 +76,19 @@
 
 var (
 	protocols    = make(map[string]ALTSRecordFunc)
-	writeBufPool *mem.BinaryTieredBufferPool
+	writeBufPool *imem.BinaryTieredBufferPool
 	// readBufPool pools buffers of at least `altsReadBufferInitialSize` size.
 	// Since the read buffer size is slightly larger than 32KB, using a regular
 	// BinaryTieredBufferPool results in allocating buffers of almost double the
 	// required length.
-	readBufPool = mem.NewDirtySimplePool()
+	readBufPool = imem.NewDirtySimplePool()
+
+	// Compile-time check to ensure conn implements ReadyReader.
+	_ readyreader.Reader = &conn{}
 )
 
 func init() {
-	pool, err := mem.NewDirtyBinaryTieredBufferPool(
+	pool, err := imem.NewDirtyBinaryTieredBufferPool(
 		8,
 		12, // Go page size, 4KB
 		14, // 16KB (max HTTP/2 frame size used by gRPC)
@@ -126,7 +130,8 @@
 	// nextFrame stores the next frame (in protected buffer) info.
 	nextFrame []byte
 	// overhead is the calculated overhead of each frame.
-	overhead int
+	overhead  int
+	constPool constBufferPool // stored as a field to avoid heap allocations.
 }
 
 // NewConn creates a new secure channel instance given the other party role and
@@ -163,11 +168,27 @@
 	return altsConn, nil
 }
 
+type constBufferPool struct {
+	buffer []byte
+}
+
+func (p *constBufferPool) Get(int) *[]byte {
+	return &p.buffer
+}
+
+func (p *constBufferPool) Put(*[]byte) {}
+
 // Read reads and decrypts a frame from the underlying connection, and copies the
 // decrypted payload into b. If the size of the payload is greater than len(b),
 // Read retains the remaining bytes in an internal buffer, and subsequent calls
 // to Read will read from this buffer until it is exhausted.
 func (p *conn) Read(b []byte) (n int, err error) {
+	p.constPool.buffer = b
+	_, n, err = p.ReadOnReady(len(b), &p.constPool)
+	return n, err
+}
+
+func (p *conn) ReadOnReady(bufSize int, pool mem.BufferPool) (*[]byte, int, error) {
 	if len(p.buf) == 0 {
 		var framedMsg []byte
 		var protected []byte
@@ -175,9 +196,10 @@
 			protected = *p.protectedHandle
 			protected = protected[:cap(protected)]
 		}
+		var err error
 		framedMsg, p.nextFrame, err = ParseFramedMsg(p.nextFrame, altsRecordLengthLimit)
 		if err != nil {
-			return 0, err
+			return nil, 0, err
 		}
 		// Check whether the next frame to be decrypted has been
 		// completely received yet.
@@ -217,40 +239,42 @@
 				// Connection was idle, need to re-allocate the read buffer.
 				newBuf, nRead, err := p.reader.ReadOnReady(altsReadBufferInitialSize, readBufPool)
 				if err != nil {
-					return 0, err
+					return nil, 0, err
 				}
 				p.protectedHandle = newBuf
 				protected = (*newBuf)[:nRead]
 			} else {
 				nRead, err := p.Conn.Read(protected[len(protected):cap(protected)])
 				if err != nil {
-					return 0, err
+					return nil, 0, err
 				}
 				protected = protected[:len(protected)+nRead]
 			}
 			framedMsg, p.nextFrame, err = ParseFramedMsg(protected, altsRecordLengthLimit)
 			if err != nil {
-				return 0, err
+				return nil, 0, err
 			}
 		}
 		// Now we have a complete frame, decrypted it.
 		msg := framedMsg[MsgLenFieldSize:]
 		msgType := binary.LittleEndian.Uint32(msg[:msgTypeFieldSize])
 		if msgType&0xff != altsRecordMsgType {
-			return 0, fmt.Errorf("received frame with incorrect message type %v, expected lower byte %v",
+			return nil, 0, fmt.Errorf("received frame with incorrect message type %v, expected lower byte %v",
 				msgType, altsRecordMsgType)
 		}
 		ciphertext := msg[msgTypeFieldSize:]
 
 		// Decrypt directly into the buffer, avoiding a copy from p.buf if
 		// possible.
-		if len(b) >= len(ciphertext) {
-			dec, err := p.crypto.Decrypt(b[:0], ciphertext)
+		if bufSize >= len(ciphertext) {
+			allocatedBuf := pool.Get(bufSize)
+			dec, err := p.crypto.Decrypt((*allocatedBuf)[:0], ciphertext)
 			if err != nil {
-				return 0, err
+				pool.Put(allocatedBuf)
+				return nil, 0, err
 			}
 			p.dropProtectedIfEmtpy()
-			return len(dec), nil
+			return allocatedBuf, len(dec), nil
 		}
 		// Decrypt requires that if the dst and ciphertext alias, they
 		// must alias exactly. Code here used to use msg[:0], but msg
@@ -261,14 +285,15 @@
 		// check: https://golang.org/pkg/crypto/cipher/#AEAD.
 		p.buf, err = p.crypto.Decrypt(ciphertext[:0], ciphertext)
 		if err != nil {
-			return 0, err
+			return nil, 0, err
 		}
 	}
 
-	n = copy(b, p.buf)
+	allocatedBuf := pool.Get(bufSize)
+	n := copy(*allocatedBuf, p.buf)
 	p.buf = p.buf[n:]
 	p.dropProtectedIfEmtpy()
-	return n, nil
+	return allocatedBuf, n, nil
 }
 
 func (p *conn) dropProtectedIfEmtpy() {
diff --git a/internal/envconfig/envconfig.go b/internal/envconfig/envconfig.go
index cf1b9a8..8b5b14b 100644
--- a/internal/envconfig/envconfig.go
+++ b/internal/envconfig/envconfig.go
@@ -142,6 +142,16 @@
 	//
 	// TODO: In release v1.82.0, env var will be enabled by default.
 	Enable8KBDefaultHeaderListSize = boolFromEnv("GRPC_GO_EXPERIMENTAL_ENABLE_8KB_DEFAULT_HEADER_LIST_SIZE", false)
+
+	// EnableHTTPFramerReadBufferPooling enables the use of the
+	// readyreader.Reader interface to perform non-memory-pinning reads,
+	// provided the underlying net.Conn supports it. This reduces memory usage
+	// when subchannels are idle.
+	//
+	// This environment variable serves as an escape hatch to disable the
+	// feature if unforeseen issues arise, and it will be removed in a future
+	// release.
+	EnableHTTPFramerReadBufferPooling = boolFromEnv("GRPC_GO_EXPERIMENTAL_HTTP_FRAMER_READ_BUFFER_POOLING", true)
 )
 
 func boolFromEnv(envVar string, def bool) bool {
diff --git a/internal/transport/http_util.go b/internal/transport/http_util.go
index 5bbb641..c34975f 100644
--- a/internal/transport/http_util.go
+++ b/internal/transport/http_util.go
@@ -36,6 +36,9 @@
 	"golang.org/x/net/http2"
 	"golang.org/x/net/http2/hpack"
 	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/internal/envconfig"
+	imem "google.golang.org/grpc/internal/mem"
+	"google.golang.org/grpc/internal/transport/readyreader"
 	"google.golang.org/grpc/mem"
 )
 
@@ -296,7 +299,7 @@
 }
 
 type bufWriter struct {
-	pool      *sync.Pool
+	pool      *imem.SimpleBufferPool
 	buf       []byte
 	offset    int
 	batchSize int
@@ -304,7 +307,7 @@
 	err       error
 }
 
-func newBufWriter(conn io.Writer, batchSize int, pool *sync.Pool) *bufWriter {
+func newBufWriter(conn io.Writer, batchSize int, pool *imem.SimpleBufferPool) *bufWriter {
 	w := &bufWriter{
 		batchSize: batchSize,
 		conn:      conn,
@@ -326,7 +329,7 @@
 		return n, toIOError(err)
 	}
 	if w.buf == nil {
-		b := w.pool.Get().(*[]byte)
+		b := w.pool.Get(w.batchSize)
 		w.buf = *b
 	}
 	written := 0
@@ -407,22 +410,32 @@
 	errDetail error
 }
 
-var writeBufferPoolMap = make(map[int]*sync.Pool)
-var writeBufferMutex sync.Mutex
+var ioBufferPoolMap = make(map[int]*imem.SimpleBufferPool)
+var ioBufferMutex sync.Mutex
+
+func bufferedReader(r io.Reader, bufSize int) io.Reader {
+	if bufSize <= 0 {
+		return r
+	}
+	if envconfig.EnableHTTPFramerReadBufferPooling {
+		if rr := readyreader.NewNonBlocking(r); rr != nil {
+			readPool := ioBufferPool(bufSize)
+			return readyreader.NewBuffered(rr, bufSize, readPool)
+		}
+	}
+	return bufio.NewReaderSize(r, bufSize)
+}
 
 func newFramer(conn io.ReadWriter, writeBufferSize, readBufferSize int, sharedWriteBuffer bool, maxHeaderListSize uint32, memPool mem.BufferPool) *framer {
 	if writeBufferSize < 0 {
 		writeBufferSize = 0
 	}
-	var r io.Reader = conn
-	if readBufferSize > 0 {
-		r = bufio.NewReaderSize(r, readBufferSize)
-	}
-	var pool *sync.Pool
+	r := bufferedReader(conn, readBufferSize)
+	var writePool *imem.SimpleBufferPool
 	if sharedWriteBuffer {
-		pool = getWriteBufferPool(writeBufferSize)
+		writePool = ioBufferPool(writeBufferSize)
 	}
-	w := newBufWriter(conn, writeBufferSize, pool)
+	w := newBufWriter(conn, writeBufferSize, writePool)
 	f := &framer{
 		writer: w,
 		fr:     http2.NewFramer(w, r),
@@ -578,20 +591,15 @@
 	return df.FrameHeader
 }
 
-func getWriteBufferPool(size int) *sync.Pool {
-	writeBufferMutex.Lock()
-	defer writeBufferMutex.Unlock()
-	pool, ok := writeBufferPoolMap[size]
+func ioBufferPool(size int) *imem.SimpleBufferPool {
+	ioBufferMutex.Lock()
+	defer ioBufferMutex.Unlock()
+	pool, ok := ioBufferPoolMap[size]
 	if ok {
 		return pool
 	}
-	pool = &sync.Pool{
-		New: func() any {
-			b := make([]byte, size)
-			return &b
-		},
-	}
-	writeBufferPoolMap[size] = pool
+	pool = imem.NewDirtySimplePool()
+	ioBufferPoolMap[size] = pool
 	return pool
 }
 
diff --git a/internal/transport/http_util_test.go b/internal/transport/http_util_test.go
index bda6d77..a16081e 100644
--- a/internal/transport/http_util_test.go
+++ b/internal/transport/http_util_test.go
@@ -19,6 +19,7 @@
 package transport
 
 import (
+	"bufio"
 	"bytes"
 	"errors"
 	"fmt"
@@ -31,6 +32,9 @@
 	"time"
 
 	"golang.org/x/net/http2"
+	"google.golang.org/grpc/internal/envconfig"
+	"google.golang.org/grpc/internal/testutils"
+	"google.golang.org/grpc/internal/transport/readyreader"
 	"google.golang.org/grpc/mem"
 )
 
@@ -259,7 +263,7 @@
 	// Configure the bufWriter with a batchsize that results in data being flushed
 	// to the underlying conn, midway through Write().
 	writeBufferSize := (len(data) - 1) / 2
-	writer := newBufWriter(&badNetworkConn{}, writeBufferSize, getWriteBufferPool(writeBufferSize))
+	writer := newBufWriter(&badNetworkConn{}, writeBufferSize, ioBufferPool(writeBufferSize))
 
 	errCh := make(chan error, 1)
 	go func() {
@@ -413,3 +417,74 @@
 		})
 	}
 }
+
+type testReadyReader struct {
+	readyreader.Reader
+}
+
+func (t *testReadyReader) Read([]byte) (int, error) {
+	return 0, io.EOF
+}
+
+func (s) TestBufferedReader(t *testing.T) {
+	normalReader := bytes.NewReader(nil)
+
+	tests := []struct {
+		name          string
+		reader        io.Reader
+		bufSize       int
+		enablePooling bool
+		wantTypeOf    any
+	}{
+		{
+			name:          "bufSize_0",
+			reader:        normalReader,
+			bufSize:       0,
+			enablePooling: true,
+			wantTypeOf:    (*bytes.Reader)(nil),
+		},
+		{
+			name:          "env_var_disabled_normal_reader",
+			reader:        normalReader,
+			bufSize:       10,
+			enablePooling: false,
+			wantTypeOf:    (*bufio.Reader)(nil),
+		},
+		{
+			name:          "env_var_disabled_ready_reader",
+			reader:        &testReadyReader{},
+			bufSize:       10,
+			enablePooling: false,
+			wantTypeOf:    (*bufio.Reader)(nil),
+		},
+		{
+			name:          "env_var_enabled_normal_reader",
+			reader:        normalReader,
+			bufSize:       10,
+			enablePooling: true,
+			wantTypeOf:    (*bufio.Reader)(nil),
+		},
+		{
+			name:          "env_var_enabled_ready_reader",
+			reader:        &testReadyReader{},
+			bufSize:       10,
+			enablePooling: true,
+			wantTypeOf:    readyreader.NewBuffered(nil, 10, mem.DefaultBufferPool()),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			testutils.SetEnvConfig(t, &envconfig.EnableHTTPFramerReadBufferPooling, tt.enablePooling)
+
+			got := bufferedReader(tt.reader, tt.bufSize)
+
+			gotType := reflect.TypeOf(got)
+			wantType := reflect.TypeOf(tt.wantTypeOf)
+
+			if gotType != wantType {
+				t.Errorf("bufferedReader() type = %v, want %v", gotType, wantType)
+			}
+		})
+	}
+}
diff --git a/internal/transport/readyreader/raw_conn_linux.go b/internal/transport/readyreader/raw_conn_linux.go
index a156e0a..56906c3 100644
--- a/internal/transport/readyreader/raw_conn_linux.go
+++ b/internal/transport/readyreader/raw_conn_linux.go
@@ -18,20 +18,22 @@
 
 package readyreader
 
-import (
-	"golang.org/x/sys/unix"
-)
+import "syscall"
 
 func isRawConnSupported() bool {
 	return true
 }
 
-// sysRead uses the modern unix package for Unix-like systems.
+// sysRead uses the standard syscall package rather than the modern unix package
+// to avoid triggering the race detector. Because both packages perform sync
+// operations on a local variable to satisfy the race detector, mixing them
+// for read and write syscalls causes data races. We use syscall here to remain
+// consistent with net.Conn implementations in standard library.
 func sysRead(fd uintptr, p []byte) (int, error) {
-	return unix.Read(int(fd), p)
+	return syscall.Read(int(fd), p)
 }
 
 // wouldBlock checks standard Unix non-blocking errors.
 func wouldBlock(err error) bool {
-	return err == unix.EAGAIN || err == unix.EWOULDBLOCK
+	return err == syscall.EAGAIN || err == syscall.EWOULDBLOCK
 }
diff --git a/internal/transport/readyreader/ready_reader.go b/internal/transport/readyreader/ready_reader.go
index 05e63fa..250a300 100644
--- a/internal/transport/readyreader/ready_reader.go
+++ b/internal/transport/readyreader/ready_reader.go
@@ -63,9 +63,9 @@
 	buf       *[]byte
 }
 
-// newNonBlocking returns a ReadyReader if the passed reader supports
+// NewNonBlocking returns a ReadyReader if the passed reader supports
 // non-memory-pinning reads, else nil.
-func newNonBlocking(r io.Reader) Reader {
+func NewNonBlocking(r io.Reader) Reader {
 	if rr, ok := r.(Reader); ok {
 		return rr
 	}
@@ -155,7 +155,7 @@
 // If [syscall.RawConn] is unavailable, it falls back to using the simpler
 // [io.Reader] interface for reads.
 func New(r io.Reader) Reader {
-	if r := newNonBlocking(r); r != nil {
+	if r := NewNonBlocking(r); r != nil {
 		return r
 	}
 	return &blockingReader{reader: r}
diff --git a/internal/transport/readyreader/ready_reader_ext_test.go b/internal/transport/readyreader/ready_reader_ext_test.go
index 310580c..cd686fd 100644
--- a/internal/transport/readyreader/ready_reader_ext_test.go
+++ b/internal/transport/readyreader/ready_reader_ext_test.go
@@ -123,7 +123,11 @@
 	}
 }
 
-func (s) TestReadyReader_TCP_Blocking(t *testing.T) {
+// Tests the behavior of readers wrapping TCP connections. It ensures that read
+// operations do not hold onto an allocated buffer while the connection is idle.
+// Buffers should only be permanently allocated from the pool when data is
+// actually ready on the socket, optimizing memory usage for idle connections.
+func (s) TestReadyReader_TCP(t *testing.T) {
 	if runtime.GOOS != "linux" {
 		t.Skip("This test is only applicable for Linux, as RawConn functionality is not implemented for non-linux platforms.")
 	}