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 gọi là false sharing (chia sẻ giả), và nó 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ả.

False sharing

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. 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 (đều cộng đúng 600 triệu); khác biệt duy nhất là bố cục bộ nhớ.

Một lần tôi đo hớ: biến "riêng" vẫn đụng nhau

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

Đâ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. Nếu tôi tin "biến riêng thì độc lập" mà không đo, tôi đã bỏ qua một cú chậm gấp đôi.

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. 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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 mà Series trước đã đo ở ngữ cảnh đơn luồng; ở đa luồng, căn lề không chỉ để truy cập nhanh mà để tránh nảy.

Hệ quả 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.

Hệ quả thứ ba là tinh thần đo lường: khi số đo không khớp với mô hình trong đầu, tin số đo và đào sâu. Con số mang theo: hai biến độc lập, mỗi luồng chỉ ghi biến của riêng mình, vẫn làm nhau chậm tới 2,05× ở 8 luồng vì chúng nằm chung một dòng cache 64 byte và dòng đó nảy giữa các lõi (false sharing) — dù logic không hề chia sẻ; căn lề mỗi biến sang một dòng cache riêng (_Alignas(64)) xóa hiện tượng này và nhanh gấp đôi. Đây là bug hiệu năng vô hình trong mã: khi nhiều luồng ghi dữ liệu "riêng" mà vẫn chậm, hãy nghi false sharing.

Thử ba mươi giây

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ữ. Ba mươi giây đó 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.