Bạn viết một chương trình đa luồng "sạch sẽ": mỗi luồng có bộ đếm riêng, không luồng nào đụng biến của luồng khác, không khóa, không atomic. Về logic chúng hoàn toàn độc lập — lẽ ra phải chạy song song hoàn hảo. Vậy mà đo ra càng thêm luồng càng chậm. Tôi từng ngồi soi mãi không hiểu, cho tới khi nhớ ra thủ phạm: một cạm bẫy vô hình nằm hẳn dưới tầng code, tên là false sharing (chia sẻ giả).
Hình dung một tờ giấy A4 chuyền tay quanh bàn. Bốn người mỗi người phụ trách ghi vào một góc riêng của tờ giấy — góc trên trái, trên phải, dưới trái, dưới phải. Nghe thì độc lập, nhưng cả bốn dùng chung một tờ giấy: mỗi lần ai muốn ghi, phải cầm nguyên tờ về phía mình, ba người kia ngồi chờ. Họ chẳng ghi đè lên nhau chữ nào, nhưng vẫn phải chuyền tờ giấy qua lại liên tục. CPU xử lý bộ nhớ đúng như thế — nó không chuyền theo "biến", nó chuyền theo cả một dòng cache. Hai biến riêng của hai luồng mà tình cờ nằm chung một dòng thì y như hai người ghi chung một tờ giấy. Tôi đo trong container gcc:13 trên host ARM, và con số cho thấy các biến "độc lập" đâm nhau ra sao.
Coherence theo dòng, không theo biến
Mỗi lõi CPU có cache riêng, và phần cứng phải giữ chúng nhất quán (cache coherence): nếu lõi A ghi vào một dòng cache mà lõi B cũng đang giữ bản sao, dòng đó phải chuyển quyền sở hữu sang A và vô hiệu bản của B. Lần sau B ghi, nó lại giật về — một cuộc ping-pong tốn hàng chục tới trăm chu kỳ mỗi lần chuyển. Đúng cảnh chuyền tờ giấy.
Điểm mấu chốt: đơn vị của coherence là cả dòng cache (128 byte trên máy này, phần 6), không phải từng biến. Nên nếu hai biến riêng của hai luồng tình cờ nằm chung một dòng, mỗi lần một luồng ghi biến của mình, phần cứng vẫn giật cả dòng khỏi lõi kia — dù hai biến chẳng liên quan gì nhau về logic. Hai luồng tưởng độc lập, nhưng phần cứng thấy chúng chia sẻ một dòng. Đó là false sharing.
Để đo, tôi cho T luồng, mỗi luồng tăng một bộ đếm riêng 50 triệu lần, theo hai bố cục: gói sát — 8 bộ đếm long liền nhau (64 byte, chung một dòng 128B); và padded — mỗi bộ đếm căn alignas(128) để nằm ở dòng cache riêng.
Đo: cùng code, chỉ khác bố cục bộ nhớ
Mỗi luồng tăng bộ đếm RIÊNG 50 triệu lần; ns/tăng (wall time / iter), host ARM:
T | gói sát (chung dòng) | padded (dòng riêng) | chậm hơn
--|----------------------|---------------------|----------
1 | 0,653 ns | 0,716 ns | 0,9x
2 | 1,121 ns | 0,855 ns | 1,3x
4 | 1,762 ns | 0,909 ns | 1,9x
8 | 3,997 ns | 1,561 ns | 2,6x
Đọc bảng: với 1 luồng, hai bố cục ngang nhau (không có ai để tranh dòng). Nhưng khi thêm luồng, bản gói sát chậm dần so với padded: 1,3x ở 2 luồng, 1,9x ở 4, và 2,6x ở 8 luồng. Cùng một đoạn code, cùng số phép tăng, mỗi luồng ghi biến riêng của nó — khác biệt duy nhất là các bộ đếm có nằm chung dòng cache hay không. Phần chậm thêm ấy là thuần chi phí coherence: dòng cache chứa cả 8 bộ đếm bị ping-pong giữa các lõi mỗi lần bất kỳ luồng nào ghi.
Có một chi tiết tôi thấy đáng gạch chân: đây là ghi thường, không atomic, không khóa. Vậy mà vẫn trả giá đồng bộ — vì sự "đồng bộ" ở đây không phải do bạn viết ra, mà do phần cứng bắt buộc để giữ cache nhất quán. Bản padded (mỗi biến một dòng riêng) không có ai tranh dòng nên scale tốt hơn hẳn.
Nói cho công bằng: trên lõi Apple Silicon này, coherence khá hiệu quả nên false sharing chỉ ~2,6x — nhẹ hơn con số 10–100x hay gặp trên x86 đời cũ. (Bản padded cũng chậm nhẹ ở 8 luồng, có thể do lõi hiệu năng và lõi tiết kiệm điện không đều nhau; vì thế nên nhìn tỉ lệ gói-sát/padded để tách riêng chi phí false sharing.) Nhưng dù nhẹ hơn, hiệu ứng có thật và tăng theo số luồng — càng nhiều lõi càng đau.
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với một niềm tin rất hợp lý: "mỗi luồng có biến riêng, không dùng chung, thì chúng độc lập — chạy song song thoải mái, chẳng tốn gì". Con số bác ngay: các biến độc lập về logic vẫn đâm nhau về vật lý nếu chung dòng cache — gói sát chậm 2,6x padded ở 8 luồng. Phần cứng không thấy "biến"; nó thấy "dòng cache". Nếu tin "biến riêng là an toàn" rồi xếp mảng bộ đếm-mỗi-luồng sát nhau, bạn tự tay tạo một nút thắt coherence mà không một dòng code nào cho thấy.
Nhưng nếu vì thế mà tin điều ngược lại — "chi phí đồng bộ chỉ đến từ atomic hoặc khóa; ghi thường thì miễn phí" — thì cũng sai. Đây là phép tăng counter++ thường, không atomic, không lock, mà vẫn trả giá coherence khi các biến chung dòng — khác với chi phí atomic/barrier bạn chủ ý dùng. Sự đồng bộ ẩn ở tầng phần cứng: mọi ghi vào một dòng đang được nhiều lõi giữ đều kích hoạt ping-pong, bất kể bạn có dùng atomic hay không. Chi phí song song không chỉ nằm ở chỗ bạn chủ ý đồng bộ, mà cả ở bố cục bộ nhớ bạn vô tình tạo ra.
Về đến code hằng ngày
Việc đầu tiên: đệm (padding) các biến bị nhiều luồng ghi ra dòng cache riêng. Mảng bộ đếm-mỗi-luồng, các trường "hot" mà nhiều luồng cập nhật, kết quả cục bộ trước khi gộp — hãy căn lề alignas(64)/alignas(128) hoặc chèn padding để mỗi cái một dòng. Đây là lý do các cấu trúc đồng thời hiệu năng cao (bộ đếm phân mảnh, hàng đợi, thống kê per-thread) luôn đệm dữ liệu per-thread.
Việc thứ hai: cảnh giác với các struct chia sẻ có trường ghi bởi nhiều luồng. Một struct mà luồng A ghi trường x và luồng B ghi trường y liền kề sẽ false-share dù A, B không bao giờ đụng biến của nhau. Tách các trường "nóng theo luồng" ra, hoặc padding giữa chúng. Công cụ như perf c2c giúp tìm false sharing thật, nhưng biết nguyên lý là bước đầu.
Cái bẫy này nổi tiếng đến mức các nền tảng lớn đều có sẵn cách chống, và nhận ra chúng giúp bạn hiểu vì sao. Java có annotation @Contended (JEP 142) để JVM tự đệm một trường ra dòng cache riêng — chính là alignas(128) phiên bản Java, và LongAdder dùng nó để mỗi ô counter không giẫm dòng của ô khác. Go, khi bạn nhìn vào runtime của nó, sẽ thấy đầy những pad [N]byte chèn vào struct per-P, per-thread — cùng một thủ thuật. Ngay cả khi bạn không viết code đa luồng cấp thấp, hiện tượng vẫn hiện ra ở tầng cao dưới dạng "vì sao thêm luồng mà throughput không tăng, thậm chí giảm" — và false sharing là một trong những nghi phạm đầu bảng, bên cạnh tranh chấp khóa thật. Nguyên tắc chung: song song không tự do, phần cứng đồng bộ theo dòng cache sau lưng bạn, nên bố cục bộ nhớ là một phần của thiết kế đồng thời chứ không phải chuyện vặt.
Muốn tự thấy thì viết một chương trình T luồng, mỗi luồng tăng một bộ đếm riêng của nó vài chục triệu lần (dùng volatile để không bị tối ưu mất), bấm giờ tổng. Làm hai phiên bản: một, các bộ đếm là một mảng long counters[T] (liền nhau, chung dòng cache); hai, mỗi bộ đếm bọc trong struct alignas(128) { long v; } (mỗi cái một dòng riêng). Chạy với T = 1, 2, 4, 8 rồi so. Phiên bản mảng liền chậm dần so với phiên bản đệm khi thêm luồng — dù không luồng nào đụng biến của luồng khác, không atomic, không khóa. Đó là false sharing: dòng cache chứa mảng bị giật qua giật lại giữa các lõi. Ba mươi giây đó cho thấy điều mà "mỗi luồng biến riêng thì song song thoải mái" giấu đi: phần cứng giữ cache nhất quán theo dòng, không theo biến — nên chỉ cần hai biến riêng vô tình chung một dòng, chúng đã ngầm tranh nhau, và cách chữa đơn giản là đẩy chúng ra hai dòng khác nhau.