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.

Ảnh chụp đoạn mã Go nền tối minh hoạ false sharing hai lõi tranh nhau một cache line, CPU không đọc ghi từng byte mà theo khối 64 byte cache line nếu hai biến của hai lõi khác nhau nằm cùng một cache line mỗi lần ghi buộc đồng bộ chéo giữa các lõi, type PackedA struct c 8 int64 sát nhau 8 counter liền tiếp bằng 64 byte đúng 1 cache line, type PaddedA struct c 8 struct v int64 đệm 56 byte mỗi counter chiếm trọn 1 cache line 64B riêng 8 cộng 56 bằng 64 tách cache line, 8 goroutine mỗi cái tăng counter riêng của nó không chia sẻ dữ liệu go func i for k atomic AddInt64 con trỏ p.c i 1, bố cục Packed 8 counter chung 1 line 8 lõi tranh nhau Padded mỗi counter 1 line riêng, cơ chế giao thức đồng bộ cache coherency khi lõi A ghi vào cache line mọi bản sao ở lõi khác bị vô hiệu hoá nếu 8 lõi cùng ghi vào 8 counter trên một line line bị ping-pong liên tục dù không chia sẻ dữ liệu thật gọi là chia sẻ giả false sharing

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)

Ảnh chụp bảng kết quả đo thật nền tối false sharing Go 1.23 arm64 GOMAXPROCS 8, với atomic buộc ghi bộ nhớ mỗi lần hiệu ứng rõ nhất BenchmarkFalseSharingAtomic 830 ms/op 8 counter chung 1 cache line BenchmarkPaddedAtomic 27.5 ms/op mỗi counter 1 cache line tỷ lệ khoảng 30 lần chỉ khác chỗ đặt counter trong bộ nhớ, với phép tăng thường không atomic BenchmarkFalseSharing 42.3 ms/op sát nhau BenchmarkPadded 25.4 ms/op đệm vẫn chậm khoảng 1.67 lần atomic khuếch đại vì mỗi lệnh buộc đồng bộ line, cốt lõi 8 goroutine tăng 8 counter riêng của mình không hề chia sẻ dữ liệu về mặt logic nhưng khi 8 counter đó nằm chung một cache line 64 byte mỗi lệnh atomic buộc cache line ping-pong giữa 8 lõi làm chậm khoảng 30 lần chèn 56 byte đệm để mỗi counter chiếm một cache line riêng xoá hẳn tranh chấp

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ề

  1. 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.
  2. 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ớ.
  3. 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.CacheLinePad cho 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.