กระรอกดินต้นฉบับกำลังเติมชิ้นข้อมูลลงถาดพื้นที่ว่างของกล่องบัฟเฟอร์บนโต๊ะสว่าง

Go bytes.Buffer.AvailableBuffer: ต่อข้อมูลลงพื้นที่ว่างโดยลด allocation

พี่โชว์Engineering

เมื่อเราประกอบข้อความทีละช่อง การสร้าง string ชั่วคราวทุกค่าเหมือนหยิบถุงใหม่ให้ของทุกชิ้นครับ bytes.Buffer.AvailableBuffer เปิด slice ของพื้นที่ว่างให้ strconv.Append* เติมข้อมูลตรงนั้น แล้ว Write ยืนยันความยาวกลับเข้า buffer

WHY: ลดของชั่วคราวใน hot path

วิธีนี้เหมาะกับ serializer เล็ก ๆ ที่วัดแล้วว่า allocation สำคัญ ไม่ใช่ของที่ควรใช้แทน fmt ทุกจุด สัญญาสำคัญคือ slice ใช้ได้จนถึงการเขียนครั้งถัดไปเท่านั้น ห้ามเก็บไว้ใน struct หรือใช้หลัง Write, Grow, Reset หรือการเปลี่ยน buffer อื่น

package main

import (
	"bytes"
	"strconv"
	"testing"
)

func appendRow(buf *bytes.Buffer, name string, score int64) {
	tail := buf.AvailableBuffer()
	tail = strconv.AppendQuote(tail, name)
	tail = append(tail, ',')
	tail = strconv.AppendInt(tail, score, 10)
	tail = append(tail, '\n')
	_, _ = buf.Write(tail)
}

func TestAvailableBuffer(t *testing.T) {
	var buf bytes.Buffer
	buf.Grow(64)
	appendRow(&buf, "mint", 42)
	appendRow(&buf, "ไทย", -3)
	want := "\"mint\",42\n\"ไทย\",-3\n"
	if buf.String() != want {
		t.Fatalf("got %q want %q", buf.String(), want)
	}
	if buf.Available() <= 0 {
		t.Fatal("expected reusable capacity")
	}
}

Trade-off ที่ต้องรู้

AvailableBuffer ไม่เพิ่มความยาว buffer เอง เราต้อง Write slice ที่ต่อเสร็จกลับไป และต้องจัดการ error ของ writer จริงเสมอ ตัวอย่างใช้ bytes.Buffer ซึ่ง Write ไม่คืน error แต่ถ้าย้ายแนวคิดไป I/O อื่นต้องไม่ทิ้ง error

ถ้าโค้ดไม่ได้อยู่ใน hot path ให้เริ่มจาก fmt.Fprintf หรือ strings.Builder ที่อ่านง่ายกว่า แล้ว benchmark ด้วย input จริงก่อน optimize เหมือนร้านข้าวตามสั่งที่เตรียมถาดไว้ล่วงหน้าเฉพาะช่วงเที่ยง ไม่จำเป็นต้องกางถาดทั้งวัน

Recap

  • ใช้ slice ก่อนการเปลี่ยน buffer ครั้งถัดไปเท่านั้น
  • ต่อข้อมูลด้วย strconv.Append* แล้ว Write กลับ
  • Grow เมื่อประมาณขนาดได้ แต่ไม่พึ่ง capacity เป็น contract
  • benchmark ก่อนแลก readability กับ allocation ที่ลดลง