bài trước ta thấy các mức memory ordering của atomic. Nhưng vì sao ta cần ordering ngay từ đầu? Vì CPU hiện đại — đặc biệt ARM, kiến trúc yếu thứ tự — không thực thi các thao tác bộ nhớ đúng thứ tự bạn viết. Nó có đệm ghi (store buffer) và chạy out-of-order, nên một luồng có thể thấy các ghi của luồng khác lộn thứ tự. Nhiều người cho rằng đây chỉ là lý thuyết trong sách. Tôi đo nó — thật sự đếm số lần reorder xảy ra — và nó không phải lý thuyết.

Rào bộ nhớ phần cứng

Đệm ghi và bài kiểm chứng store-buffer

Khi một lõi CPU ghi vào bộ nhớ, giá trị không đi thẳng ra bộ nhớ chung ngay — nó vào một store buffer riêng của lõi để lõi chạy tiếp không phải chờ. Nghĩa là một lúc, ghi của lõi A đã "xong" với A nhưng chưa hiện cho lõi B. Hệ quả kinh điển là bài kiểm chứng store-buffer (litmus test):

Luồng A:  X = 1;  r1 = Y;
Luồng B:  Y = 1;  r2 = X;

Nếu CPU chạy tuần tự, ít nhất một trong hai store phải hiện ra trước load của luồng kia — nên không thể cả r1 == 0r2 == 0. Nhưng với store buffer: A ghi X vào buffer (chưa hiện cho B) rồi đọc Y (thấy 0, vì B chưa ghi); đồng thời B ghi Y vào buffer rồi đọc X (thấy 0, vì X còn trong buffer của A). Kết quả cả hai đều 0 — một trạng thái mà mô hình tuần tự cho là bất khả.

Đo: 11 ca thật trong 60 triệu vòng

Tôi dựng bài litmus này với hai luồng trong container gcc:13 (ARM AArch64), dùng atomic relaxed (không rào), đồng bộ hai luồng chạy song song mỗi vòng, và đếm số lần r1 == 0 && r2 == 0. Chạy 12 lần, mỗi lần 5 triệu vòng:

KHÔNG rào (60 triệu vòng)  : 11 ca (r1=0 && r2=0)   -> ~1 trên 5,5 triệu vòng
CÓ dmb ish (fence seq_cst) : 0 ca                    -> luôn 0

Reorder xảy ra thật — 11 ca của trạng thái "bất khả" được đếm trực tiếp, chứng minh phần cứng ARM thật sự sắp lại thứ tự bộ nhớ giữa các lõi. Rồi tôi thêm một rào phần cứng giữa store và load ở mỗi luồng — atomic_thread_fence(memory_order_seq_cst), mà objdump xác nhận sinh ra lệnh dmb ish (data memory barrier) — và số ca reorder về 0, luôn luôn. Rào ép store buffer phải xả ra trước khi load chạy, khôi phục thứ tự.

Một lần tôi đo hớ: reorder có thật, nhưng hiếm đến mức chết người

Tôi vào đo với niềm tin ngầm của rất nhiều lập trình viên: "CPU chạy theo thứ tự mình viết; chuyện reorder bộ nhớ chỉ là thứ trong sách giáo khoa, thực tế không gặp". Đo phá tan điều đó theo hai chiều, và chiều thứ hai mới đáng sợ.

Chiều thứ nhất: reorder có thật — 11 ca đếm được. Không phải lý thuyết. Chiều thứ hai, cú lật thật sự: nó hiếm khủng khiếp — khoảng 1 trên 5,5 triệu vòng. Và chính sự hiếm đó là cái bẫy chết người. Một đoạn code đồng thời sai (thiếu rào/atomic đúng) sẽ chạy đúng trong mọi lần bạn test — hàng nghìn, hàng triệu lần — rồi thỉnh thoảng, một lần trong vài triệu, ở production dưới tải thật, nó reorder và cho kết quả sai: một con trỏ đọc trước khi dữ liệu kịp ghi, một cờ thấy "sẵn sàng" trước khi thứ nó bảo vệ tồn tại. Bug này gần như không thể tái hiện, không thể debug bằng cách chạy lại.

Bài học đo lường: "chạy thử thấy đúng" không chứng minh code đồng thời đúng. Với code tuần tự, chạy vài lần đúng là bằng chứng khá tốt. Với code đồng thời trên phần cứng yếu thứ tự, một bug reorder ẩn qua được hàng triệu lần chạy — test của bạn không thể bắt nó bằng xác suất. Đây là lý do sâu xa vì sao ta không bao giờ được "tự chế" đồng bộ dựa vào thứ tự chương trình, mà phải dùng các nguyên hàm (atomic có ordering, mutex) do người ta đã chứng minh đúng.

Đo được cái hiếm này cũng khó

Chính việc quan sát được reorder đã là một bài học về đo lường, đúng tinh thần đo lường vi mô đúng cách. Lần đầu tôi đồng bộ hai luồng bằng một hàng rào (barrier) trước mỗi vòng để chúng chạy đồng loạt — nhưng chính cái barrier đó là một rào bộ nhớ đầy đủ, nên nó dập luôn reorder và tôi đếm được 0 ca, một kết quả sai gây hiểu lầm. Chỉ khi tôi dùng cách đồng bộ nhẹ hơn (không phải một fence) để hai luồng chồng lấn vùng store-load, store buffer mới có cửa sổ để reorder, và tôi bắt được 11 ca qua 60 triệu vòng. Bài học kép: (1) công cụ đo có thể che mất chính hiện tượng bạn muốn đo — một barrier để "đo cho sạch" lại xóa cái cần đo; và (2) một hiện tượng hiếm đòi hỏi rất nhiều mẫu (60 triệu vòng cho 11 ca) mới lộ ra, nên "chạy vài lần không thấy" không có nghĩa là không có. Cùng lý do khiến bug reorder khó bắt trong test cũng khiến nó khó đo trong phòng thí nghiệm.

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

Hệ quả đầu tiên: không bao giờ tin thứ tự chương trình giữa các luồng. Nếu luồng A ghi dữ liệu rồi đặt một cờ, và luồng B đọc cờ rồi dùng dữ liệu, bạn phải có ordering (release ở A khi đặt cờ, acquire ở B khi đọc cờ) — nếu không, B có thể thấy cờ "sẵn sàng" trước khi thấy dữ liệu. Đừng dựa vào "tôi ghi dữ liệu trước cờ trong code nên nó phải hiện ra theo thứ tự đó"; phần cứng không hứa điều đó.

Hệ quả thứ hai: dùng nguyên hàm đúng, đừng tự chế đồng bộ. atomic với ordering phù hợp, mutex, condition variable — chúng đã đặt sẵn rào ở đúng chỗ. Rào biên dịch và rào phần cứng là hai lớp khác nhau (compiler sắp lại lúc dịch, CPU sắp lại lúc chạy); một volatile chỉ chặn compiler, không chặn CPU — nên volatile không đủ cho đồng bộ đa luồng (như bài đó đã đo). Chỉ atomic/rào mới chặn cả hai.

Hệ quả thứ ba là tinh thần đo lường: một bug hiếm vẫn là bug, và test theo xác suất không bắt được nó. Con số mang theo: CPU ARM (yếu thứ tự) có store buffer nên reorder bộ nhớ giữa các lõi XẢY RA THẬT — đo litmus store-buffer đếm được 11 ca (r1=0 && r2=0, bất khả nếu tuần tự) trong 60 triệu vòng khi không rào, thêm rào dmb ish (atomic_thread_fence seq_cst) thì 0 ca; nhưng reorder chỉ ~1 trên 5,5 triệu vòng nên code sai vẫn qua mọi test rồi nổ ở production — KHÔNG được tin thứ tự chương trình, phải dùng atomic/rào cho đồng bộ. Sự hiếm không làm nó an toàn; nó làm nó nguy hiểm hơn.

Thử ba mươi giây

Bạn không cần chạy một litmus test đầy đủ để thấm bài học — chỉ cần một thí nghiệm tưởng tượng ba mươi giây. Hình dung hai luồng: một luồng làm data = 42; ready = 1;, luồng kia làm while(!ready){} use(data);. Trên giấy, code này "đúng" — data được đặt trước ready. Nhưng nếu readydata là biến thường (không atomic), phần cứng yếu thứ tự có thể để luồng hai thấy ready == 1 trước khi thấy data == 42, và use(data) chạy với giá trị rác. Nó sẽ đúng gần như mọi lần bạn thử — rồi hỏng một lần trong triệu ở production. Ba mươi giây nghĩ về kịch bản đó dạy bạn điều mà cả một sê-ri đo lường củng cố: giữa các luồng, thứ tự bạn viết không phải thứ tự phần cứng đảm bảo — và bạn phải nói rõ điều mình cần bằng atomic hoặc rào, không được cho không.