Đếm một thứ gì đó từ nhiều luồng — số request đã xử lý, số byte đã gửi — là việc tưởng đơn giản mà đầy bẫy. Cách "đúng sách" là bảo vệ bộ đếm bằng mutex, nhưng ai cũng biết khóa thì nặng. Lời khuyên phổ biến: dùng biến nguyên tử (atomic) — atomic_fetch_add — vì nó là một lệnh phần cứng, không cần khóa. Bài này đo atomic so với mutex khi nhiều luồng cùng đếm — và phát hiện lời khuyên "dùng atomic cho nhanh" chỉ đúng một nửa, che mất cách nhanh hơn nhiều.

Số nguyên tử và CAS

Ba cách tăng một bộ đếm chung

Có ba cách tăng an toàn một bộ đếm chia sẻ giữa nhiều luồng. Mutex: khóa, counter++, mở khóa — an toàn nhưng mỗi thao tác kéo theo bộ máy khóa (và futex khi bị tranh). Atomic fetch_add: một lệnh phần cứng duy nhất tăng biến một cách nguyên tử, không cần khóa (lock xadd trên x86, lệnh LSE trên ARM). CAS (compare-and-swap): đọc giá trị cũ, rồi "so-sánh-rồi-đổi" nó thành giá trị mới trong một lệnh nguyên tử — nếu giữa chừng có luồng khác đã đổi, phép CAS thất bại và ta phải thử lại.

Nhưng cả ba đều có một điểm chung chí mạng: chúng đều đọc-sửa-ghi cùng một biến nằm trên cùng một dòng cache. Và như đã đo ở bài false sharing, một dòng cache bị nhiều nhân ghi vào sẽ nảy qua lại giữa các nhân liên tục qua giao thức đồng bộ cache. Nên câu hỏi thật không phải "atomic có nhanh hơn mutex không" mà "cái nào scale khi thêm luồng". Còn một cách thứ tư sẽ nói ở cuối. Tôi đo cả bốn.

Đo: atomic nhanh hơn, nhưng không cái nào scale

Tôi cho các luồng cùng tăng một bộ đếm chung tổng cộng 100 triệu lần, đo số thao tác mỗi giây với mutex, atomic, CAS, và shard (mỗi luồng một bộ đếm riêng, cộng dồn ở cuối), khi số luồng tăng:

Số luồng mutex atomic cas shard
1 140 446 485 679
4 30 79 78 1.793
8 30 63 49 2.626

(triệu thao tác mỗi giây)

Với một luồng, atomic (446) và CAS (485) nhanh hơn mutex (140) khoảng ba lần — đúng như lời khuyên: bỏ được bộ máy khóa. Nhưng nhìn cột theo chiều dọc mới thấy điều quan trọng: khi thêm luồng, cả mutex, atomic lẫn CAS đều tụt thông lượng — atomic từ 446 xuống 79 rồi 63 triệu op/s. Càng nhiều luồng, càng chậm, chứ không nhanh thêm. Vì một bộ đếm chung dù dùng atomic vẫn là một dòng cache duy nhất, và tám nhân giành nhau ghi vào nó khiến dòng đó nảy điên cuồng — đúng giới hạn coherency của false sharing. Atomic không hóa giải được cái bẫy đó; nó chỉ bỏ được lớp khóa nằm bên trên cái bẫy.

Cột shard kể một câu chuyện khác hẳn: 679, rồi 1.793, rồi 2.626 — tăng theo số luồng. Vì mỗi luồng tăng bộ đếm riêng của nó (không chia sẻ), không có dòng cache nào phải nảy, nên thêm luồng là thêm thông lượng thật. Ở 8 luồng, shard (2.626) nhanh hơn atomic (63) khoảng 42 lần.

Một lần tôi đo hớ: "atomic" không đồng nghĩa "scale"

Sai lầm của tôi là dừng lại quá sớm ở một tối ưu trông có vẻ đủ tốt. Tôi vào bài với lời khuyên quen thuộc "bộ đếm đa luồng thì dùng atomic thay mutex cho nhanh", đo thấy atomic nhanh gấp ba mutex, và suýt chốt bài ở đó — "atomic thắng, xong". Nhưng con số theo chiều luồng bác bỏ ý "atomic là giải pháp scale": nó nhanh hơn mutex, nhưng vẫn tụt khi tải tăng.

Cái tôi bỏ lỡ là câu hỏi đúng. "Nhanh hơn" và "scale" là hai chuyện khác nhau — atomic nhanh hơn mutex ở mọi mức luồng, nhưng không cái nào trong ba scale, vì tất cả đều tranh nhau cùng một dòng cache. Nút thắt không nằm ở cách tăng (khóa hay nguyên tử), mà ở việc chia sẻ một biến chung giữa các nhân. Thứ duy nhất scale là không tranh chấp: cho mỗi luồng một bộ đếm riêng, cộng dồn một lần ở cuối. Bài học đo lường: khi tối ưu, đừng dừng ở cải thiện đầu tiên mà hỏi 'thứ này có scale không, nút thắt thật nằm ở đâu'. Tôi "tối ưu" từ mutex sang atomic được 3 lần rồi định hài lòng, trong khi cú lớn hơn 42 lần nằm ở việc gỡ bỏ hoàn toàn sự chia sẻ. "Nhanh hơn một chút" có thể che mất "nhanh hơn rất nhiều theo một trục khác" — đúng tinh thần "'nhanh' vô nghĩa nếu chưa hỏi 'scale/bền chưa'" của cả sê-ri.

Có một chi tiết nữa đáng ghi: CAS thua fetch_add khi bị tranh. Ở 8 luồng, CAS (49) chậm hơn atomic fetch_add (63), vì phép CAS phải thử lại mỗi khi giá trị bị luồng khác đổi giữa chừng — dưới tranh chấp cao, nó quay vòng phí công. fetch_add là một lệnh phần cứng làm gọn trong một nhịp, không thử lại. Nên khi chỉ cần tăng, dùng fetch_add, đừng tự viết vòng CAS.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là atomic đúng cho tranh chấp thấp, không phải cho scale cao. Nếu bộ đếm hiếm khi bị nhiều luồng chạm cùng lúc, atomic là lựa chọn gọn và nhanh — hơn mutex, đơn giản hơn sharding. Nhưng nếu đó là một bộ đếm nóng mà mọi luồng đập vào mỗi thao tác (đếm request trên đường nóng), atomic sẽ thành nút thắt y như mutex khi tải tăng. Đo mức tranh chấp thật trước khi chọn.

Hệ quả thứ hai là muốn đếm scale thì đừng chia sẻ — hãy shard. Bộ đếm theo luồng (hoặc theo nhân) cộng dồn khi đọc là mẫu thiết kế chuẩn cho các số liệu thông lượng cao: mỗi luồng ghi vào ô riêng (padding ra dòng cache riêng để tránh cả false sharing), và chỉ khi cần đọc tổng mới cộng lại. Đây là cách các thư viện metrics, bộ đếm nhân Linux (percpu), và nhiều hệ thống hiệu năng cao làm. Đổi lại đọc tổng đắt hơn một chút, nhưng ghi thì scale tuyến tính.

Hệ quả thứ ba, về đo lường: hỏi 'scale' chứ không chỉ 'nhanh hơn', và tìm nút thắt thật. Con số mang theo: bộ đếm atomic nhanh hơn mutex ~3 lần khi một luồng, nhưng cả mutex/atomic/CAS đều TỤT thông lượng khi thêm luồng (atomic 446 xuống 63 triệu op/s) vì cùng tranh một dòng cache — không cái nào scale; chỉ bộ đếm riêng từng luồng (shard) mới scale LÊN (679 lên 2.626, ~42 lần atomic ở 8 luồng); và CAS thua fetch_add khi bị tranh vì phải thử lại. Nút thắt của một bộ đếm nóng không phải khóa hay nguyên tử, mà là sự chia sẻ; gỡ chia sẻ mới là tối ưu thật.

Thử ba mươi giây

Trong ngôn ngữ của bạn, tìm chỗ nhiều luồng cùng tăng một biến đếm — AtomicLong (Java), atomic<int> (C++), sync/atomic (Go), Interlocked (C#). Nếu đó là đường nóng và bạn thấy nó thành nút thắt dưới tải, thử phiên bản shard: nhiều ngôn ngữ có sẵn (LongAdder của Java thay AtomicLong chính là bộ đếm sharded, nhanh hơn hẳn khi tranh chấp cao). Đo hai bản dưới nhiều luồng bằng cách chia tổng thao tác cho thời gian: bạn sẽ thấy AtomicLong tụt còn LongAdder giữ tốc độ khi thêm luồng, đúng khác biệt bài này đo. Và nhớ: một bộ đếm nhanh khi một luồng chưa chắc nhanh khi tám luồng — luôn đo ở đúng mức song song thật.