Hình dung hai đồng nghiệp, mỗi người có một khay thư đi (outbox) trên bàn mình. Khi viết xong một tờ ghi chú, họ thả vào khay của mình rồi làm việc tiếp ngay — nhưng tờ ghi chú chưa tới cái bảng tin chung của phòng, nó còn nằm trong khay một lúc. Bây giờ cả hai cùng viết một tờ, thả vào khay riêng, rồi ngay lập tức quay ra liếc bảng tin chung xem người kia đã dán gì chưa. Kết quả trớ trêu: cả hai đều không thấy tờ của người kia — vì cả hai tờ còn kẹt trong khay đi, chưa lên bảng. Một trạng thái mà lẽ thường bảo "không thể" (ít nhất một người phải thấy tờ kia chứ). CPU hiện đại có đúng cái "khay thư đi" đó — gọi là store buffer — và cái trạng thái "không thể" ấy xảy ra thật.
Ở 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.
Đệ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 (khay thư đ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 == 0 và r2 == 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 — đúng cảnh hai người liếc bảng mà không ai thấy tờ của người kia.
Đ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 (hand-carry tờ ghi chú lên bảng trước khi liếc), khôi phục thứ tự.
(Môi trường đo: container gcc:13 trên host ARM AArch64 — kiến trúc yếu thứ tự; atomic relaxed cho bản không rào, atomic_thread_fence(seq_cst) → dmb ish cho bản có rào; 12×5 triệu vòng. x86 (TSO, mạnh thứ tự) gần như không lộ ca này — nên con số reorder phụ thuộc kiến trúc, nhưng bài học "phải có rào" thì phổ quát.)
Hai niềm tin đối nghịch, cùng sai
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 lật tan điều đó — và cái niềm tin đối nghịch, "à thì có thật nhưng hiếm vậy chắc không sao", còn nguy hiểm hơn.
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, chứ không phải điều trấn an. 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.
Hai niềm tin gặp nhau ở một sự thật: "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ề đến code hằng ngày
Điều đầ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 đó.
Điều 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. Chỉ atomic/rào mới chặn cả hai.
Chỗ này cái khác biệt giữa các kiến trúc và ngôn ngữ đáng nhớ, vì nó là nguồn của một lớp bug "chạy máy tôi thì đúng". x86 là kiến trúc mạnh thứ tự (TSO) — gần như không lộ ca store-buffer này, nên code đồng bộ sai vẫn chạy đúng trên laptop x86 của bạn; ARM (điện thoại, Mac Apple Silicon, server AWS Graviton) và POWER yếu thứ tự thì lộ. Đây đúng là cái bẫy của double-checked locking: test trên x86 thấy hoàn hảo, deploy lên ARM thì hỏng. Các ngôn ngữ giải bài này bằng một mô hình bộ nhớ rõ ràng: Java Memory Model định nghĩa quan hệ happens-before — một ghi volatile rồi một đọc volatile cùng biến tạo ra rào cả compiler lẫn phần cứng, nên ví dụ "đặt data rồi set cờ volatile" an toàn trong Java (khác volatile của C chỉ chặn compiler); C++11 cho std::atomic với memory_order. Và công cụ cứu bạn khỏi cái test-không-bắt-được này là race detector: Go có -race, C/C++ có ThreadSanitizer (-fsanitize=thread) — chúng không dựa vào xác suất reorder xảy ra, mà phát hiện hai luồng truy cập chung không đồng bộ ngay cả khi lần chạy đó tình cờ đúng, bắt đúng lớp bug mà chạy-thử-triệu-lần bỏ sót. Mẫu số chung: đừng tin mắt thường trên một kiến trúc, hãy tin mô hình bộ nhớ và chạy race detector.
Muốn tự thấm thì không cần chạy litmus test đầy đủ — chỉ cần một thí nghiệm tưởng tượng. 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 ready và data 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. 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.