Bài đọc gctrace nhắc tới con số goal — ngưỡng heap kích hoạt GC lần sau. Con số đó không phải cố định: nó do một biến duy nhất điều khiển, GOGC, và biến này là núm đánh đổi trực tiếp giữa CPU và bộ nhớ của toàn bộ chương trình Go. Hiểu nó là hiểu cách "điều tiết" GC. Bài này đo cùng một workload với các giá trị GOGC khác nhau và cho thấy đánh đổi rõ như ban ngày.

GC pacing: chạy theo tăng trưởng, không theo đồng hồ

Điểm mấu chốt: GC của Go không chạy theo lịch thời gian (mỗi X giây) mà theo mức tăng trưởng heap. Sau mỗi chu kỳ GC, runtime biết heap sống là bao nhiêu, rồi đặt ngưỡng cho lần sau theo công thức:

Ngưỡng GC lần sau = heap_sống × (1 + GOGC/100)

Với GOGC=100 (mặc định), ngưỡng = 2× heap sống — GC chạy khi heap tăng gấp đôi so với phần sống. Khi heap cấp phát chạm ngưỡng, GC chạy, dọn rác, tính lại heap sống, đặt ngưỡng mới. Đây gọi là GC pacing — điều tiết nhịp GC theo lượng rác sinh ra.

Ảnh chụp đoạn mã Go nền tối minh hoạ GC pacing GOGC điều khiển khi nào GC chạy, GC không chạy theo đồng hồ mà theo mức tăng trưởng heap GOGC quyết định heap được phép phình bao nhiêu so với phần sống trước khi kích hoạt chu kỳ GC tiếp theo, ngưỡng GC lần sau bằng heap sống nhân 1 cộng GOGC chia 100, GOGC 100 mặc định GC khi heap 2 lần phần sống tăng gấp đôi, GOGC 50 GC sớm hơn 1.5 lần sống GC dày ít bộ nhớ tốn CPU, GOGC 200 GC muộn hơn 3 lần sống GC thưa nhiều bộ nhớ đỡ CPU, GOGC off tắt hẳn GC không gom rác bộ nhớ tăng vô hạn, chỉnh bằng biến môi trường GOGC 200 chương trình hoặc trong code import runtime debug debug SetGCPercent 200, workload đo cấp 64KB rồi bỏ liên tục tạo rác đều 3000 vòng giữ 20 buffer sống bỏ phần dư, cơ chế sau mỗi lần GC runtime biết heap sống đặt ngưỡng lần sau bằng sống nhân 1 cộng GOGC chia 100 khi heap chạm ngưỡng GC chạy GOGC càng cao heap phình càng nhiều trước khi gom

Hình 1: Công thức pacing. GOGC càng cao, heap được phình càng nhiều trước khi GC chạy — đổi bộ nhớ lấy ít lần GC hơn. Chỉnh bằng biến GOGC hoặc debug.SetGCPercent.

Đo thật: đánh đổi CPU và bộ nhớ

Chạy đúng một workload (cấp 64KB rồi bỏ liên tục, 3000 vòng, giữ 20 buffer sống) với các giá trị GOGC khác nhau, đo số lần GC (đại diện chi phí CPU) và đỉnh heap (bộ nhớ):

Ảnh chụp bảng kết quả đo thật nền tối cùng workload GOGC khác nhau Go 1.23 arm64, 3000 vòng cấp 64KB giữ 20 buffer sống bỏ phần dư đo số lần GC và đỉnh heap, GOGC 50 số lần GC 148 đỉnh heap 4 MB tổng STW 6.76 ms GC dày ít bộ nhớ, GOGC 100 mặc định 68 lần 8 MB 3.19 ms, GOGC 200 33 lần 10 MB 1.06 ms, GOGC 400 13 lần 15 MB 0.57 ms GC thưa nhiều bộ nhớ, GOGC off 0 lần 187 MB 0.00 ms không GC nguy hiểm, cốt lõi GOGC là núm đánh đổi thẳng giữa CPU và bộ nhớ từ 50 lên 400 số lần GC giảm từ 148 xuống 13 đỡ CPU nhưng đỉnh heap tăng từ 4 lên 15 MB GOGC off tắt hẳn GC 0 lần gom nhưng heap vọt lên 187 MB giữ mọi rác

Hình 2: Từ GOGC=50 tới 400, số lần GC giảm từ 148 xuống 13 (đỡ CPU) nhưng đỉnh heap tăng từ 4 lên 15 MB. GOGC=off: 0 lần GC nhưng heap vọt lên 187 MB.

Con số vẽ ra đường đánh đổi hoàn hảo:

  • GOGC=50: 148 lần GC, đỉnh heap 4 MB, STW 6,76 ms. GC chạy sớm (khi heap = 1,5× sống) → dày, tốn CPU, nhưng giữ bộ nhớ thấp.
  • GOGC=100 (mặc định): 68 lần GC, 8 MB, 3,19 ms.
  • GOGC=200: 33 lần GC, 10 MB, 1,06 ms.
  • GOGC=400: 13 lần GC, đỉnh heap 15 MB, STW 0,57 ms. GC chạy muộn (5× sống) → thưa, đỡ CPU, nhưng tốn bộ nhớ.
  • GOGC=off: 0 lần GC, heap vọt lên 187 MB. Tắt hẳn gom rác — giữ mọi thứ, bộ nhớ tăng không giới hạn.

Đường đánh đổi rất sạch: tăng GOGC gấp 8 lần (50→400) làm số lần GC giảm ~11 lần (148→13) nhưng đỉnh heap tăng ~4 lần (4→15 MB). Bạn đang mua ít CPU-cho-GC hơn bằng nhiều bộ nhớ hơn.

Ứng dụng thực tế

Tăng GOGC khi có RAM dư và GC ngốn CPU. Nếu dịch vụ của bạn có nhiều RAM trống và gctrace cho thấy %CPU-cho-GC cao, tăng GOGC (ví dụ 200-400) giảm số lần GC, trả CPU về cho công việc thật. Nhiều dịch vụ throughput cao chạy GOGC=200-400.

Giảm GOGC khi RAM eo hẹp. Trong container giới hạn bộ nhớ chặt, GOGC thấp (50-75) giữ heap nhỏ hơn, tránh chạm giới hạn — đổi lại GC chạy dày hơn. Nhưng cách hiện đại hơn là dùng GOMEMLIMIT (bài sau) thay vì hạ GOGC.

Chỉnh trong code với debug.SetGCPercent. Nếu cần đổi GOGC theo pha chương trình (ví dụ tắt GC trong lúc khởi tạo dữ liệu lớn rồi bật lại), debug.SetGCPercent(n) làm được lúc chạy. Trả về giá trị cũ để khôi phục.

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

GOGC=off cực kỳ nguy hiểm trên dịch vụ dài hạn. Đo cho thấy heap vọt lên 187 MB chỉ trong một workload ngắn. Tắt GC trên dịch vụ chạy lâu sẽ dẫn tới hết bộ nhớ (OOM) chắc chắn. Chỉ dùng GOGC=off cho công cụ chạy một lần, ngắn, biết trước lượng cấp phát (như trình biên dịch batch).

Không có con số GOGC "đúng" chung. Đánh đổi phụ thuộc hoàn toàn workload: tỉ lệ rác/sống, mẫu cấp phát, RAM khả dụng, yêu cầu độ trễ. Con số trong bài (4-15 MB) là của workload cụ thể này; workload của bạn khác. Phải đo trên chính chương trình và tải thật để chọn.

Đỉnh heap không chỉ do GOGC. Cấp phát đột biến (spike) có thể đẩy heap vượt ngưỡng goal trước khi GC kịp phản ứng, đặc biệt với GOGC cao. Nếu độ trễ và bộ nhớ đều quan trọng, GOGC một mình không đủ — cần kết hợp với GOMEMLIMIT để đặt trần cứng.

Ba ý mang về

  1. GC chạy theo tăng trưởng heap, không theo đồng hồ: GOGC đặt ngưỡng GC lần sau = heap_sống × (1 + GOGC/100), mặc định GOGC=100 nghĩa là GC khi heap tăng gấp đôi phần sống.
  2. GOGC là núm đánh đổi thẳng CPU ↔ bộ nhớ: đo thật, từ GOGC=50 tới 400, số lần GC giảm từ 148 xuống 13 (đỡ CPU) nhưng đỉnh heap tăng từ 4 lên 15 MB — tăng GOGC khi có RAM dư và GC ngốn CPU, giảm khi RAM eo hẹp.
  3. GOGC=off tắt hẳn GC, chỉ hợp công cụ ngắn hạn: đo thật heap vọt 187 MB không gom — trên dịch vụ dài hạn sẽ OOM; và không có con số GOGC đúng chung, phải đo trên workload thật.

Bài tập làm thử

Bài 1 (đọc hiểu công thức). Áp dụng công thức pacing Ngưỡng GC lần sau = heap_sống × (1 + GOGC/100). Nếu heap sống hiện tại là 50 MB và GOGC=200, ngưỡng kích hoạt GC lần sau là bao nhiêu?

Đáp án

Ngưỡng = 50 × (1 + 200/100) = 50 × 3 = 150 MB. Với GOGC=200, GC chạy khi heap tăng gấp 3 lần so với phần sống.

Bài 2 (đọc hiểu số liệu). Đo thật cho thấy GOGC=50 chạy 148 lần GC với đỉnh heap 4 MB, còn GOGC=400 chỉ chạy 13 lần GC nhưng đỉnh heap 15 MB. Giải thích vì sao tăng GOGC lại làm giảm số lần GC nhưng tăng đỉnh heap.

Đáp án

GOGC quyết định heap được phép phình bao nhiêu so với phần sống trước khi GC kích hoạt. GOGC=50 nghĩa là ngưỡng chỉ 1,5× heap sống nên GC chạy sớm và dày (nhiều lần, heap luôn nhỏ). GOGC=400 nghĩa là ngưỡng lên tới 5× heap sống nên GC chờ heap phình rất nhiều mới chạy — ít lần GC hơn (đỡ CPU) nhưng heap được phép lớn hơn nhiều trước khi bị dọn.

Bài 3 (sửa lỗi/vận dụng code). Đoạn code sau muốn tắt tạm GC trong lúc nạp một khối dữ liệu lớn lúc khởi động rồi bật lại đúng giá trị cũ, nhưng có lỗi khiến giá trị cũ bị mất. Sửa lại cho đúng.

import "runtime/debug"

func loadBigData() {
    debug.SetGCPercent(-1) // tắt GC
    doHeavyAllocation()
    debug.SetGCPercent(100) // bật lại — nhưng lỡ trước đó GOGC=200 thì sao?
}
Đáp án

debug.SetGCPercent trả về giá trị GOGC cũ, nên phải lưu lại giá trị đó và khôi phục thay vì hard-code 100:

func loadBigData() {
    old := debug.SetGCPercent(-1) // tắt GC, nhớ giá trị cũ
    doHeavyAllocation()
    debug.SetGCPercent(old) // khôi phục đúng giá trị trước đó
}

Bài 4 (bẫy/đánh đổi). Một dịch vụ chạy dài hạn (24/7) đang gặp GC ngốn nhiều CPU. Một kỹ sư đề xuất set GOGC=off để loại bỏ hoàn toàn chi phí GC. Đề xuất này có vấn đề gì?

Đáp án

GOGC=off tắt hẳn việc gom rác — mọi object không được thu hồi nữa. Đo thật trong bài cho thấy chỉ với một workload ngắn, heap đã vọt lên 187 MB. Trên dịch vụ chạy dài hạn, bộ nhớ sẽ tăng không giới hạn và chắc chắn dẫn tới hết bộ nhớ (OOM). GOGC=off chỉ nên dùng cho công cụ chạy một lần, ngắn, biết trước lượng cấp phát (như một trình biên dịch batch) — không hợp cho service dài hạn.

Bài 5 (vận dụng thực tế). Dịch vụ của bạn chạy trong container có nhiều RAM dư nhưng gctrace cho thấy %CPU dành cho GC khá cao. Theo bài viết, bạn nên điều chỉnh GOGC theo hướng nào, và tại sao không nên dùng GOGC thấp khi RAM đang eo hẹp mà nên cân nhắc công cụ khác?

Đáp án

Khi có RAM dư và GC ngốn CPU, nên tăng GOGC (ví dụ 200-400) để giảm số lần GC, trả CPU về cho công việc thật — đổi lại heap sẽ phình lớn hơn, nhưng đó là đánh đổi chấp nhận được khi có RAM dư. Ngược lại khi RAM eo hẹp, tuy có thể giảm GOGC (50-75) để giữ heap nhỏ hơn, bài viết lưu ý cách hiện đại hơn là dùng GOMEMLIMIT để đặt trần cứng cho bộ nhớ thay vì chỉ hạ GOGC — vì GOGC không đặt giới hạn tuyệt đối, cấp phát đột biến vẫn có thể đẩy heap vượt ngưỡng trước khi GC kịp phản ứng.

Phần sau ta xét công cụ bổ trợ giải quyết đúng điểm yếu của GOGC — đặt trần bộ nhớ: Phần sau mổ xẻ GOMEMLIMIT — giới hạn bộ nhớ mềm, cách nó phối hợp với GOGC để vừa đỡ CPU vừa không tràn RAM container.