Bộ thu gom rác (GC) là thứ khiến nhiều người sợ khi nói tới ngôn ngữ có quản lý bộ nhớ tự động — vì trong nhiều hệ thống, GC dừng cả chương trình hàng trăm mili-giây để dọn dẹp. GC của Go được thiết kế đặc biệt để tránh điều đó: nó chạy đồng thời với chương trình và chỉ dừng chương trình ở những khoảnh khắc cực ngắn. Bài này đo trực tiếp thời gian dừng đó (bất ngờ nhỏ), và giải thích thuật toán tricolor mark-sweep làm nên điều kỳ diệu ấy.

Ba màu: trắng, xám, đen

GC của Go là loại mark-sweep (đánh dấu rồi quét bỏ): trước tiên đánh dấu mọi object còn sống, sau đó thu hồi những object không được đánh dấu. Để làm việc này đồng thời với chương trình đang chạy, nó phân object thành ba màu:

  • Trắng: chưa được thăm — ứng viên bị thu hồi. Nếu cuối cùng vẫn trắng thì là rác.
  • Xám: đã được thăm (biết là sống) nhưng chưa quét các con của nó — đang nằm trong hàng chờ xử lý.
  • Đen: đã được thăm và đã quét hết con — chắc chắn sống, không cần quét lại.

Thuật toán: bắt đầu, tất cả object màu trắng. Các "gốc" (biến toàn cục, biến trên stack goroutine) được tô xám. Lặp: lấy một object xám, quét các con trỏ của nó (mỗi con trắng → tô xám), rồi tô object đó đen. Khi không còn object xám nào, mọi object đen là sống, và mọi object còn trắng là rác — được quét bỏ.

Ảnh chụp sơ đồ nền tối GC tricolor mark-sweep đánh dấu object sống bằng ba màu, GC của Go chạy đồng thời với chương trình phân object thành ba màu để biết cái nào đã quét cái nào còn phải quét cái nào là rác mà không cần dừng chương trình, trắng chưa thăm ứng viên bị thu hồi rác nếu cuối cùng vẫn trắng, xám đã thăm nhưng chưa quét các con của nó đang trong hàng chờ, đen đã thăm và đã quét hết con chắc chắn sống không quét lại, thuật toán bắt đầu tất cả trắng gốc biến toàn cục stack thành xám lặp lấy 1 object xám quét con của nó con trắng thành xám nó thành đen hết xám mọi object đen là sống mọi object trắng còn lại rác quét bỏ, type Node struct val int children con trỏ Node cây object có con trỏ t buildTree 11 GC phải lần theo children để đánh dấu cả cây, write barrier mấu chốt để chạy đồng thời khi GC đang đánh dấu mà chương trình gắn con trỏ từ object đen sang object trắng object trắng có thể bị bỏ sót Go cài rào ghi tự động tô xám object trắng đảm bảo không object sống nào bị thu nhầm

Hình 1: Ba màu cho biết trạng thái quét của mỗi object. Write barrier là mấu chốt để đánh dấu an toàn trong khi chương trình vẫn chạy và sửa con trỏ.

Write barrier: bí quyết chạy đồng thời

Đây là phần khéo léo nhất. Vì GC đánh dấu đồng thời với chương trình, chương trình có thể sửa con trỏ giữa chừng. Nguy hiểm: nếu một object đen (đã quét xong, không quét lại) được gắn một con trỏ tới một object trắng chưa ai thăm, object trắng đó có thể bị bỏ sót và thu nhầm dù nó đang sống.

Go giải quyết bằng write barrier (rào ghi): một đoạn mã nhỏ trình biên dịch tự chèn vào mọi phép gán con trỏ trong lúc GC đánh dấu. Khi phát hiện gán con trỏ có thể tạo tình huống nguy hiểm, nó tô xám object liên quan để GC chắc chắn quét tới. Nhờ vậy, không object sống nào bị thu nhầm, dù chương trình chạy song song với GC. Đổi lại, write barrier có chi phí nhỏ trên mỗi phép gán con trỏ trong pha đánh dấu.

Đo thật: dừng chỉ 62 micro-giây

Chạy một chương trình vừa cấp phát nhiều (dựng cây object có con trỏ) vừa chạy một goroutine "công việc" nền, với GODEBUG=gctrace=1:

Ảnh chụp bảng kết quả đo thật nền tối GODEBUG gctrace 1 Go 1.23 arm64 10 P về GC tricolor, định dạng gc N STW_a cộng mark đồng thời cộng STW_b ms clock heap trước tới đỉnh tới sau, gc 1 at 0.002s 7 phần trăm 0.028 cộng 0.93 cộng 0.025 ms clock 3 tới 4 tới 3 MB, gc 5 0.020 cộng 4.4 cộng 0.14 ms clock 31 tới 35 tới 32 MB, gc 8 0.021 cộng 14 cộng 0.020 ms clock 155 tới 163 tới 118 MB, gc 10 0.022 cộng 10 cộng 0.021 ms clock 146 tới 150 tới 84 MB, số đầu và cuối đỏ bằng STW dừng chương trình chỉ khoảng 0.02 tới 0.14 ms số giữa xanh bằng mark chạy đồng thời tới 14 ms nhưng không dừng, tổng kết chương trình xong sau 311ms số lần GC 10 công việc nền chạy 111280099 lần tổng STW PauseTotalNs 0.62 ms qua 10 lần GC trung bình 0.062 ms mỗi lần 62 micro giây

Hình 2: Mỗi dòng gctrace có dạng STW_a + mark_đồng_thời + STW_b. Số đầu/cuối (STW dừng chương trình) chỉ ~0,02-0,14 ms; số giữa (mark) tới 14 ms nhưng chạy đồng thời. Tổng STW 10 chu kỳ = 0,62 ms, trung bình 62 µs/lần.

Đọc gctrace là thấy toàn bộ câu chuyện. Dòng gc 8: 0.021+14+0.020 ms clock. Ba con số là ba pha: STW kết thúc quét trước (0,021 ms), mark đồng thời (14 ms), STW kết thúc đánh dấu (0,020 ms). Điểm mấu chốt: dù pha đánh dấu tốn 14 ms, chỉ có ~0,04 ms dừng chương trình; 14 ms còn lại chạy song song.

Tổng kết: 10 chu kỳ GC, tổng thời gian dừng (PauseTotalNs) chỉ 0,62 ms — trung bình 62 micro-giây mỗi chu kỳ. Và bằng chứng chạy đồng thời: goroutine công việc nền chạy được 111 triệu lần trong lúc 10 chu kỳ GC diễn ra. Chương trình gần như không bị GC làm gián đoạn.

Ứng dụng thực tế

Go phù hợp cho dịch vụ nhạy độ trễ. Vì STW chỉ vài chục micro-giây, một API server Go không bị "khựng" hàng trăm ms như các hệ thống GC stop-the-world cũ. Đây là lý do Go được chọn cho hạ tầng độ trễ thấp (proxy, database, message queue). Thời gian dừng gần như không phụ thuộc kích thước heap.

Đọc gctrace để chẩn đoán GC. Nếu nghi ngờ GC gây vấn đề, GODEBUG=gctrace=1 cho bạn tần suất GC, thời gian mỗi pha, và tăng trưởng heap. STW dài bất thường (nhiều ms) là dấu hiệu có vấn đề (thường do stack quá lớn hoặc quá nhiều goroutine). Chi tiết đọc gctrace ở bài sau.

Write barrier có chi phí — giảm cấp phát vẫn tốt. Dù GC rẻ, nó không miễn phí: pha mark tốn CPU (cột "cpu" trong gctrace), và write barrier thêm chi phí mỗi phép gán con trỏ. Các bài trước (giảm allocation, tái dùng buffer) vẫn có giá trị: ít rác hơn nghĩa là GC chạy thưa hơn và làm ít việc hơn.

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

Đồng thời đổi lấy thông lượng CPU. GC chạy song song nghĩa là nó chia sẻ CPU với chương trình trong pha mark (cột "%" đầu dòng gctrace là phần trăm CPU dành cho GC — ở đây 7-10%). Với hệ thống ưu tiên thông lượng thô hơn độ trễ, chi phí CPU này là đánh đổi; nhưng với đa số ứng dụng, độ trễ thấp đáng giá hơn.

Go không nén heap (non-moving). Khác một số GC khác, GC Go không di chuyển object để chống phân mảnh — điều này giữ con trỏ ổn định (đơn giản cho cgo và unsafe) nhưng có thể để lại phân mảnh heap. Đánh đổi có chủ đích: đơn giản và độ trễ thấp, chấp nhận phân mảnh.

Thời gian dừng thấp không có nghĩa GC miễn phí. 62 µs là thời gian dừng, không phải tổng chi phí. GC vẫn tiêu CPU cho việc mark/sweep (chạy nền), vẫn có write barrier. Đừng nhầm "STW ngắn" với "GC không tốn gì" — đo tổng chi phí bằng profiling khi tối ưu nghiêm túc.

Ba ý mang về

  1. GC của Go là tricolor mark-sweep chạy đồng thời: object được phân ba màu (trắng chưa thăm, xám đã thăm chưa quét con, đen quét xong) — bắt đầu từ gốc tô xám, lan ra tới hết, object còn trắng là rác bị quét bỏ.
  2. Write barrier cho phép đánh dấu an toàn khi chương trình vẫn chạy: nó tự tô xám object khi có phép gán con trỏ nguy hiểm, đảm bảo không object sống nào bị thu nhầm — đổi lại một chi phí nhỏ mỗi phép gán con trỏ trong pha mark.
  3. Thời gian dừng cực ngắn: đo thật 10 chu kỳ GC chỉ dừng chương trình tổng 0,62 ms (62 µs/lần) kể cả khi pha đánh dấu tốn 14 ms, và công việc nền chạy 111 triệu lần trong lúc GC — đây là lý do Go hợp cho dịch vụ nhạy độ trễ.

Phần sau ta học đọc chính công cụ vừa dùng, đến từng con số: Phần sau mổ xẻ cách đọc GODEBUG=gctrace=1 thật — ý nghĩa từng trường (pha, CPU, heap trước-đỉnh-sau, goal), và cách dùng nó để chẩn đoán vấn đề GC.