Skip to content

Commit 7039364

Browse files
authored
avoid per-frame cleanup closures on read to remove 2 allocs per frame read (#565)
read: avoid per-frame cleanup closures Every frame header and payload read called prepareRead, which returned a cleanup function to clear the timeout and translate close or cancellation errors. That function captured the context, connection, and the address of the caller's named error result. Because prepareRead returned the function, the closure outlived its stack frame and Go allocated its captured state on the heap for every frame read. Move the cleanup logic to a normal finishRead method and defer a direct method call instead. This preserves timeout cleanup and error translation without returning a closure. Compiler escape analysis with -gcflags=-m=2 confirms that the old function literal escaped and forced the named error result in readFrameHeader and readFramePayload onto the heap; neither escape remains after this change. The results below compare parent d099e16 with this commit on an Apple M4 Max using GOMAXPROCS=1 and benchstat over 10 samples. A temporary in-package harness, not included in this commit, repeatedly called one internal frame read. Header reads parse a minimal frame header; payload reads copy 512 bytes from a buffered repeating reader. The background cases use context.Background, while the cancelable cases use an uncanceled context.WithCancel. Removing the escaping closure eliminates 2 allocations and 64 bytes from every frame read: one allocation for the closure environment and one for the named error result retained by that closure. Header reads with a background context improve from 170.5 to 118.5 ns/op, 216 to 152 B/op, and 6 to 4 allocs/op. Header reads with a cancelable context improve from 211.1 to 158.7 ns/op with the same allocation reduction. Payload reads remove the same fixed overhead; they remain slower because the benchmark also copies 512 bytes. Both context types benefit because this commit does not change timeout registration. It only removes cleanup allocations made after every prepareRead call. An interleaved 12-sample BenchmarkConn/disabledCompress run, which exercises complete message reads, improves from 3.875 to 3.657 us/op (-5.63%), 42 to 32 allocs/op (-23.81%), and 1536 to 1216 B/op (-20.83%).
1 parent d099e16 commit 7039364

1 file changed

Lines changed: 26 additions & 29 deletions

File tree

read.go

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -217,51 +217,48 @@ func (c *Conn) readLoop(ctx context.Context) (header, error) {
217217
}
218218
}
219219

220-
// prepareRead sets the readTimeout context and returns a done function
221-
// to be called after the read is done. It also returns an error if the
222-
// connection is closed. The reference to the error is used to assign
223-
// an error depending on if the connection closed or the context timed
224-
// out during use. Typically, the referenced error is a named return
225-
// variable of the function calling this method.
226-
func (c *Conn) prepareRead(ctx context.Context, err *error) (func(), error) {
220+
// prepareRead sets the read timeout and checks whether the connection is closed.
221+
func (c *Conn) prepareRead(ctx context.Context) error {
227222
select {
228223
case <-c.closed:
229-
return nil, net.ErrClosed
224+
return net.ErrClosed
230225
default:
231226
}
232227
c.setupReadTimeout(ctx)
233228

234-
done := func() {
235-
c.clearReadTimeout()
236-
select {
237-
case <-c.closed:
238-
if *err != nil {
239-
*err = net.ErrClosed
240-
}
241-
default:
242-
}
243-
if *err != nil && ctx.Err() != nil {
244-
*err = ctx.Err()
245-
}
246-
}
247-
248229
c.closeStateMu.Lock()
249230
closeReceivedErr := c.closeReceivedErr
250231
c.closeStateMu.Unlock()
251232
if closeReceivedErr != nil {
252-
defer done()
253-
return nil, closeReceivedErr
233+
c.clearReadTimeout()
234+
return closeReceivedErr
254235
}
255236

256-
return done, nil
237+
return nil
238+
}
239+
240+
// finishRead clears the read timeout and reports whether the connection or
241+
// operation context ended while the read was in progress.
242+
func (c *Conn) finishRead(ctx context.Context, err *error) {
243+
c.clearReadTimeout()
244+
select {
245+
case <-c.closed:
246+
if *err != nil {
247+
*err = net.ErrClosed
248+
}
249+
default:
250+
}
251+
if *err != nil && ctx.Err() != nil {
252+
*err = ctx.Err()
253+
}
257254
}
258255

259256
func (c *Conn) readFrameHeader(ctx context.Context) (_ header, err error) {
260-
readDone, err := c.prepareRead(ctx, &err)
257+
err = c.prepareRead(ctx)
261258
if err != nil {
262259
return header{}, err
263260
}
264-
defer readDone()
261+
defer c.finishRead(ctx, &err)
265262

266263
h, err := readFrameHeader(c.br, c.readHeaderBuf[:])
267264
if err != nil {
@@ -272,11 +269,11 @@ func (c *Conn) readFrameHeader(ctx context.Context) (_ header, err error) {
272269
}
273270

274271
func (c *Conn) readFramePayload(ctx context.Context, p []byte) (_ int, err error) {
275-
readDone, err := c.prepareRead(ctx, &err)
272+
err = c.prepareRead(ctx)
276273
if err != nil {
277274
return 0, err
278275
}
279-
defer readDone()
276+
defer c.finishRead(ctx, &err)
280277

281278
n, err := io.ReadFull(c.br, p)
282279
if err != nil {

0 commit comments

Comments
 (0)