Hình dung hai người dùng chung một cuốn bìa còng đánh số trang. Bạn chỉ viết trang 3, tôi chỉ viết trang 7 — hai trang hoàn toàn khác nhau, không ai đụng chữ của ai. Nghe thì chẳng có gì phải tranh. Nhưng chỉ có một cuốn bìa còng vật lý, và để viết bất kỳ trang nào bạn phải đang cầm cả cuốn trong tay. Thế là mỗi lần tôi muốn viết trang 7, tôi phải giật cả cuốn từ bạn; rồi bạn muốn viết trang 3 lại giật về — cuốn bìa cứ chuyền qua chuyền lại, dù trang của hai ta chẳng bao giờ chồng lên nhau. Đó chính xác là false sharing (chia sẻ giả): CPU quản bộ nhớ theo "cuốn bìa" 64 byte (dòng cache), và hai biến độc lập nằm chung một dòng làm nhau chậm y như tranh chấp thật.

Ở bài atomic ta thấy nhiều luồng cùng ghi một biến gây cache-line bouncing. Nhưng có một bẫy tinh vi hơn nhiều: nhiều luồng ghi những biến hoàn toàn khác nhau — mỗi luồng một biến riêng, không ai chạm của ai — mà vẫn chậm như thể chia sẻ. Đây là một trong những bug hiệu năng ẩn khó chịu nhất trong lập trình đa luồng: bạn không thấy nó khi đọc code, chỉ có đo mới lộ ra. Tôi đo nó, và con số nói lên tất cả.

Cache làm việc theo dòng, không theo byte

Chìa khóa để hiểu false sharing là: cache không quản lý từng byte, mà từng "dòng" (cache line) 64 byte — cả cuốn bìa còng, không phải từng trang. Khi một lõi CPU muốn ghi vào bất kỳ chỗ nào trong một dòng cache, nó phải giành quyền sở hữu độc quyền cả dòng đó — nghĩa là đẩy dòng ra khỏi cache của mọi lõi khác (giao thức coherence như MESI đảm bảo chỉ một lõi được ghi một dòng tại một thời điểm). Điều này hợp lý khi các lõi thật sự chia sẻ dữ liệu. Nhưng nó gây họa khi hai dữ liệu độc lập tình cờ nằm chung một dòng: mỗi lõi ghi phần của mình vẫn kéo cả dòng về, đẩy lõi kia ra, và dòng cứ nảy qua lại — dù về mặt logic không có gì được chia sẻ.

Đo: hai biến riêng vẫn làm nhau chậm 2 lần

Tôi dựng một mảng 8 bộ đếm long, mỗi luồng tăng bộ đếm của riêng nó (counter[tid]) 200 triệu lần. Tám long liền nhau là đúng 64 byte = một dòng cache, nên tất cả nằm chung một dòng — kịch bản false sharing tối đa. So với bản căn lề: mỗi bộ đếm được đệm sang một dòng cache riêng (_Alignas(64) cộng đệm cho đủ 64 byte). Đo trong container gcc:13 (AArch64):

        FALSE SHARING | PADDED | chậm
T=1 :   147 ms        | 149 ms | 0,99×
T=2 :   222 ms        | 162 ms | 1,37×
T=4 :   323 ms        | 202 ms | 1,60×
T=8 :   746 ms        | 365 ms | 2,05×

Với một luồng không có khác biệt (0,99×) — không lõi nào khác giành dòng cache. Nhưng khi thêm luồng, bản false sharing chậm dần so với bản căn lề: 1,37× ở 2 luồng, 1,60× ở 4, và 2,05× ở 8 luồng. Hai biến độc lập, mỗi luồng chỉ chạm biến của mình, mà làm nhau chậm gấp đôi — chỉ vì chúng nằm chung một dòng cache. Cả hai bản chạy logic y hệt; khác biệt duy nhất là bố cục bộ nhớ.

(Môi trường đo: container gcc:13 trên host ARM AArch64, dòng cache 64B, 8 bộ đếm long chung một dòng so với mỗi bộ đếm _Alignas(64) một dòng riêng, mỗi luồng 200 triệu phép tăng. Con số tuyệt đối đổi theo máy; điều bền vững là hình dạng: một luồng không khác, càng nhiều luồng bản chung-dòng càng chậm so bản căn lề.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với niềm tin nghe rất hợp lý: "hai biến khác nhau, mỗi luồng ghi biến của riêng nó thì không thể ảnh hưởng nhau — không có dữ liệu chia sẻ, không có tranh chấp". Đo ra sai hẳn: chúng làm nhau chậm 2 lần. Cái đánh lừa tôi là tôi nghĩ ở mức biến (logic), còn phần cứng nghĩ ở mức dòng cache (vật lý). Về logic, counter[0] và counter[1] là hai thứ tách biệt. Về vật lý, chúng ở cùng một dòng 64 byte, và giao thức coherence không phân biệt "biến nào" — nó chỉ biết "dòng này đang bị lõi khác ghi", nên phải giành lại.

Trớ trêu là niềm tin đối nghịch — "để dữ liệu sát nhau cho gọn thì cache tốt hơn" — cũng thường đúng nhưng ở đây phản tác dụng. Với một luồng, nhồi dữ liệu sát nhau cải thiện cục bộ (cache line kéo về một lần dùng được nhiều) — đó là lời khuyên đúng ở căn lề và dòng cache cho code đơn luồng. Nhưng với đa luồng, chính sự "sát nhau" đó gom các biến bị nhiều lõi khác nhau ghi vào cùng một dòng, và biến cục-bộ-tốt thành false-sharing-tệ. Hai niềm tin ngược nhau (biến riêng thì độc lập; dữ liệu sát nhau thì tốt) gặp nhau ở một sự thật: ranh giới tranh chấp là dòng cache 64 byte, không phải biến — nên hai thứ độc lập về logic vẫn đụng nhau nếu chung dòng.

Đây là lý do false sharing đặc biệt nguy hiểm: nó vô hình trong mã nguồn. Code trông hoàn hảo — mỗi luồng thao tác dữ liệu riêng, không khóa, không biến chung. Không có gì để một người đọc code chỉ ra. Chỉ khi đo và thấy tốc độ không tăng theo số luồng như kỳ vọng, rồi nhìn vào bố cục bộ nhớ, bạn mới phát hiện thủ phạm. Bài học đo lường của bài: một số bug chỉ tồn tại ở tầng phần cứng mà mã nguồn không hé lộ — và chỉ đo mới lôi chúng ra ánh sáng.

Ba họ hàng của cùng một gốc

False sharing, cache-line bouncing của atomic tranh chấp, và chi phí của mutex bị giành thật ra là ba biểu hiện của cùng một gốc: nhiều lõi cùng cần ghi vào một dòng cache. Với atomic hay mutex, việc chia sẻ là thật (các luồng cố ý cập nhật cùng một dữ liệu). Với false sharing, việc chia sẻ là giả (dữ liệu độc lập, chỉ tình cờ cùng dòng) — và chính vì giả nên nó âm hiểm hơn, vì bạn không hề chủ ý chia sẻ gì. Nhưng cách chữa gốc rễ giống nhau: giảm số dòng cache bị nhiều lõi cùng ghi. Với chia sẻ thật, ta cho mỗi luồng một bản cục bộ rồi gộp cuối. Với chia sẻ giả, ta căn lề để tách các dữ liệu độc lập ra các dòng khác nhau (mỗi người một cuốn bìa còng). Cả hai đều là biến thể của một nguyên tắc: dữ liệu bị ghi nóng nên thuộc về một lõi, không bị các lõi khác kéo qua kéo lại — và đo lường đúng cách là cách duy nhất để biết mình đã đạt được điều đó chưa.

Về đến code hằng ngày

Điều đầu tiên: căn lề dữ liệu bị nhiều luồng ghi sang các dòng cache riêng. Khi mỗi luồng có một trạng thái riêng bị ghi liên tục (bộ đếm, tổng cục bộ, con trỏ hàng đợi), hãy đảm bảo mỗi cái nằm trên một dòng cache độc lập — bằng _Alignas(64) hoặc chèn đệm. Đây chính là ứng dụng đồng thời của căn lề dữ liệu và dòng cache: ở đa luồng, căn lề không chỉ để truy cập nhanh mà để tránh nảy.

Điều thứ hai: coi chừng các mảng theo chỉ số luồng và các trường liền kề trong struct chia sẻ. Mẫu results[thread_id] hay một struct có nhiều trường mà các luồng khác nhau ghi vào các trường khác nhau là ổ false sharing kinh điển. Nếu một cấu trúc dữ liệu song song không tăng tốc theo số lõi, false sharing là nghi phạm số một — kiểm bằng cách đệm rồi đo lại.

Chỗ này cái nhu cầu "đệm một trường ra riêng dòng cache" phổ biến đến mức các nền tảng làm hẳn công cụ cho nó, và nhận ra chúng giúp bạn khỏi tự chế vụng. Java có chú thích @Contended (jdk.internal.vm.annotation.Contended) tự đệm một trường ra dòng riêng — và nó được dùng ngay trong LongAdder/Striped64 (các ô đếm per-thread phải tách dòng, nếu không thì sharding cũng vô nghĩa vì false sharing) và trong ForkJoinPool; LMAX Disruptor thì nổi tiếng vì đệm tay các con trỏ sequence bằng bảy trường long thừa (p1..p7) trước khi có @Contended. Go phơi CacheLinePad (một [N]byte cho đủ dòng) trong golang.org/x/sys/cpu và dùng nó trong runtime để tách các biến per-P. Rust có crossbeam::CachePadded. Mẫu số chung: bất cứ khi nào bạn tạo một mảng trạng thái per-thread bị ghi nóng, hãy đệm mỗi phần tử ra một dòng cache riêng — đây là bước không thể thiếu để sharding/per-core thật sự scale, chứ không phải tối ưu xa xỉ.

Muốn tự thấy thì khai một mảng long counter[8] và cho 8 luồng, mỗi luồng tăng counter[tid] vài trăm triệu lần; đo thời gian. Rồi đổi thành struct { _Alignas(64) long v; } counter[8]; (mỗi phần tử chiếm trọn một dòng cache) và cho các luồng tăng counter[tid].v — đo lại. Bạn sẽ thấy bản thứ hai nhanh hơn rõ rệt, dù logic không đổi một chữ. Vài phút đó cho bạn chạm tận tay vào false sharing: cùng một thuật toán, cùng dữ liệu "riêng", mà chỉ việc trải chúng ra các dòng cache khác nhau đã cắt đôi thời gian — một bài học rằng ở đa luồng, bố cục bộ nhớ quan trọng ngang thuật toán.