Hãy coi trình biên dịch là một nhà thầu cực kỳ máy móc: nó làm đúng những gì hợp đồng (đặc tả ngôn ngữ) cho phép, không hơn một ly. Nếu hợp đồng không ghi "cái sổ cái chung này phải được cập nhật nguyên khối, và bảng thông báo kia phải đọc lại mỗi lần", thì nhà thầu được tự do tối ưu theo cách làm hỏng ý bạn — và tệ hơn: nếu bạn để hai luồng giành nhau một biến mà không đồng bộ, bạn đã xé hợp đồng, và lúc đó nhà thầu được phép làm bất cứ điều gì. Đó chính là memory model của C, và 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.

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. 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) — đúng anh thư ký đọc số dư sổ cái, cộng, ghi lại — 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(;;);. Đúng anh nhân viên được bảo "cứ nhìn bảng thông báo tới khi thấy chữ 'xong'" nhưng vì lười, liếc bảng một lần, nhớ "chưa xong", rồi lặp mãi không ngó lại. 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 cũng vô ích. Đổ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ôi trường đo: container gcc:13 trên host ARM AArch64, -O2, kiểm cả mã máy sinh ra. Hành vi cụ thể (treo/mất bao nhiêu %) đổi theo trình biên dịch và mức -O; điều bền vững là: non-atomic KHÔNG được đảm bảo, atomic thì có.)

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với hai niềm tin phổ biến. 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". 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). Phép đo đập tan cả hai — nhưng đường tới đó còn dạy tôi một bài về chính việc đo.

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ả: 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 ép mỗi phép tăng phải xảy ra rời — bằng volatile long — cuộc đua thật mới lộ: mất ~50%. Và đây là chỗ niềm tin thứ hai sụp: volatile chặn được gập và hoist (nên nó cứu được vòng lặp cờ khỏi 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 "đừng bỏ/gộp lần truy cập này" (đúng cho thanh ghi phần cứng, MMIO); 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. Vậy hai niềm tin ngược nhau — "biến thường cũng ổn" và "volatile là đủ" — gặp nhau ở một sự thật: đ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ì. 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.

Về đến code hằng ngày

Điều đầ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.

Điều 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, 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 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>.

Chỗ này có một cái bẫy khái niệm liên ngôn ngữ đáng gọi tên, vì nó gây nhầm lẫn triền miên: volatile của Java KHÁC volatile của C. volatile trong Java (và C#) có cho đảm bảo hiển thị + thứ tự (happens-before) — nên một cờ volatile boolean ready trong Java sẽ không bị treo như ví dụ C ở trên. Nhưng ngay cả Java volatile cũng không làm count++ nguyên tử (vẫn cần AtomicInteger/AtomicLong) — nên bài học "volatile không thay atomic cho phép đọc-sửa-ghi" đúng ở cả hai ngôn ngữ, chỉ khác ở phần hiển thị. Đây là lý do Java có cả java.util.concurrent.atomic và cả Java Memory Model với happens-before. Và Go giải bài "làm sao bắt được data race" bằng công cụ tuyệt vời: chạy với cờ -race, runtime tự phát hiện hai luồng đua trên một biến và in ra stack — thứ mà C/C++ phải nhờ ThreadSanitizer (-fsanitize=thread) mới có. Mẫu số chung: mọi ngôn ngữ hiện đại đều có một hợp đồng bộ nhớ, và "chạy thử thấy đúng" không bao giờ là bằng chứng tuân hợp đồng.

Muốn tự thấy thì 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 (trong C) — 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. Vài phút rà đó — hoặc chạy dưới -fsanitize=thread / -race — 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.