Bài GC tricolor đã nhắc write barrier là "bí quyết chạy đồng thời", nhưng chưa mổ xẻ. Đây là một trong những cơ chế tinh vi nhất của runtime Go: nó bắt buộc phải tồn tại để GC chạy song song mà không thu nhầm object sống, và nó có một cái giá ẩn trên mọi phép ghi con trỏ. Bài này chứng minh sự tồn tại của nó bằng assembly thật, giải thích vì sao nó cần thiết qua "bất biến tricolor", và đo chi phí thật.
Vì sao GC đồng thời bắt buộc có write barrier
Nhớ lại tricolor: object trắng (chưa thăm), xám (đã thăm chưa quét con), đen (quét xong). GC đánh dấu song song với chương trình. Vấn đề: chương trình có thể sửa con trỏ giữa lúc GC đang mark.
Điều nguy hiểm là vi phạm bất biến tricolor: không object đen nào được trỏ thẳng tới object trắng. Vì đen nghĩa là "đã quét xong, không quét lại". Nếu chương trình làm den.next = trang (đen trỏ tới trắng) trong lúc mark, và object trắng đó không được ai khác trỏ tới, thì GC sẽ không bao giờ thăm object trắng — nó ở lại trắng tới cuối và bị thu nhầm dù đang sống. Đó là lỗi thảm hoạ: bộ nhớ đang dùng bị giải phóng.

Hình 1: Bất biến tricolor cấm đen→trắng. Write barrier giữ bất biến. Assembly thật: setPtr (ghi con trỏ) có CALL gcWriteBarrier2, còn setInt (ghi số) chỉ một lệnh MOVD trần.
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 pha mark, khi bạn gán con trỏ, nó tô xám object liên quan (Go dùng "hybrid write barrier" từ 1.8: tô xám cả object mới-được-trỏ-tới lẫn object cũ-bị-mất-trỏ), đảm bảo GC chắc chắn quét tới. Nhờ vậy bất biến được giữ dù chương trình chạy song song.
Đo thật: chỉ con trỏ mới có barrier
Assembly chứng minh rõ ràng. Với go build -gcflags="-S":
func setPtr(t *T, v *T) { t.p = v } // ghi CON TRỎ
func setInt(t *T, v int) { t.x = v } // ghi SỐ
setPtr(ghi con trỏ) biên dịch ra:MOVWU runtime.writeBarrier(SB)(kiểm cờ barrier) +CALL runtime.gcWriteBarrier2(ghi lại con trỏ cho GC).setInt(ghi số) chỉ một lệnh:MOVD R1, 8(R0)— store trần, không barrier.
Lý do rõ ràng: ghi một số không thể tạo tham chiếu tới object nào, nên không thể vi phạm bất biến — không cần barrier. Chỉ ghi con trỏ mới nguy hiểm. Trình biên dịch biết kiểu của field và chỉ chèn barrier cho field con trỏ.
Đo thật: chi phí của barrier
Benchmark ghi con trỏ liên tục so với ghi số liên tục:

Hình 2: Ghi con trỏ 0,636 ns so với ghi số 0,226 ns — chậm ~2,8 lần, chênh ~0,4 ns là chi phí kiểm cờ barrier. GC on/off gần như nhau vì loop nóng không cấp phát nên GC ít mark trùng.
Kết quả trung thực: ghi con trỏ tốn 0,636 ns, ghi số tốn 0,226 ns — chậm ~2,8 lần. Chênh lệch ~0,4 ns là chi phí của việc kiểm cờ writeBarrier trên mỗi phép ghi con trỏ. Chi phí này luôn có mặt, kể cả khi GC nhàn rỗi (cờ tắt) — vì nhánh kiểm cờ vẫn được thực thi.
Một điều trung thực đáng nói: chạy với GOGC=off (barrier chắc chắn tắt) và GOGC=1 (GC hay chạy) cho kết quả gần như y hệt (0,636 vs 0,630 ns). Vì loop nóng này không cấp phát nên GC hiếm khi mark trùng với các phép ghi. Chi phí ta đo được ở đây chủ yếu là nhánh kiểm cờ (luôn có), chưa phải phần ghi lại con trỏ (chỉ tốn thêm khi GC thật sự đang mark). Khi GC đang mark thật, mỗi phép ghi con trỏ còn tốn thêm để đẩy con trỏ vào hàng đợi của GC.
Ứng dụng thực tế
Code nhiều con trỏ có thể chậm hơn code toàn giá trị. Vì mỗi phép gán con trỏ trả giá barrier, một cấu trúc dữ liệu đầy con trỏ (linked list, cây con trỏ) chịu chi phí barrier trên mỗi lần cập nhật liên kết. Cấu trúc dựa trên slice giá trị (mảng liền, index thay con trỏ) tránh được barrier — nhanh hơn trên đường nóng cập nhật nhiều.
Đây là một lý do nữa để giảm con trỏ trong struct nóng. Bài tiny allocator đã cho thấy struct không con trỏ được gộp và GC bỏ qua khi quét. Giờ thêm lý do: ghi vào field con trỏ trả giá barrier, ghi field số thì không. Thiết kế struct nóng ít con trỏ có lợi kép.
Thay con trỏ bằng index khi hợp. Trong cấu trúc dữ liệu hiệu năng cao, thay *Node bằng int32 (chỉ số vào một slice) không chỉ tiết kiệm bộ nhớ và thân thiện cache, mà còn tránh write barrier — vì gán một int32 không kích hoạt barrier. Đây là kỹ thuật phổ biến trong thư viện tốc độ cao.
Đánh đổi cần cân nhắc
Write barrier là cái giá tất yếu của GC đồng thời độ trễ thấp — không tránh được. Không có barrier thì GC không thể chạy song song, phải stop-the-world lâu (hàng trăm ms). ~0,4 ns mỗi phép ghi con trỏ là cái giá rất rẻ để đổi lấy STW chỉ vài chục micro-giây (bài GC tricolor). Đừng "sợ" barrier tới mức tránh con trỏ ở mọi nơi — chỉ cân nhắc trên đường siêu nóng.
Chi phí barrier nhỏ ở quy mô phổ thông. 0,4 ns mỗi phép gán con trỏ là không đáng kể với đa số code. Chỉ khi bạn gán con trỏ hàng tỉ lần trên đường nóng (cập nhật cấu trúc dữ liệu lớn liên tục) thì tổng chi phí mới đáng đo. Profile trước, đừng tối ưu mù.
Không thể tắt write barrier riêng lẻ. Nó là phần cố định của mô hình GC đồng thời. Cách duy nhất "tránh" là dùng ít con trỏ hơn (index, giá trị), hoặc — với chương trình ngắn không cần GC — chạy GOGC=off (nhưng barrier vẫn compiled in, chỉ là cờ tắt). Không có công tắc tắt barrier.
Ba ý mang về
- GC đồng thời bắt buộc có write barrier để giữ bất biến tricolor: cấm object đen trỏ thẳng tới object trắng — nếu không, object sống bị bỏ sót và thu nhầm; write barrier tô xám object liên quan khi gán con trỏ trong pha mark.
- Chỉ ghi con trỏ mới có barrier: đo thật qua assembly,
setPtrcóCALL gcWriteBarrier2cònsetIntchỉ một lệnhMOVDtrần — vì ghi số không thể tạo tham chiếu nên không cần barrier. - Chi phí ~0,4 ns mỗi phép ghi con trỏ: đo thật ghi con trỏ 0,636 ns so với ghi số 0,226 ns (chậm 2,8 lần) — luôn có mặt kể cả khi GC nhàn; đây là lý do dùng index/giá trị thay con trỏ trên đường nóng vừa tiết kiệm bộ nhớ vừa tránh barrier, nhưng chỉ đáng tối ưu ở quy mô rất lớn.
Bài tập làm thử
Bài 1 (đọc hiểu). "Bất biến tricolor" mà write barrier phải giữ là gì? Nếu bất biến này bị vi phạm thì hậu quả cụ thể ra sao?
Đáp án
Bất biến tricolor: không object đen nào được trỏ thẳng tới object trắng (vì đen nghĩa là đã quét xong, không quét lại). Nếu vi phạm — ví dụ den.next = trang trong lúc mark, và object trắng đó không có ai khác trỏ tới — thì GC sẽ không bao giờ thăm object trắng đó nữa. Nó ở lại trắng tới cuối chu kỳ và bị thu hồi nhầm dù đang sống, tức là bộ nhớ đang dùng bị giải phóng — một lỗi thảm hoạ.
Bài 2 (đọc hiểu code/assembly). Cho hai hàm Go sau, biên dịch bằng go build -gcflags="-S". Hàm nào sẽ sinh ra lệnh gọi CALL runtime.gcWriteBarrier2, và tại sao hàm kia thì không?
type T struct {
p *T
x int
}
func setPtr(t *T, v *T) { t.p = v }
func setInt(t *T, v int) { t.x = v }
Đáp án
setPtr sẽ sinh ra MOVWU runtime.writeBarrier(SB) (kiểm cờ barrier) rồi CALL runtime.gcWriteBarrier2, vì nó ghi một con trỏ — có thể tạo hoặc phá quan hệ tham chiếu giữa các object nên cần write barrier để giữ bất biến tricolor. setInt chỉ sinh một lệnh MOVD store trần, không có barrier, vì ghi một số nguyên không thể tạo tham chiếu tới object nào nên không thể vi phạm bất biến tricolor.
Bài 3 (đọc hiểu số liệu). Benchmark đo được: ghi con trỏ (t.next = v) tốn 0,636 ns/op, ghi số (t.val = i) tốn 0,226 ns/op. Chạy lại với GOGC=off và GOGC=1 cho kết quả gần như y hệt (0,636 vs 0,630 ns). Giải thích tại sao bật/tắt GC gần như không đổi chi phí, và chi phí ~0,4 ns đo được thực chất đến từ đâu.
Đáp án
Loop benchmark này không cấp phát bộ nhớ, nên dù GOGC bật hay tắt, GC hiếm khi đang mark trùng thời điểm với các phép ghi con trỏ — phần "ghi lại con trỏ cho GC" (chỉ tốn thêm khi GC thật sự đang mark) gần như không xảy ra ở cả hai trường hợp. Chi phí ~0,4 ns đo được ở đây chủ yếu là nhánh kiểm cờ writeBarrier — nhánh này luôn được thực thi trên mọi phép ghi con trỏ bất kể GC có đang chạy hay không.
Bài 4 (vận dụng thực tế). Bạn đang thiết kế một cấu trúc dữ liệu hiệu năng cao (ví dụ cây hoặc đồ thị) cần cập nhật liên kết rất thường xuyên trên đường nóng. Theo bài viết, kỹ thuật nào giúp tránh chi phí write barrier, và vì sao nó hiệu quả?
Đáp án
Thay con trỏ (*Node) bằng chỉ số kiểu số (ví dụ int32) trỏ vào một slice chứa các node, thay vì dùng con trỏ trực tiếp. Vì gán một giá trị số không kích hoạt write barrier (chỉ ghi con trỏ mới cần barrier), kỹ thuật này vừa tránh được chi phí barrier, vừa tiết kiệm bộ nhớ và thân thiện với cache hơn — đây là kỹ thuật phổ biến trong các thư viện tốc độ cao.
Bài 5 (bẫy/đánh đổi). Một lập trình viên sau khi đọc bài này quyết định loại bỏ hết con trỏ trong toàn bộ codebase để "tránh write barrier", kể cả ở những nơi không phải đường nóng. Bài viết có ủng hộ cách tiếp cận này không? Vì sao?
Đáp án
Không. Bài viết nói rõ chi phí ~0,4 ns mỗi phép ghi con trỏ là "cái giá tất yếu của GC đồng thời độ trễ thấp — không tránh được" và "rất rẻ" so với lợi ích STW chỉ vài chục micro-giây; đồng thời nhấn mạnh "chi phí barrier nhỏ ở quy mô phổ thông" — chỉ đáng cân nhắc khi gán con trỏ hàng tỉ lần trên đường siêu nóng. Bài viết khuyên "đừng sợ barrier tới mức tránh con trỏ ở mọi nơi" mà nên profile trước, chỉ tối ưu chỗ đã xác định là nóng thật sự.
Phần sau ta đo chính xác con số mà write barrier giúp giữ nhỏ: Phần sau mổ xẻ stop-the-world — GC còn dừng chương trình ở những điểm nào, thời gian dừng thật là bao nhiêu, và điều gì làm nó dài ra.