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ù.

Bài tập làm thử

Bài 1 (đọc hiểu). Bản xuLyNaive trong bài có ba nguồn cấp phát chồng lên nhau. Kể tên cả ba.

Đáp án

(1) slice lines bắt đầu từ nil nên lớn dần qua nhiều lần append, gây nhiều lần cấp phát lại (realloc); (2) mỗi fmt.Sprintf("%d:%s", r.id, r.name) đóng gói r.id/r.name vào interface{} (boxing) và tạo chuỗi mới; (3) strings.Join(lines, "\n") cấp thêm chuỗi kết quả trung gian.

Bài 2 (sửa lỗi). Đoạn xuLyToiUu sau có lỗi khiến việc "tái dùng buffer" mất tác dụng khi hàm được gọi nhiều lần liên tiếp trên cùng một *strings.Builder. Tìm và sửa lỗi.

func xuLyToiUu(b *strings.Builder) string {
    b.Grow(16 * len(recs))
    for i, r := range recs {
        if i > 0 { b.WriteByte('\n') }
        b.WriteString(strconv.Itoa(r.id))
        b.WriteByte(':'); b.WriteString(r.name)
    }
    return b.String()
}
Đáp án

Thiếu b.Reset() ở đầu hàm. Không reset thì mỗi lần gọi hàm sẽ nối thêm nội dung vào phần đã có từ lần gọi trước thay vì tái sử dụng buffer rỗng, làm kết quả sai dần và buffer phình to vô ích. Sửa bằng cách thêm b.Reset() làm dòng đầu tiên trong thân hàm, trước b.Grow(...).

Bài 3 (đọc hiểu số liệu). Theo số đo trong bài, bản tối ưu giảm số lần gọi allocs/op từ 2756 xuống 901, và qua 100.000 lần gọi thì số lần GC chạy giảm từ 2068 xuống 511. Giải thích chuỗi nhân quả nối hai con số này lại với nhau.

Đáp án

Ít cấp phát mỗi lần gọi (allocs/op giảm) → tổng lượng rác (churn) sinh ra qua nhiều lần gọi giảm theo (7118 MB xuống 1837 MB) → GC có ít rác để dọn hơn nên chạy thưa hơn (2068 xuống 511 lần) → ít chu kỳ GC hơn nghĩa là ít CPU dành cho GC hơn → chương trình nhanh hơn tổng thể (4,2 lần), không chỉ nhờ code tự thân chạy nhanh hơn mà còn nhờ GC làm ít việc hơn.

Bài 4 (vận dụng thực tế). Bài đưa ra quy trình 4 bước để giảm áp lực GC. Hãy liệt kê đúng thứ tự bốn bước đó.

Đáp án
  1. Đo allocs/op bằng -benchmem để 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ó thể dùng -gcflags=-m. 3. Áp kỹ thuật phù hợp (tái dùng buffer, prealloc bằng Grow/make(...,0,n), thay fmt bằng strconv, sync.Pool cho object lớn). 4. Đo lại để xác nhận cả allocs/op lẫn ns/op đều giảm — nếu không cải thiện thì hoàn tác.

Bài 5 (bẫy/đánh đổi). Bài cảnh báo "tái dùng buffer thêm trạng thái và rủi ro". Nếu bạn truyền cùng một *strings.Builder cho nhiều goroutine gọi xuLyToiUu đồng thời, điều gì có thể xảy ra, và tại sao "chỉ 4 lần nhanh hơn" không phải lý do đủ để luôn tối ưu theo cách này?

Đáp án

strings.Builder không an toàn khi dùng đồng thời (không có khoá bên trong), nên nhiều goroutine cùng ghi vào một buffer sẽ gây dữ liệu đua (race) và kết quả sai/hỏng — mỗi goroutine cần Builder riêng hoặc phải đồng bộ hoá. Về đánh đổi: độ phức tạp thêm (quản lý tham số buffer, gọi Reset đúng lúc) chỉ đáng khi đây là đường nóng chạy hàng triệu lần; với code chạy vài lần, bản ngây thơ dễ đọc hơn và 4 lần nhanh hơn của một hàm ít gọi là không đáng kể.

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.