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

Bài tập làm thử

Bài 1 (đọc hiểu). Trong thuật toán tricolor, một object đang màu xám nghĩa là gì? Nó khác object màu đen ở điểm nào?

Đáp án

Xám nghĩa là object đã được thăm (biết là sống) nhưng chưa quét hết các con trỏ của nó — vẫn còn nằm trong hàng chờ xử lý của GC. Đen là đã được thăm và đã quét xong hết con, chắc chắn sống và không cần quét lại. 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 bị coi là rác.

Bài 2 (đọc hiểu số liệu). Dòng gctrace sau nghĩa là gì, và trong đó bao nhiêu mili-giây là thời gian dừng thật sự chương trình?

gc 8 @0.021+14+0.020 ms clock, 155->163->118 MB
Đáp án

Ba số là ba pha: STW kết thúc quét trước (0,021 ms) + mark đồng thời (14 ms, chạy song song với chương trình, không tính là dừng) + STW kết thúc đánh dấu (0,020 ms). Thời gian dừng chương trình thật sự chỉ là tổng hai số STW: 0,021 + 0,020 = 0,041 ms — pha mark 14 ms không phải thời gian dừng.

Bài 3 (vận dụng thực tế). Bạn nghi ngờ một service Go bị GC gây "khựng" độ trễ. Bạn nên bật cấu hình gì để kiểm tra, và nếu STW hiện lên tới hàng chục mili-giây (thay vì vài chục micro-giây như bài đo) thì đó thường là dấu hiệu của vấn đề gì?

Đáp án

Bật GODEBUG=gctrace=1 để xem tần suất GC, thời gian từng pha và tăng trưởng heap. STW dài bất thường (nhiều ms) thường là dấu hiệu stack quá lớn hoặc có quá nhiều goroutine cần quét gốc.

Bài 4 (bẫy/đánh đổi). Một đồng nghiệp nói: "gctrace cho thấy STW chỉ 62 µs/lần nên GC của chương trình này gần như miễn phí, không cần tối ưu gì thêm." Nhận định này sai ở đâu?

Đáp án

Sai vì 62 µs chỉ là thời gian dừng (STW), không phải tổng chi phí của GC. GC vẫn tiêu tốn CPU cho pha mark/sweep chạy nền (cột "%" trong gctrace, ở đây 7-10% CPU) và write barrier vẫn thêm chi phí cho mỗi phép gán con trỏ trong lúc mark. Muốn biết tổng chi phí thật phải đo bằng profiling, không chỉ nhìn thời gian dừng.

Bài 5 (sửa lỗi/giải thích cơ chế). Đoạn mô tả sau có một chỗ hiểu sai về write barrier — chỉ ra chỗ sai: "Trong lúc GC đang đánh dấu, nếu chương trình gán một con trỏ tới object trắng cho một object đen, GC sẽ tự động bỏ qua vì object đen coi như đã xong." Điều gì thực sự xảy ra?

Đáp án

Sai: nếu GC "bỏ qua" trường hợp này thì object trắng đó có thể bị bỏ sót và thu nhầm dù đang sống. Thực tế, write barrier là đoạn mã trình biên dịch chèn vào mọi phép gán con trỏ trong lúc GC đánh dấu; khi phát hiện tình huống nguy hiểm này nó sẽ tô xám object trắng đó để đảm bảo GC chắc chắn quét tới, không để lọt object sống nào.

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.