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