Bài trước dạy cách đo allocation cho đúng. Giờ ta giảm nó. Trong Go, phần lớn allocation dư thừa đến từ việc cấp mới một slice/buffer mỗi lần cần, dù có thể tái dùng cái cũ. Hai kỹ thuật đơn giản — mẫu [:0] và preallocation — cắt được phần lớn allocation trên đường nóng, không cần thư viện nào. Bài này đo chính xác chúng tiết kiệm bao nhiêu và giải thích cơ chế.

Slice, capacity, và vì sao append cấp phát

Một slice Go có ba thành phần: con trỏ tới mảng nền (backing array), độ dài (len), và dung lượng (cap). Khi bạn append vượt quá cap, runtime phải cấp một mảng nền mới lớn hơn (thường gấp đôi), copy dữ liệu cũ sang, rồi trỏ slice vào mảng mới. Mỗi lần như vậy là một cấp phát + một lần copy.

Điều này dẫn tới hai nguồn allocation lãng phí: (1) một slice bắt đầu từ nil phải nhân đôi capacity nhiều lần khi lớn dần, mỗi lần một cấp phát; (2) một hàm cấp slice mới mỗi lần được gọi, dù mảng nền của lần trước có thể tái dùng.

Ảnh chụp đoạn mã Go nền tối minh hoạ giảm allocation tái dùng buffer thay vì cấp mới, mỗi lần cấp slice mới là một lần chạm allocator cộng việc cho GC nếu xử lý lặp đi lặp lại tái dùng cùng backing array cắt sạch cấp phát, mẫu 1 hai chấm 0 reset độ dài về 0 nhưng giữ capacity backing array buf bằng make int 0 500 cấp một lần ngoài vòng lặp for buf bằng locChan buf hai chấm 0 1000 len 0 cap giữ nguyên append ghi đè lên vùng nhớ cũ không cấp phát mới, mẫu 2 prealloc đúng capacity tránh nhiều lần realloc khi lớn s bằng make int 0 1000 biết trước cỡ 1 cấp phát for j append s j, so với bắt đầu từ nil append tự lớn 1 2 4 tới 1024 var s int nil 10 tới 12 lần realloc cộng copy, vì sao hiệu quả slice bắt đầu từ nil phải nhân đôi capacity nhiều lần mỗi lần một cấp phát mới cộng copy prealloc đúng cỡ chỉ cấp một lần tái dùng buf hai chấm 0 không cấp lần nào sau khởi động

Hình 1: Mẫu buf[:0] reset độ dài về 0 nhưng giữ mảng nền, nên vòng lặp sau ghi đè thay vì cấp mới. Prealloc đúng cỡ chỉ cấp một lần.

Đo thật kỹ thuật 1: tái dùng với [:0]

Mẫu buf[:0] là biểu thức tạo một slice có len=0 nhưng giữ nguyên capacity (và mảng nền) của buf. Nghĩa là các append tiếp theo sẽ ghi đè lên vùng nhớ cũ thay vì cấp mảng mới. Cấp buffer một lần ngoài vòng lặp, rồi mỗi vòng dùng buf[:0]:

buf := make([]int, 0, 500)         // cấp MỘT lần
for i := 0; i < b.N; i++ {
    buf = locChan(buf[:0], 1000)   // reset len=0, giữ cap -> không cấp phát
    sink = buf
}

So với cấp slice mới (bắt đầu từ nil) mỗi lần:

Ảnh chụp bảng kết quả đo thật nền tối go test bench benchmem Go 1.23 arm64 về tái dùng buffer, tái dùng buffer hai chấm 0 vs cấp slice mới mỗi lần BenchmarkCapMoi 1134 ns/op 8184 B/op 10 allocs/op BenchmarkTaiDung 347.3 ns/op 0 B/op 0 allocs/op tái dùng 0 cấp phát giữ backing array nhanh khoảng 3.3 lần, prealloc đúng capacity vs để append tự lớn từ nil BenchmarkKhongPrealloc 2371 ns/op 25208 B/op 12 allocs/op BenchmarkPrealloc 942.2 ns/op 8192 B/op 1 allocs/op prealloc 12 cấp phát xuống 1 biết trước cỡ nhanh khoảng 2.5 lần từ nil phải nhân đôi cap nhiều lần 25KB churn vs 8KB, cốt lõi hai kỹ thuật cắt allocation không cần thư viện tái dùng buffer với buf hai chấm 0 reset độ dài về 0 nhưng giữ mảng nền từ 10 cấp phát xuống 0 nhanh 3.3 lần prealloc đúng capacity make T 0 n từ 12 cấp phát xuống 1

Hình 2: Tái dùng buf[:0]: 0 cấp phát, 347 ns — so với cấp mới 10 cấp phát, 1134 ns (nhanh ~3,3 lần). Prealloc: 1 cấp phát, 942 ns — so với để append tự lớn 12 cấp phát, 2371 ns (nhanh ~2,5 lần).

Kết quả: cấp slice mới mỗi lần tốn 10 cấp phát (append nhân đôi từ 1 lên 512) và 1134 ns. Tái dùng buf[:0] tốn 0 cấp phát và chỉ 347 ns — nhanh 3,3 lần, và không tạo việc gì cho GC. Sau lần khởi động đầu, cùng một mảng nền được dùng lại mãi.

Đo thật kỹ thuật 2: preallocation

Khi bạn biết trước (hoặc ước lượng được) kích thước cuối, make([]T, 0, n) cấp đúng capacity ngay từ đầu, tránh mọi lần nhân đôi:

s := make([]int, 0, 1000)   // biết trước 1000 phần tử -> 1 cấp phát
for j := 0; j < 1000; j++ { s = append(s, j) }

Đo thật: để append tự lớn từ nil tốn 12 cấp phát và 25.208 byte churn (vì nhân đôi qua nhiều capacity: 1, 2, 4, ..., 1024, mỗi lần cấp mới + copy). Prealloc đúng cỡ chỉ tốn 1 cấp phát, 8192 byte, và nhanh 2,5 lần. Chênh lệch byte (25KB so với 8KB) cho thấy phần lớn bộ nhớ bị phí vào các mảng trung gian bị vứt bỏ.

Ứng dụng thực tế

Buffer tái dùng trong vòng lặp xử lý. Nếu bạn xử lý từng dòng/từng bản ghi trong vòng lặp và mỗi lần build một slice kết quả, hãy cấp buffer một lần ngoài vòng lặp và dùng buf[:0] mỗi vòng. Đây là mẫu chuẩn trong parser, encoder, và pipeline xử lý dữ liệu — cắt allocation từ O(n) xuống O(1).

Prealloc khi biết hoặc ước lượng được cỡ. Đọc từ database trả về n dòng? make([]Row, 0, n). Xây map kết quả? make(map[K]V, n). Ngay cả ước lượng thô cũng giảm mạnh số lần realloc. bytes.Buffer.Grow(n) và strings.Builder.Grow(n) là cùng ý tưởng cho chuỗi.

bytes.Buffer và strings.Builder cho ghép chuỗi. Ghép chuỗi bằng += trong vòng lặp cấp phát mỗi lần (chuỗi bất biến). strings.Builder giữ một buffer tái dùng bên trong — cùng nguyên lý bài này, đóng gói sẵn.

Đánh đổi cần cân nhắc

Cẩn thận chia sẻ backing array. Đây là bẫy nguy hiểm nhất. Khi bạn tái dùng buf[:0], mọi slice cũ trỏ vào cùng mảng nền sẽ bị ghi đè ở vòng sau. Nếu bạn giữ lại (hoặc trả ra ngoài) một slice từ vòng trước rồi tái dùng buffer, dữ liệu cũ bị hỏng lặng lẽ. Chỉ tái dùng khi chắc chắn không ai còn giữ tham chiếu tới nội dung cũ; nếu cần giữ, phải copy ra.

Tái dùng buffer làm code phức tạp hơn — chỉ dùng ở đường nóng. Cấp mới mỗi lần là mã đơn giản, dễ đúng. Tái dùng buffer thêm trạng thái (buffer sống qua nhiều vòng) và rủi ro chia sẻ. Chỉ áp dụng khi profiler cho thấy allocation là nút thắt thật — đừng làm rối mọi hàm.

Prealloc sai cỡ có thể phản tác dụng. make([]T, 0, n) với n quá lớn cấp phí bộ nhớ bạn không dùng; quá nhỏ thì vẫn phải realloc. Ước lượng tốt mới có lợi; đoán bừa một số lớn "cho chắc" là lãng phí. Khi không rõ cỡ, để append tự lớn (nó đã khá tốt) hoặc đo phân phối cỡ thực tế.

Ba ý mang về

  1. Tái dùng buffer với buf[:0] cắt allocation xuống 0: đo thật, xử lý lặp với buffer tái dùng tốn 0 cấp phát và nhanh 3,3 lần so với cấp slice mới mỗi lần (10 cấp phát) — buf[:0] giữ mảng nền, các vòng sau ghi đè thay vì cấp mới.
  2. Prealloc đúng capacity cắt số lần realloc: đo thật, make([]T,0,1000) tốn 1 cấp phát so với để append tự lớn từ nil (12 cấp phát, nhân đôi liên tục, 25KB churn) — nhanh 2,5 lần khi biết trước cỡ.
  3. Cẩn thận chia sẻ backing array: tái dùng buffer ghi đè dữ liệu cũ, nên đừng giữ/trả ra slice từ vòng trước khi sắp tái dùng — chỉ áp dụng ở đường nóng profiler chỉ ra, vì nó thêm trạng thái và rủi ro.

Phần sau ta xét công cụ chuẩn của Go cho việc tái dùng object an toàn giữa nhiều goroutine: Phần sau mổ xẻ sync.Pool — cơ chế bể chứa object, cách nó tương tác với GC, và đo hiệu quả thật (khi nào giúp, khi nào không).