Suốt sê-ri ta dùng atomic và acquire/release như công cụ, nhưng chưa lùi lại hỏi: ngôn ngữ C thật sự đảm bảo gì khi nhiều luồng chạm chung một biến, và không đảm bảo gì? Câu trả lời nằm ở "memory model" — bản hợp đồng giữa bạn và trình biên dịch. Hiểu sai nó là nguồn của những bug đồng thời tệ nhất: chạy đúng trong test, hỏng ngẫu nhiên ở production. Tôi đo ba tình huống trong container gcc:13 (ARM) để tách rõ ranh giới đảm bảo/không đảm bảo — và vấp đúng một cái bẫy đo lường ngay khi thử chứng minh.
Hợp đồng: đua tranh dữ liệu là UB
Trái tim của memory model C11 là một định nghĩa và một hình phạt. Đua tranh dữ liệu (data race): hai luồng truy cập cùng một ô nhớ, ít nhất một là ghi, và không có quan hệ đồng bộ nào giữa chúng. Hình phạt: hành vi không xác định (undefined behavior). Không phải "giá trị sai", mà là cả chương trình rơi vào cõi không định nghĩa — trình biên dịch được phép làm bất cứ gì, kể cả biến đổi code của bạn theo cách khiến bug xuất hiện hay biến mất tùy mức tối ưu.
Điều này nghe trừu tượng, nên hãy đo hai hệ quả cụ thể của "không đảm bảo", rồi đo cái mà atomic có đảm bảo.
Đo: hai thứ không được đảm bảo
Thứ nhất — mất cập nhật. Hai luồng cùng tăng một biến long chung, mỗi luồng 2 triệu lần. Vì x++ thật ra là ba thao tác rời (đọc, cộng, ghi), hai luồng xen kẽ sẽ ghi đè lẫn nhau:
kỳ vọng (2 × 2.000.000) : 4.000.000
volatile non-atomic (3 lần) : 2.097.378 / 2.000.000 / 2.015.240
-> mất ~48-50%, và KHÁC nhau mỗi lần chạy
atomic relaxed : 4.000.000 (khớp chính xác, mọi lần)
Biến non-atomic mất gần một nửa số cập nhật, và con số đổi mỗi lần chạy — dấu hiệu kinh điển của một cuộc đua không định trước. Đổi sang atomic_fetch_add (kể cả memory_order_relaxed) cho đúng 4.000.000 mọi lần: atomic đảm bảo mỗi phép cộng là nguyên tử — đọc-cộng-ghi không thể bị chen ngang.
Thứ hai — đọc bị đóng băng. Một luồng quay while(!ready){} trên một cờ int ready thường (không atomic, không volatile), luồng khác đặt ready=1 sau 100 ms:
cờ non-atomic, -O2 : TREO vĩnh viễn (timeout, exit 124)
cờ atomic acquire : THOÁT ngay khi thấy ready=1 (exit 0)
Cờ non-atomic khiến chương trình treo mãi. Vì sao? -O2 thấy vòng lặp không hề đổi ready, nên nó hoist (nâng) phép đọc ra ngoài vòng — đọc ready đúng một lần vào thanh ghi rồi lặp if(!ready) for(;;);. Kiểm chứng bằng cách xem mã máy: bên trong vòng lặp không còn một lần đọc ready nào. Luồng kia có đặt ready=1 trong bộ nhớ cũng vô ích — vòng lặp không bao giờ đọc lại. Đổi ready sang atomic_int với acquire thì chương trình thoát đúng như mong đợi: atomic buộc mỗi lần đọc là đọc thật từ bộ nhớ, và cấm hoist.
Một lần tôi đo hớ: -O2 giấu mất cuộc đua
Tôi vào đo với hai niềm tin phổ biến — và cả phương pháp đo cũng vấp. Niềm tin thứ nhất: "biến chung đọc/ghi bình thường thì vẫn chạy được, họa hoằn mới sai". Niềm tin thứ hai: "volatile là đủ cho đa luồng" (một hiểu lầm rất phổ biến từ thời Java cũ và C nhúng).
Cái vấp đầu tiên là đo lường: tôi định chứng minh mất cập nhật bằng một long plain thường, hai luồng plain++ 5 triệu lần. Kết quả đo: mất 0 cập nhật, đúng 10.000.000. Tưởng "à, hóa ra không đua". Sai — -O2 đã gập cả vòng for(i<5000000) plain++ thành một phép cộng duy nhất plain += 5000000. Mỗi luồng giờ chỉ làm một thao tác đọc-cộng-ghi, nên cửa sổ đua co lại gần bằng không và hai phép cộng lớn tình cờ không chồng lên nhau. Cuộc đua có thật (vẫn là UB), nhưng bản tối ưu đã giấu nó ngay trong microbenchmark của tôi — đúng cái bẫy -O2 gập vòng mà sê-ri đã cảnh báo, giờ quay lại cắn chính phép đo về nó.
Chỉ khi tôi ép mỗi phép tăng phải xảy ra rời — bằng volatile long — thì cuộc đua thật mới lộ ra: mất ~50%. Nhưng đây là chỗ hiểu lầm thứ hai bị đập tan: volatile chặn được gập và hoist (nên vòng lặp cờ dùng volatile sẽ không treo), nhưng nó không làm x++ trở thành nguyên tử — ba thao tác vẫn rời, hai luồng vẫn xen kẽ, vẫn mất một nửa. volatile không phải atomic. volatile nói với trình biên dịch "đừng bỏ/gộp lần truy cập này"; nó không nói gì về nguyên tử hay thứ tự bộ nhớ giữa các lõi. Chỉ atomic mới cho cả hai.
Bài học đo lường, hai tầng: (1) đua tranh dữ liệu là UB, và UB nghĩa là bug có thể ẩn dưới mức tối ưu này rồi hiện dưới mức khác — "chạy đúng trong test" không chứng minh gì; (2) cẩn thận cả khi đo UB, vì chính bản tối ưu có thể xóa mất hiện tượng bạn định chứng minh. Muốn thấy cuộc đua, phải cản trình biên dịch tối ưu nó đi mất; muốn sửa cuộc đua, phải dùng atomic chứ không phải volatile.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: mọi ô nhớ chia sẻ giữa các luồng, có ít nhất một luồng ghi, phải là atomic (hoặc được một mutex bảo vệ). Không có vùng xám "biến nhỏ chắc không sao". Đua tranh là UB, và UB không cho bạn một giá trị sai gọn gàng — nó cho trình biên dịch giấy phép gập, hoist, hoặc tệ hơn. Nếu hai luồng chạm chung, hoặc khóa nó, hoặc làm nó atomic.
Hệ quả thứ hai: đừng dùng volatile để đồng bộ luồng. volatile chỉ đảm bảo mỗi truy cập là một lần đọc/ghi bộ nhớ thật (đúng cho thanh ghi phần cứng, MMIO), nhưng không cho nguyên tử hay thứ tự giữa các lõi. Một cờ volatile có thể chống được hoist nhưng một volatile counter++ vẫn mất cập nhật như ta đo. Công cụ đúng là <stdatomic.h>.
Hệ quả thứ ba là tinh thần đo lường: hiểu ngôn ngữ đảm bảo gì, đừng suy từ "chạy thử thấy đúng". Con số mang theo: đua tranh dữ liệu non-atomic là UB — hai luồng tăng một biến chung mất ~50% cập nhật (khác mỗi lần chạy), và đọc cờ chung non-atomic bị -O2 HOIST ra ngoài vòng lặp thành TREO vĩnh viễn; atomic (kể cả relaxed) đảm bảo nguyên tử (đếm khớp 4tr), và acquire đảm bảo hiển thị (thoát khỏi vòng chờ); volatile chặn gập/hoist nhưng KHÔNG nguyên tử nên vẫn mất 50% — volatile ≠ atomic. Và cẩn thận: khi đo chính UB, -O2 có thể gập mất cuộc đua, khiến microbenchmark trông "an toàn" một cách giả tạo.
Thử ba mươi giây
Mở một đoạn code đa luồng bạn có và tìm mọi biến được đọc bởi một luồng và ghi bởi luồng khác. Với mỗi biến, hỏi: nó là atomic, hay được một mutex bao quanh mọi truy cập? Nếu câu trả lời là "không, nó là một int/bool/long thường" — kể cả khi có mỗi volatile — thì bạn có một cuộc đua dữ liệu, tức UB, và nó sẽ chạy đúng cho tới ngày trình biên dịch (hay một mức -O khác, hay một CPU khác) quyết định gập hay hoist nó đi. Ba mươi giây rà đó bắt được một lớp bug mà không lần chạy thử nào bắt được — vì với memory model, đúng nghĩa là theo đúng hợp đồng, không phải theo cái chạy được hôm nay dưới mức tối ưu này.