Hình dung một chiếc đồng hồ áp suất trên nồi hơi mà cái máy tự thay đổi liên tục. Bạn được lệnh: "mỗi lần cần biết áp suất, phải đi tới tận nơi nhìn — cấm nhớ số cũ rồi dùng lại". Nghe phiền, và đúng là chậm: thay vì liếc một lần rồi làm việc, bạn cứ phải chạy đi chạy lại. Nhưng quy định đó đúng, vì cái kim tự nhúc nhích do máy điều khiển, số cũ trong đầu bạn có thể sai bất cứ lúc nào. volatile trong C chính là quy định "cấm nhớ, phải đi nhìn lại mỗi lần" đó — và tôi từng tưởng nó là nút tăng tốc.

Suốt sê-ri này tôi đã dùng một biến volatile long SINK để chặn compiler xóa mất phép đo — nhưng chưa giải thích chính từ khóa đó làm gì. volatile báo compiler rằng một biến có thể đổi ngoài luồng thực thi nó thấy được — bởi phần cứng, một trình ngắt, hay một luồng khác — nên compiler không được tối ưu quanh nó như biến thường. Nhiều người tưởng volatile là một cờ "cho nhanh" hay "cho an toàn đa luồng". Tôi đo trong container gcc:13 (ARM), và cả hai đều sai — nó làm code chậm đi, và không an toàn luồng chút nào.

volatile: mỗi truy cập là bộ nhớ thật

Với biến thường, compiler được tự do: gộp các lần đọc/ghi trùng, giữ giá trị trong thanh ghi thay vì đọc lại bộ nhớ, sắp lại thứ tự truy cập, và khử mã chết nếu kết quả không dùng. Với volatile, tất cả những thứ đó bị cấm: mỗi lần chạm một biến volatile phải là một lần truy cập bộ nhớ thật, đúng thứ tự viết trong nguồn. Compiler không được giả định giá trị còn nguyên giữa hai lần đọc, vì "ai đó bên ngoài" có thể đã đổi nó. Đúng cái đồng hồ áp suất: cấm nhớ, phải đi nhìn lại.

Tôi cộng dồn 500 triệu phần tử vào một biến toàn cục, một bản thường và một bản volatile.

biến thường (giữ thanh ghi)      : 0,2577 ns/phần tử
volatile (load+store mỗi vòng)   : 1,3232 ns/phần tử   -> chậm 5,1 lần

volatile chậm gấp 5 lần. objdump chỉ rõ vì sao. Với biến thường, compiler nạp acc vào một thanh ghi một lần trước vòng, cộng dồn trong thanh ghi suốt vòng, ghi ra bộ nhớ một lần sau vòng — thân vòng chỉ có ldrsw (nạp a[i]) và add. Đây đúng kiểu giữ-giá-trị-nóng-trong-thanh-ghi mà loại biểu thức con chung dựa vào. Với volatile, thân vòng có tới bốn lệnh: ldr acc + ldrsw a[i] + add + str acc — nạp và ghi acc ra bộ nhớ mỗi vòng lặp, một chuyến khứ hồi bộ nhớ cho mỗi phần tử. Không một ldr/str nào của acc được kéo ra ngoài vòng, vì volatile cấm chính điều đó. Hai lệnh bộ nhớ thừa mỗi vòng, nhân 500 triệu, chính là 1,06 ns chênh lệch tôi đo được.

Đây là chỗ tôi từng nhầm. Tôi tưởng volatile "làm biến nhanh". Đo ra nó chặn tối ưu nên chậm — và đó là bản chất, không phải khiếm khuyết: volatile đánh đổi tốc độ để lấy sự đúng đắn trong những tình huống mà tối ưu sẽ làm sai. Assembly không nói dối: bạn thấy tận mắt vì sao nó chậm — nó bị cấm làm cái tối ưu rẻ nhất trong sách.

Khi nào cần, và cạm bẫy chết người

volatile đúng và cần thiết ở ba chỗ. Một, MMIO (memory-mapped I/O): một thanh ghi phần cứng ánh xạ vào bộ nhớ, giá trị đổi bởi thiết bị chứ không bởi code — nếu compiler "tối ưu" bằng cách đọc một lần rồi tái dùng, bạn đọc phải giá trị cũ mãi (đúng cái đồng hồ áp suất). Hai, cờ ngắt: một biến bị một trình phục vụ ngắt (ISR) đổi giữa chừng — nếu compiler giữ nó trong thanh ghi, vòng lặp chính không bao giờ thấy cờ đổi. Ba, chính volatile SINK tôi dùng cả sê-ri: gán kết quả đo vào một biến volatile buộc compiler thật sự tính nó, chặn khử mã chết xóa phép đo.

Nhưng có một cạm bẫy chết người: volatile KHÔNG phải atomic, và KHÔNG đảm bảo an toàn đa luồng. Nhiều người thấy volatile "liên quan tới bộ nhớ và luồng khác" rồi dùng nó để chia sẻ biến giữa các luồng — đó là bug. volatile chỉ ngăn compiler tối ưu; nó không cho phép đọc-sửa-ghi nguyên tử (hai luồng cùng v++ vẫn đua và mất cập nhật), và không dựng rào bộ nhớ đủ mạnh để đồng bộ thứ tự nhìn thấy giữa các lõi CPU. Để đồng bộ luồng, bạn cần atomic (hay mutex), không phải volatile. Nhầm hai thứ này là một trong những lỗi đa luồng khó lần nhất — code "trông như" được bảo vệ mà thực ra vẫn đua.

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

Điều đầu tiên: đừng rắc volatile để "cho nhanh" hay "cho chắc" — nó làm chậm, và dùng sai chỗ là vô nghĩa hoặc có hại. Một biến cục bộ bình thường không cần volatile; thêm vào chỉ ép compiler bỏ mọi tối ưu trên nó (chậm 5 lần như đo), chẳng được gì. volatile là công cụ chuyên dụng cho phần cứng/ngắt/đo lường, không phải gia vị rắc bừa.

Điều thứ hai: dùng đúng công cụ cho đúng việc đồng bộ. Cần đọc thanh ghi phần cứng hay cờ ISR → volatile. Cần đếm/chia sẻ biến giữa luồng → atomic (nguyên tử + rào bộ nhớ). Cần bảo vệ một vùng tới hạn → mutex. volatile không thay được atomic; đây là ranh giới mà mọi lập trình viên hệ thống phải rõ.

Và cái ranh giới đó chính là nơi các ngôn ngữ khác đi một đường rất khác C, đáng biết để khỏi mang nhầm thói quen. Java volatile KHÁC hẳn C volatile: nó có dựng happens-before và rào bộ nhớ, nên một cờ volatile boolean trong Java đủ để một luồng thấy luồng kia đặt — thứ mà C volatile không đảm bảo. Nhưng bẫy vẫn còn: Java volatile cũng không làm count++ nguyên tử, vẫn phải dùng AtomicInteger. Go thì gần như xóa sổ từ khóa này — không có volatile, mọi chia sẻ giữa goroutine đi qua channel hoặc sync/atomic, và -race sẽ tóm cổ bạn nếu đua. Rust cũng vậy: volatile chỉ còn cho MMIO qua read_volatile/write_volatile, còn chia sẻ luồng là việc của Atomic* và Mutex. Mẫu số chung: "biến này có thể đổi sau lưng tôi" và "hai luồng phối hợp trên biến này" là HAI bài toán khác nhau — C gộp một nửa vào volatile rồi để bạn tự lo phần còn lại, các ngôn ngữ sau tách bạch chúng ra để bớt người vấp.

Muốn tự thấy thì viết long acc=0; for(int i=0;i<1000000000;i++) acc+=i; và đo bằng clock_gettime. Rồi đổi long acc thành volatile long acc và đo lại — bạn sẽ thấy bản volatile chậm nhiều lần. Xem vì sao bằng gcc -O2 -S -o - t.c: bản thường giữ acc trong một thanh ghi suốt vòng (chỉ một str sau vòng), bản volatile có một ldr và một str của acc bên trong thân vòng — mỗi lần lặp một chuyến bộ nhớ. Ba mươi giây đó cho bạn thấy volatile không phải nút tăng tốc, mà là một lệnh "cấm tối ưu biến này" — đắt, nhưng cần đúng chỗ. Và cũng chính nó là cái neo đã giữ cho mọi con số trong sê-ri này đứng vững: một biến volatile buộc compiler tính thật thay vì xóa phép đo đi.