Suốt chặng GC, ta đã học từng mảnh: allocator, escape analysis, tái dùng buffer, sync.Pool, GOGC, GOMEMLIMIT, write barrier. Bài này gộp chúng lại thành một quy trình thực chiến. Giảm áp lực GC hiếm khi là một mẹo — nó là áp nhiều kỹ thuật cùng lúc lên đường nóng, dẫn dắt bởi số đo. Ta lấy một tác vụ đời thực, đo áp lực GC của bản ngây thơ, tối ưu, rồi đo lại để thấy cải thiện thật.

Tác vụ: ghép chuỗi từ nghìn record

Một tác vụ cực phổ biến: ghép 1000 record thành một chuỗi (ví dụ dựng response, dòng log, CSV). Bản ngây thơ viết tự nhiên nhưng đầy nguồn cấp phát:

func xuLyNaive() string {
    var lines []string                          // slice từ nil -> nhiều realloc
    for _, r := range recs {
        lines = append(lines, fmt.Sprintf("%d:%s", r.id, r.name))
    }                                            // Sprintf: boxing interface{} mỗi record
    return strings.Join(lines, "\n")             // còn cấp chuỗi trung gian
}

Bản này có ba nguồn cấp phát chồng lên nhau: slice lines lớn dần từ nil, mỗi fmt.Sprintf đóng gói r.id/r.name vào interface{} và tạo chuỗi mới, và strings.Join cấp thêm chuỗi kết quả.

Ảnh chụp đoạn mã Go nền tối minh hoạ giảm áp lực GC thực chiến gộp mọi kỹ thuật, cùng một tác vụ ghép 1000 record thành chuỗi hai cách viết bản tối ưu áp dụng tái dùng buffer prealloc tránh interface boxing cắt cấp phát để GC chạy ít hơn, ngây thơ nhiều nguồn cấp phát func xuLyNaive var lines string slice từ nil nhiều realloc for range recs lines append Sprintf boxing interface mỗi lần return strings Join còn cấp chuỗi trung gian, tối ưu một buffer tái dùng không boxing func xuLyToiUu b con trỏ strings Builder b Reset tái dùng buffer như buf 0 b Grow 16 nhân len recs prealloc đúng cỡ ước lượng for i r range recs WriteString strconv Itoa r.id Itoa không boxing WriteString r.name return b String, ba kỹ thuật gộp lại strings Builder tái dùng thay vì dựng string rồi Join Grow n prealloc đúng cỡ tránh nhiều lần nhân đôi strconv Itoa thay fmt Sprintf tránh đóng gói interface và bộ máy format

Hình 1: Bản ngây thơ (nhiều nguồn cấp phát) vs bản tối ưu áp ba kỹ thuật cùng lúc: tái dùng strings.Builder, Grow prealloc, và strconv.Itoa thay fmt.Sprintf để tránh boxing.

Bản tối ưu: ba kỹ thuật gộp lại

func xuLyToiUu(b *strings.Builder) string {
    b.Reset()                      // tái dùng buffer (như buf[:0])
    b.Grow(16 * len(recs))         // prealloc đúng cỡ ước lượng
    for i, r := range recs {
        if i > 0 { b.WriteByte('\n') }
        b.WriteString(strconv.Itoa(r.id))  // Itoa: KHÔNG boxing
        b.WriteByte(':'); b.WriteString(r.name)
    }
    return b.String()
}

Ba kỹ thuật từ các bài trước áp cùng lúc: (1) strings.Builder tái dùng — một buffer sống qua nhiều lần gọi (bài tái dùng buffer), thay vì dựng []string rồi Join. (2) Grow(n) prealloc đúng cỡ ước lượng (bài prealloc), tránh nhiều lần nhân đôi. (3) strconv.Itoa thay fmt.Sprintf — tránh đóng gói interface{} (bài escape/boxing) và bộ máy format nặng.

Đo thật: cắt GC 4 lần, nhanh 4 lần

Benchmark cả hai với -benchmem, rồi đo áp lực GC qua 100.000 lần gọi:

Ảnh chụp bảng kết quả đo thật nền tối trước vs sau tối ưu Go 1.23 arm64, benchmark thời gian cộng cấp phát mỗi lần ghép 1000 record BenchmarkNaive 63774 ns/op 74641 B/op 2756 allocs/op BenchmarkToiUu 15287 ns/op 19264 B/op 901 allocs/op nhanh khoảng 4.2 lần cấp phát mỗi lần giảm khoảng 3.1 lần bộ nhớ mỗi lần giảm khoảng 3.9 lần, áp lực GC qua 100000 lần gọi Naive GC 2068 lần tổng cấp phát churn 7118 MB ToiUu GC 511 lần tổng cấp phát churn 1837 MB số lần GC giảm khoảng 4 lần lượng rác sinh ra giảm khoảng 3.9 lần GC chạy ít hơn bằng ít CPU cho GC bằng chương trình nhanh hơn, cốt lõi giảm áp lực GC không phải một mẹo đơn lẻ mà là gộp nhiều kỹ thuật đã học tái dùng strings Builder cộng Grow prealloc cộng tránh fmt Sprintf boxing

Hình 2: Bản tối ưu nhanh 4,2 lần (15287 vs 63774 ns), cấp phát mỗi lần giảm từ 2756 xuống 901 alloc. Qua 100k lần gọi, GC chạy 511 thay vì 2068 lần (giảm 4 lần), rác sinh ra giảm từ 7118 xuống 1837 MB.

Kết quả rõ ràng ở hai tầng:

  • Mỗi lần gọi: bản tối ưu nhanh 4,2 lần (15.287 vs 63.774 ns), số cấp phát giảm từ 2756 xuống 901 (~3,1 lần), bộ nhớ mỗi lần giảm ~3,9 lần.
  • Áp lực GC (qua 100.000 lần gọi): bản tối ưu làm GC chạy 511 lần thay vì 2068 (giảm ~4 lần), tổng rác sinh ra giảm từ 7118 MB xuống 1837 MB (~3,9 lần).

Mắt xích nhân quả: ít cấp phát → ít rác → GC chạy thưa hơn → ít CPU dành cho GC → chương trình nhanh hơn. Tốc độ 4,2 lần không chỉ từ code chạy nhanh hơn mà còn từ việc GC không phải làm việc nhiều.

Quy trình thực chiến

Đây là quy trình chuẩn để giảm áp lực GC, không phải đoán mò:

  1. Đo allocs/op bằng -benchmem (bài đo cấp phát). Đây là điểm khởi đầu — biết hàm nào cấp phát nhiều.
  2. Tìm nguồn cấp phát lớn: slice từ nil, fmt.Sprintf/interface boxing, con trỏ thoát heap, cấp buffer mới mỗi lần. Đọc -gcflags=-m (bài escape) nếu cần.
  3. Áp kỹ thuật phù hợp: tái dùng buffer (Reset/[:0]), prealloc (Grow/make(...,0,n)), thay fmt bằng strconv, sync.Pool cho object lớn bị churn.
  4. Đo lại: xác nhận allocs/op giảm và ns/op giảm. Nếu không cải thiện, hoàn tác — độ phức tạp thêm không đáng.

Ứng dụng thực tế

Đường nóng của server là nơi đáng tối ưu nhất. Hàm xử lý mỗi request, mỗi message, mỗi dòng dữ liệu — chạy hàng triệu lần — là nơi cắt cấp phát có tác động lớn nhất tới GC toàn cục. Profile để tìm chúng (bài pprof sau).

strconv và strings.Builder là bạn thân của đường nóng. Bất cứ chỗ nào ghép chuỗi hay format số trong vòng lặp nóng, fmt.Sprintf là nghi phạm đầu tiên. Thay bằng strconv.Itoa/AppendInt và strings.Builder thường cắt phần lớn cấp phát.

Giảm cấp phát bổ trợ cho chỉnh GOGC/GOMEMLIMIT. Hai hướng phối hợp: giảm cấp phát làm GC làm ít việc hơn mỗi lần, còn chỉnh GOGC/GOMEMLIMIT điều tiết tần suất. Cùng nhau cho kết quả tốt nhất.

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

Đừng tối ưu mù — luôn để con số dẫn đường. Bản tối ưu phức tạp hơn (thêm tham số buffer, quản lý Reset). Chỉ đáng ở đường nóng profiler chỉ ra. Với code chạy vài lần, bản ngây thơ dễ đọc hơn thắng — 4 lần nhanh của một hàm chạy 10 lần chẳng đáng gì.

Tái dùng buffer thêm trạng thái và rủi ro. Truyền *strings.Builder qua các lần gọi nghĩa là buffer sống lâu hơn — cẩn thận không chia sẻ nó giữa các goroutine (không an toàn) và luôn Reset trước dùng. Bài tái dùng buffer đã cảnh báo về chia sẻ backing array.

Cải thiện phụ thuộc workload. Con số 4 lần ở đây là của tác vụ ghép chuỗi cụ thể. Workload khác (tính toán số, I/O) có tỉ lệ cấp phát khác, nên mức cải thiện khác. Luôn đo trên chính workload của bạn, đừng giả định con số của bài này áp cho mọi trường hợp.

Ba ý mang về

  1. Giảm áp lực GC là gộp nhiều kỹ thuật, không phải một mẹo: đo thật, tái dùng strings.Builder + Grow prealloc + strconv.Itoa thay fmt.Sprintf cùng cắt cấp phát từ 2756 xuống 901 alloc/lần.
  2. Ít cấp phát dẫn tới ít GC dẫn tới nhanh hơn: qua 100k lần gọi, GC chạy 511 thay vì 2068 lần (giảm 4 lần) và rác giảm từ 7118 xuống 1837 MB — tốc độ 4,2 lần đến từ cả code nhanh hơn lẫn GC làm ít việc.
  3. Quy trình đo-tìm-sửa-đo lại: dùng -benchmem tìm nguồn cấp phát, áp kỹ thuật phù hợp, xác nhận cả allocs/op lẫn ns/op giảm — chỉ tối ưu đường nóng profiler chỉ ra, đừng tối ưu mù.

Phần sau ta học công cụ tìm chính xác cái gì cấp phát: Phần sau đào sâu heap profiling với pprof — cách chụp và đọc heap profile, tìm hàm/dòng cấp phát nhiều nhất, và phân biệt inuse vs alloc.