Bài trước dạy sắp field để struct gọn nhất — nhồi dữ liệu gần nhau tiết kiệm bộ nhớ. Bài này là mặt trái đáng ngạc nhiên: có lúc nhồi dữ liệu gần nhau lại làm chương trình chậm 30 lần. Hiện tượng đó gọi là false sharing (chia sẻ giả), và nó xảy ra khi nhiều lõi CPU cùng ghi vào các biến khác nhau nhưng nằm chung một cache line. Bài này đo trực tiếp mức chậm khủng khiếp đó và cách sửa.
Cache line: CPU không làm việc theo byte
CPU không đọc/ghi bộ nhớ theo từng byte mà theo khối cố định gọi là cache line — thường 64 byte. Khi bạn chạm một byte, cả cache line 64 byte chứa nó được nạp vào cache. Đây là lý do duyệt mảng tuần tự nhanh (dữ liệu kề nhau vào cùng line).
Vấn đề nảy sinh với cache coherency — giao thức đảm bảo mọi lõi thấy dữ liệu nhất quán. Khi lõi A ghi vào một cache line, mọi bản sao của line đó ở các lõi khác bị vô hiệu hoá (invalidate): chúng phải nạp lại bản mới. Nếu hai lõi liên tục ghi vào cùng một cache line, line đó bị "ping-pong" qua lại giữa chúng — dù mỗi lõi ghi vào một biến khác nhau trong line.

Hình 1: 8 counter int64 liền tiếp = 64 byte = đúng một cache line, nên 8 goroutine cùng ghi vào một line. Chèn 56 byte đệm để mỗi counter chiếm một cache line riêng.
Đo thật: chậm 30 lần
Đây là một trong những phép đo gây sốc nhất của cả sê-ri. Tám goroutine, mỗi cái tăng counter riêng của nó — hoàn toàn không chia sẻ dữ liệu về mặt logic. Chỉ khác chỗ đặt counter trong bộ nhớ: sát nhau (chung cache line) hay có đệm (mỗi cái một line).
type PackedA struct{ c [8]int64 } // 8*8 = 64 byte = 1 cache line
type PaddedA struct{ c [8]struct{ v int64; _ [56]byte } } // mỗi cái 64B
// mỗi goroutine: for k... atomic.AddInt64(&p.c[i], 1)

Hình 2: Với atomic, counter sát nhau 830 ms so với có đệm 27,5 ms — chậm ~30 lần. Với phép tăng thường: 42,3 so với 25,4 ms (~1,67 lần). Chỉ khác chỗ đặt counter.
Kết quả với atomic: counter sát nhau tốn 830 ms, counter có đệm chỉ 27,5 ms — chậm ~30 lần! Tám lõi cùng làm atomic.AddInt64 vào tám counter trên một cache line khiến line đó bị giằng co liên tục giữa tám lõi. Mỗi lệnh atomic phải giành quyền độc chiếm line, ghi, rồi lõi khác lại giành lại. Chèn 56 byte đệm để mỗi counter chiếm một cache line riêng → tám lõi làm việc độc lập, không tranh chấp → nhanh 30 lần.
Với phép tăng thường (++, không atomic), hiệu ứng nhỏ hơn (42 so với 25 ms, ~1,67 lần) vì phần cứng và compiler có chút tự do gộp ghi. Atomic khuếch đại vì mỗi lệnh buộc đồng bộ cache line ngay lập tức — đó là bản chất của atomic.
Ứng dụng thực tế
Mảng/slice counter theo mỗi goroutine là ổ false sharing. Mẫu phổ biến: mỗi worker cập nhật stats[workerID]. Nếu các phần tử stats sát nhau (mảng int64), bạn đang tự tạo false sharing. Đệm mỗi phần tử tới 64 byte, hoặc cho mỗi worker một biến hoàn toàn riêng rồi gộp cuối cùng.
Struct chia sẻ giữa nhiều goroutine cần cân nhắc bố cục. Nếu một struct có nhiều field được các goroutine khác nhau ghi (ví dụ hai bộ đếm cho hai luồng), đặt chúng cùng cache line gây false sharing. Tách chúng bằng padding nếu đo được vấn đề.
Đây là lý do runtime Go tự đệm một số cấu trúc. Các cấu trúc nội bộ của runtime (như trong scheduler, allocator) có padding chống false sharing ở những chỗ nóng. sync.Pool, mcache per-P... đều được thiết kế để mỗi P chạm vùng nhớ riêng, tránh giằng co cache line giữa các lõi.
Đánh đổi cần cân nhắc
Padding tốn bộ nhớ — ngược với tối ưu alignment. Bài trước ta bỏ padding để struct gọn; bài này ta thêm padding để tách cache line. Hai mục tiêu mâu thuẫn: gọn bộ nhớ (tốt cho một lõi duyệt tuần tự) vs tách cache line (tốt cho nhiều lõi ghi song song). Chọn theo cách dữ liệu được truy cập: đọc tuần tự một lõi thì nhồi gọn; nhiều lõi ghi thì đệm tách.
Chỉ đệm khi đo được false sharing. Đừng đệm mọi struct "cho chắc" — phí bộ nhớ và hại cache khi truy cập tuần tự. False sharing chỉ xảy ra khi nhiều lõi ghi vào cùng cache line ở tần suất cao. Nếu dữ liệu chủ yếu đọc, hoặc chỉ một lõi ghi, không có false sharing. Dùng perf c2c (Linux) hoặc profiling để xác nhận trước khi đệm.
Kích thước cache line phụ thuộc kiến trúc. 64 byte đúng cho hầu hết x86 và arm64 hiện đại, nhưng một số kiến trúc dùng 128 byte. Để đệm an toàn tuyệt đối trên mọi CPU, dùng cpu.CacheLinePad từ golang.org/x/sys/cpu thay vì hardcode 56.
Ba ý mang về
- False sharing làm chậm chương trình song song mà không chia sẻ dữ liệu thật: đo thật, 8 goroutine tăng 8 counter riêng chậm ~30 lần (830 so với 27,5 ms) chỉ vì 8 counter nằm chung một cache line 64 byte và bị 8 lõi giằng co qua cache coherency.
- Sửa bằng cách đệm để mỗi biến hay ghi chiếm một cache line riêng: chèn 56 byte đệm (8 + 56 = 64) tách các counter → xoá hẳn tranh chấp — nhưng chỉ đệm khi đo được false sharing, vì nó tốn bộ nhớ.
- Padding chống false sharing mâu thuẫn với tối ưu alignment gọn: nhồi gọn tốt cho một lõi đọc tuần tự, tách cache line tốt cho nhiều lõi ghi — chọn theo cách truy cập, và dùng
cpu.CacheLinePadcho di động giữa kiến trúc.
Phần sau ta bước sang trụ cột lớn nhất của runtime Go — bộ thu gom rác: Phần sau mổ xẻ GC tricolor mark-sweep chạy đồng thời — cách Go đánh dấu object sống bằng ba màu, quét song song với chương trình, và giữ thời gian dừng cực ngắn.