Phần 3 cho thấy CPU chạy nhiều lệnh cùng lúc và sắp xếp lại (reorder) chúng để nhanh hơn. Việc reorder ấy chạm tới cả lệnh đọc/ghi bộ nhớ — và khi nhiều luồng chia sẻ dữ liệu, thứ tự các thao tác nhớ trở nên quan trọng. C++ cho bạn điều khiển nó qua memory_order trên atomic<T>: từ relaxed (chỉ đảm bảo nguyên tử, không ràng buộc thứ tự) tới seq_cst (thứ tự toàn cục, chặn reorder mạnh nhất). Trực giác phổ biến — và mọi bài viết tôi từng đọc — nói: seq_cst chèn rào cản (barrier/fence) đắt, nên hạ xuống relaxed để code nhanh hơn. Tôi vào đo chi phí đó trong container gcc:13 trên host ARM, một luồng để cô lập giá của rào cản thuần, rồi nhiều luồng. Con số phá tan trực giác đó — theo một hướng bất ngờ.

memory_order và rào cản bộ nhớ

Reorder, rào cản, và các mức memory_order

CPU và trình biên dịch được phép đổi thứ tự các lệnh đọc/ghi bộ nhớ, miễn kết quả trên một luồng không đổi. Nhưng luồng khác có thể quan sát thấy thứ tự bị đảo — nguồn gốc của vô số lỗi đồng thời. memory_order là cách bạn nói cho phần cứng biết được reorder tới đâu:

  • relaxed: chỉ đảm bảo thao tác nguyên tử (không bị xé nửa), không ràng buộc thứ tự với thao tác khác. Rẻ nhất về mặt ngữ nghĩa.
  • acquire/release: chặn reorder một chiều — đọc sau một acquire không được nhảy lên trước nó; ghi trước một release không được tụt xuống sau. Đủ cho hầu hết mẫu khóa/hàng đợi.
  • seq_cst: mạnh nhất — mọi luồng thấy cùng một thứ tự toàn cục. Đây là mặc định của atomic, và trên x86 nó thường chèn mfence hoặc xchg — một full barrier đắt.

Chữ "trên x86" quan trọng. x86 dùng mô hình bộ nhớ mạnh (TSO): hầu như không reorder, nên relaxed gần như miễn phí còn seq_cst phải thêm rào cản rõ rệt. ARM thì ngược lại — mô hình yếu (weak memory), reorder thoải mái, nhưng bù lại nó có lệnh riêng mang sẵn ngữ nghĩa thứ tự: ldar (load-acquire), stlr (store-release), và với ARMv8.1 LSE là ldadd/ldaddal (atomic add có/không rào). Câu hỏi đo: trên ARM, các mức memory_order chênh nhau bao nhiêu?

Đo: một luồng, cô lập chi phí rào cản

Tôi làm 50 triệu thao tác atomic trên một biến, một luồng (không tranh chấp), lấy min nhiều lần, so các memory_order:

1 luồng, host ARM (LSE), g++ -O2, ns/thao tác:

   thao tác            | relaxed | seq_cst
   --------------------|---------|--------
   fetch_add (RMW)     |  1,59   |  1,59
   store               |  0,22   |  0,22
   load                |  0,22   |  0,22
   ghi thường (không atomic) : 0,22
   ghi thường + 1 dmb (full fence) : 0,45

Kết quả gần như phản trực giác hoàn toàn: fetch_add với relaxed, release, acq_rel, seq_cst đều 1,59 nskhông chênh nổi một phần nghìn giây. storeload cũng vậy: relaxed bằng seq_cst, đều 0,22 ns. Xem assembly xác nhận trình biên dịch đúng là sinh lệnh khác nhau — str cho relaxed vs stlr cho release, ldr vs ldar, ldadd vs ldaddal — nhưng trên lõi này chúng chạy nhanh như nhau. Ngữ nghĩa thứ tự được nướng sẵn vào lệnh với chi phí gần bằng 0. "seq_cst đắt" là trực giác của x86, không đúng ở đây.

Nhưng rào cản không phải luôn miễn phí: một atomic_thread_fence(seq_cst) — dịch thành lệnh dmb ish đứng riêng — làm một vòng ghi thường từ 0,22 ns lên 0,45 ns, tức là gấp đôi. Full barrier độc lập giá đo được (~vài chu kỳ). Chỉ là khi thứ tự đã gắn liền trong ldar/stlr/ldadd, bạn không phải trả thêm cái dmb riêng đó.

Và cái thật sự đắt ngay cả một luồng: tính atomic. fetch_add (một read-modify-write nguyên tử) tốn 1,59 ns, còn một phép ghi thường chỉ 0,22 ns — gấp ~7 lần. Giá bạn trả khi dùng atomic nằm ở chỗ nó nguyên tử, không phải ở nhãn memory_order.

Đo: nhiều luồng, tranh chấp mới là kẻ ngốn thời gian

Chi phí lớn nhất chỉ lộ ra khi nhiều luồng đụng cùng một biến. Tôi cho T luồng cùng fetch_add vào một atomic chung, so với mỗi luồng một atomic riêng (căn theo dòng cache để không chia sẻ):

fetch_add, ns/thao tác (mỗi luồng 5 triệu op), host ARM:

   T luồng | chung 1 atomic (relaxed) | chung (seq_cst) | mỗi luồng ô riêng
   --------|--------------------------|-----------------|-------------------
      1    |         1,63             |      1,62       |      1,63
      2    |         3,07             |      3,03       |      0,82
      4    |         5,87             |      5,86       |      0,46
      8    |        16,27             |     16,21       |      0,27

Cột chung: càng nhiều luồng đập vào một atomic, mỗi thao tác càng chậm — 1,6 ns (1 luồng) leo tới 16,3 ns (8 luồng), chậm gấp 10 lần. Dòng cache chứa biến đó phải nảy qua lại giữa các lõi (cache coherence), mỗi lần chuyển chủ tốn hàng chục-trăm chu kỳ. Đây là tranh chấp (contention) — và nó không hề song song hóa: 8 luồng làm việc chậm hơn tổng cộng so với 1 luồng.

Cột ô riêng: cùng số thao tác, nhưng mỗi luồng một atomic riêng nằm ở dòng cache khác nhau — 0,82, 0,46, rồi 0,27 ns ở 8 luồng. Nó scale gần như hoàn hảo vì không có dòng cache nào phải chia sẻ. So sánh 8 luồng: chung 16,3 ns vs riêng 0,27 ns — chênh ~60 lần, cùng y hệt số phép fetch_add. Và một lần nữa: relaxed vs seq_cst khi tranh chấp vẫn bằng nhau (16,27 vs 16,21). Cái đắt là chia sẻ dòng cache, không phải memory_order.

Một lần tôi đo hớ: "seq_cst đắt, hạ memory_order là nhanh" và "atomic/barrier gần như miễn phí"

Tôi vào đo với niềm tin sách vở: "seq_cst chèn full barrier đắt, cứ hạ xuống relaxed/acquire là code nhanh hơn". Đo phá tan: trên host ARM này, relaxedseq_cst chênh nhau đúng 0 ns đo được — cả một luồng lẫn tám luồng, cả fetch_add, store, load. Lý do là mô hình bộ nhớ yếu của ARM đi kèm lệnh mang sẵn thứ tự (ldar/stlr/ldaddal) chạy nhanh ngang bản không thứ tự. Trực giác "seq_cst đắt" là của x86 (TSO cần mfence), tôi đã mang nhầm nó sang ARM. Nếu tôi tối ưu bằng cách hạ memory_order trên nền ARM, tôi đánh đổi tính đúng đắn lấy con số 0 — hạ ordering không nhanh hơn, mà lại dễ sinh lỗi đồng thời.

Nhưng đo cũng phá một niềm tin ngược: "atomic và barrier chỉ là chuyện lý thuyết, gần như miễn phí, dùng thoải mái". Sai theo hai hướng cụ thể. Thứ nhất, tính atomic tự nó đắt: một fetch_add tốn ~7 lần một phép ghi thường (1,59 vs 0,22 ns), vì nó là read-modify-write nguyên tử. Thứ hai, và lớn hơn nhiều, tranh chấp đắt khủng khiếp: 8 luồng đập chung một atomic chậm gấp 60 lần 8 ô riêng (16,3 vs 0,27 ns) vì dòng cache nảy giữa các lõi. Và một dmb full fence đứng riêng cũng làm gấp đôi một vòng ghi. Barrier/atomic không miễn phí — chỉ là chi phí nằm ở nguyên tửchia sẻ, không ở nhãn thứ tự.

Bài học đo lường: memory_order (relaxed↔seq_cst) trên ARM host này chênh 0 ns đo được (cả 1 luồng lẫn 8 luồng) — ordering nằm sẵn trong lệnh ldar/stlr/ldadd, khác x86 (TSO cần mfence, seq_cst đắt thật). Cái ĐẮT: (1) tính atomic — fetch_add ~7x ghi thường (1,59 vs 0,22 ns); (2) TRANH CHẤP — 8 luồng chung 1 atomic 16,3 ns vs 8 ô riêng 0,27 ns = ~60x (dòng cache nảy giữa lõi); (3) dmb full fence riêng +0,22 ns. 'Hạ memory_order là nhanh' SAI trên ARM; 'atomic/barrier miễn phí' cũng SAI. Nếu tin "seq_cst đắt" tôi hạ ordering vô ích và rước lỗi; nếu tin "atomic miễn phí" tôi để nhiều luồng đập chung một biến và chết vì tranh chấp.

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

Hệ quả đầu tiên: tránh chia sẻ trước khi lo memory_order. Con số đắt nhất trong bài là 60 lần — đến từ nhiều luồng cùng ghi một dòng cache, không từ nhãn thứ tự. Nếu bạn có bộ đếm/tổng cộng dồn qua nhiều luồng, dùng biến riêng mỗi luồng rồi gộp cuối, hoặc căn đệm (padding) để mỗi biến một dòng cache — đây chính là chống false sharing. Đó là tối ưu có thật gấp chục lần; hạ memory_order thì không.

Hệ quả thứ hai: chọn memory_order cho đúng, không phải cho nhanh. Trên ARM, hạ xuống relaxed gần như không cho tốc độ (đo 0 ns chênh) nhưng có thể phá tính đúng đắn nếu bạn cần thứ tự. Hãy dùng acquire/release khi ngữ nghĩa đòi hỏi (khóa, hàng đợi, cờ sẵn sàng), relaxed chỉ khi thật sự không cần thứ tự (bộ đếm thống kê). Trên x86 thì seq_cst đắt hơn — nên "đúng trước, đo sau, và đo trên máy đích", vì kết luận này đảo giữa hai kiến trúc.

Hệ quả thứ ba là tinh thần đo lường: chi phí đồng bộ nằm ở nguyên tử và tranh chấp, không ở nhãn thứ tự — và điều này phụ thuộc kiến trúc. Con số mang theo: relaxed=seq_cst trên ARM (0 ns chênh, ordering trong lệnh); atomic RMW ~7x ghi thường; tranh chấp 8 luồng ~60x; x86 thì seq_cst đắt hơn. Tránh chia sẻ (đếm riêng, padding) là đòn bẩy lớn; chọn ordering theo tính đúng. Cùng một dòng code atomic, nhưng có bao nhiêu luồng đụng nó mới quyết định nhanh chậm — đo mới thấy điều mà nhãn memory_order che đi.

Thử ba mươi giây

Viết một vòng lặp làm hàng chục triệu fetch_add lên một atomic<long>, một luồng, bấm giờ bằng min nhiều lần. Chạy bốn lần với memory_order_relaxed, release, acq_rel, seq_cst — trên ARM bạn sẽ thấy bốn con số gần như y hệt (trên x86 seq_cst sẽ nhỉnh hơn). Rồi so fetch_add với một phép ghi thường vào biến volatile: atomic chậm gấp mấy lần — đó là giá của nguyên tử, không phải của thứ tự. Cuối cùng, mở tám luồng cùng fetch_add vào một biến chung, rồi làm lại với mỗi luồng một biến riêng căn theo dòng cache (alignas(128)): bản chung chậm hàng chục lần vì dòng cache nảy giữa các lõi, bản riêng scale gần như hoàn hảo. Ba mươi giây đó cho bạn thấy điều mà "hạ memory_order cho nhanh" giấu đi: trên phần cứng bộ nhớ yếu như ARM, nhãn thứ tự gần như miễn phí, còn thứ thật sự đốt thời gian là chia sẻ một ô nhớ giữa các lõi — nên hãy tránh chia sẻ trước, rồi mới bàn tới thứ tự.