Hình dung ba cách gửi một bưu kiện. Gửi thường: quăng lên băng chuyền, không ai đảm bảo nó ra trước hay sau kiện khác, cũng chẳng chắc bạn đã đóng gói xong khi nó đi — nhanh, nhưng không hứa gì. Gửi có niêm phong: bưu cục chỉ dán tem "đã niêm" sau khi bạn đóng gói đủ, và người nhận thấy tem thì tin chắc bên trong đã đầy đủ — một chiều đảm bảo hai đầu khớp nhau. Gửi có theo dõi toàn trình: mọi bưu cục ghi chung một sổ vận đơn, ai cũng thấy cùng một thứ tự. Ba cách đó chính là ba mức memory ordering của atomic — relaxed, acquire/release, seq_cst. Và câu hỏi tôi mang đi đo: cái "niêm phong" và "theo dõi toàn trình" đó có thật sự đắt hơn gửi thường không?
Khi dùng biến atomic, bạn phải chọn một mức thứ tự bộ nhớ (memory order): relaxed, acquire/release, hay seq_cst. Chúng khác nhau ở việc đảm bảo thứ tự các thao tác bộ nhớ khác quanh atomic đó — và người ta thường dạy rằng seq_cst (mặc định, an toàn nhất) đắt hơn relaxed (yếu nhất), nên "chọn relaxed để đi nhanh". Tôi vào đo trên ARM để xem cái giá thật, và phát hiện lời khuyên đó dựa trên một giả định sai về phần cứng hiện đại.
Ba mức thứ tự, và ARM là kiến trúc yếu
relaxed chỉ đảm bảo thao tác nguyên tử, không đảm bảo gì về thứ tự với các đọc/ghi khác — gửi thường. acquire/release đồng bộ một chiều: một store release "công bố" mọi ghi trước nó (niêm phong sau khi đóng đủ), một load acquire "nhìn thấy" chúng (tem niêm = hàng đủ) — nền tảng của mẫu producer-consumer. seq_cst mạnh nhất: mọi luồng thấy các thao tác seq_cst theo cùng một thứ tự toàn cục (sổ vận đơn chung).
ARM là kiến trúc yếu thứ tự (weakly-ordered) — mặc định CPU được sắp lại các truy cập bộ nhớ khá tự do — nên trên ARM các mức này thật sự sinh ra lệnh máy khác nhau (khác với x86 vốn mạnh thứ tự sẵn, phần lớn ordering là miễn phí). Đây là lý do tôi đo trên ARM: để thấy sự khác biệt phần cứng thật. Tôi đọc assembly từng mức, rồi bấm giờ.
Đo: lệnh khác nhau, thời gian gần như giống
objdump cho thấy các mức sinh lệnh khác nhau:
store relaxed : str load relaxed : ldr fetch_add relaxed : ldadd (relax)
store release : stlr load acquire : ldar fetch_add seq_cst : ldadd (acq_rel)
store seq_cst : stlr load seq_cst : ldar
Điều quan trọng: relaxed dùng lệnh thường (str/ldr/ldadd), còn acquire/release/seq_cst dùng các lệnh có thứ tự sẵn của ARMv8 — stlr (store-release), ldar (load-acquire), ldaddal (rmw acquire-release). Đáng chú ý: seq_cst cho một load/store dùng chính ldar/stlr — không phải một rào dmb ish nặng nề như nhiều người tưởng (bưu cục niêm phong ngay trong động tác nhận thư, không cần một chuyến đi riêng ra kho). Bây giờ đo thời gian (một luồng, không tranh chấp):
store relaxed/release/seq_cst : 0,676 / 0,677 / 0,676 ns
load relaxed/acquire/seq_cst : 0,681 / 0,687 / 0,687 ns
fetch_add relaxed / seq_cst : 1,593 / 1,593 ns
Chênh lệch ~1% — gần như bằng nhau. Dù lệnh khác nhau, stlr/ldar/ldaddal có độ trễ gần y hệt str/ldr/ldadd trên lõi này, vì khi không có gì thật sự cần sắp thứ tự (một luồng), lệnh có-thứ-tự chẳng phải chờ gì. Và khi tôi đo có tranh chấp (nhiều luồng cùng fetch_add):
T=2: relaxed 3,8 | seq_cst 2,9 T=8: relaxed 19,2 | seq_cst 19,1
seq_cst không chậm hơn relaxed — cả hai bị cache-line bouncing chi phối, và mức thứ tự thêm vào gần như không đáng kể so với cái giá cache đó.
(Môi trường đo: container gcc:13 trên host ARM AArch64 (ARMv8, yếu thứ tự), kiểm lệnh sinh ra bằng objdump, một luồng không tranh chấp + nhiều luồng có tranh chấp. Con số phụ thuộc kiến trúc — xem lưu ý khả chuyển bên dưới — nhưng hình dạng bền vững: các mức sinh lệnh khác nhau mà thời gian gần bằng nhau.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin phổ biến: "seq_cst đắt hơn relaxed nhiều, nên hạ xuống relaxed (hoặc acquire/release) ở đường nóng để đi nhanh". Đo trên ARMv8 cho thấy khác biệt tốc độ rất nhỏ — một luồng chênh 1%, có tranh chấp thì seq_cst thậm chí không chậm hơn. Vì sao? Vì ARMv8 có sẵn các lệnh ldar/stlr/ldaddal thực thi acquire/release/seq_cst ngay trong lệnh load/store/rmw, không cần một rào dmb ish riêng. Cái dmb ish độc lập — thứ mà bài rào bộ nhớ đo là đắt — chỉ xuất hiện với atomic_thread_fence độc lập, không phải với ordering trên một thao tác atomic.
Niềm tin đối nghịch cũng sai theo chiều kia: "vậy cứ relaxed cho mọi thứ vì nó nhanh nhất và không mất gì". relaxed không đảm bảo thứ tự; nếu bạn dùng nó ở chỗ cần acquire/release (ví dụ công bố một con trỏ dữ liệu qua một cờ), chương trình sẽ chạy đúng phần lớn thời gian rồi thỉnh thoảng thấy dữ liệu chưa kịp ghi (mở kiện ra thấy đóng gói dở) — một bug đua tranh cực khó lần. Bạn đánh đổi một correctness quý giá để lấy 1% tốc độ không có thật.
Hai niềm tin gặp nhau ở một bài học: đo cả cái giá lẫn cái được trước khi tối ưu. Ở đây cái được (tốc độ) gần như bằng không, còn cái mất (đúng đắn) là rất lớn. Nên trên ARM, hãy chọn mức thứ tự cho ĐÚNG NGỮ NGHĨA thuật toán, không phải cho tốc độ — và seq_cst mặc định gần như miễn phí, nên "cứ seq_cst cho chắc" thật ra là một mặc định hợp lý cho từng thao tác atomic.
Một lưu ý về tính khả chuyển
Con số "gần như miễn phí" ở trên là của ARMv8, và không tự động đúng ở mọi kiến trúc — đúng tinh thần đo ở đúng phần cứng. Trên x86, phần lớn ordering cũng miễn phí (x86 mạnh thứ tự sẵn: mọi load là acquire, mọi store là release một cách ngầm định), nhưng có một ngoại lệ nổi tiếng: một store seq_cst trên x86 cần một lệnh mfence (hoặc xchg) đắt, trong khi trên ARM nó chỉ là stlr. Nghĩa là bức tranh chi phí đảo ngược một phần giữa hai kiến trúc: chỗ ARM rẻ có thể x86 đắt và ngược lại. Bài học kép: (1) đừng khái quát một con số ordering từ máy này sang máy khác, và (2) dù chi phí khác nhau, kết luận thực dụng vẫn giống nhau — chọn ordering cho đúng, vì cả hai kiến trúc đều làm cái đúng đủ rẻ để không phải hy sinh nó. Nếu thật sự cần vắt kiệt, hãy đo trên chính phần cứng đích, đừng đoán.
Về đến code hằng ngày
Điều đầu tiên: chọn mức thứ tự theo ý nghĩa cần, không theo "yếu hơn thì nhanh hơn". Cần công bố dữ liệu qua một cờ → release khi đặt cờ, acquire khi đọc cờ. Cần một tổng nguyên tử không phụ thuộc thứ tự với gì khác (như bộ đếm thống kê) → relaxed là đúng (không phải để nhanh, mà vì nó đủ). Đừng dùng relaxed ở chỗ cần đồng bộ chỉ vì nghe nói nó nhanh — trên ARM nó không nhanh hơn đáng kể, và sai thì rất khó gỡ.
Điều thứ hai: cái đắt thật là rào độc lập, không phải ordering trên atomic. Nếu bạn thấy dmb ish/mfence trong đường nóng (từ atomic_thread_fence hay volatile sai chỗ), đó mới là nơi đáng tối ưu. Ordering gắn liền trên một load/store/fetch_add thì gần như đi kèm miễn phí trên phần cứng có ldar/stlr.
Chỗ này các ngôn ngữ chia thành hai trường phái, và biết vậy giúp bạn dùng đúng công cụ. C++11 phơi toàn bộ std::memory_order (relaxed/acquire/release/acq_rel/seq_cst) — mạnh mẽ nhưng dễ tự bắn chân, và ngay cả các chuyên gia cũng khuyên "mặc định seq_cst, chỉ hạ khi profiling chứng minh có lợi" — đúng cái tôi đo. Java từ 9 cho VarHandle với getAcquire/setRelease/getVolatile/setVolatile (và getPlain cho relaxed), ánh xạ thẳng các mức này; volatile của Java xưa nay chính là seq_cst cho một biến. Rust có std::sync::atomic::Ordering giống hệt C++. Nhưng thú vị nhất là Go: nó cố ý chỉ cho atomic tuần tự-nhất-quán (seq_cst), không phơi relaxed/acquire/release — nhóm thiết kế Go quyết định cắt hẳn cái footgun này, chấp nhận đôi khi hơi chậm hơn để đổi lấy "không ai dùng sai mức". Đó là hiện thân của đúng kết luận tôi đo: cái đúng đủ rẻ để mặc định, nên nhiều ngôn ngữ đơn giản chỉ cho bạn cái đúng. Mẫu số chung: chọn ordering là quyết định đúng đắn, không phải tốc độ — và khi nghi ngờ, mức mạnh (seq_cst) là mặc định an toàn gần như miễn phí.
Muốn tự thấy thì viết ba hàm: atomic_store_explicit(&x, v, memory_order_relaxed), ..._release, và ..._seq_cst; biên dịch gcc -O2 -S và so assembly. Trên ARM bạn sẽ thấy relaxed cho str, còn release và seq_cst cho stlr — lệnh khác nhau thật. Rồi bấm giờ mỗi hàm trong một vòng lớn: bạn sẽ thấy chúng gần như bằng nhau. Vài phút đó phá tan một huyền thoại phổ biến — rằng "seq_cst chậm, relaxed nhanh" — ít nhất trên ARM hiện đại, và nhắc bạn rằng lựa chọn memory ordering là một quyết định về tính đúng đắn, nơi đồng hồ nói cho bạn biết bạn không phải trả gì để làm đúng.