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 vào đo, 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ó.
Tôi đo trong container gcc:13 (ARM AArch64): 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, và ghi ra bộ nhớ một lần sau vòng — thân vòng chỉ có ldrsw (nạp a[i]) và add. Với volatile, thân vòng có 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ử. Đó chính là điều volatile đòi hỏi: không giữ thanh ghi, mỗi phép đọc/ghi là thật.
Đây là đo hớ của tôi. 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.
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ị của nó đổ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. 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.
Đọc objdump: một chuyến bộ nhớ mỗi vòng
Bằng chứng nằm trong assembly, không phải trong lời hứa. Với biến thường, thân vòng gcc -O2 sinh ra đúng hai lệnh làm việc: ldrsw nạp a[i] từ mảng, rồi add cộng vào một thanh ghi đang giữ acc. Một ldr acc duy nhất đứng trước vòng và một str acc duy nhất đứng sau vòng — suốt 500 triệu vòng, acc không rời thanh ghi. Đây là điều tôi mô tả trong loại biểu thức con chung: compiler giữ giá trị nóng trong thanh ghi thay vì đi bộ nhớ mỗi lần.
Với volatile, cùng vòng đó sinh ra bốn lệnh trong thân: ldr acc (nạp acc từ bộ nhớ), ldrsw a[i], add, rồi str acc (ghi acc trả bộ nhớ) — mỗi vòng. Không có ldr/str nào được kéo ra ngoài vòng, vì volatile cấm điều đó: mỗi lần chạm acc phải là một truy cập bộ nhớ thật. 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. Assembly không nói dối: bạn thấy tận mắt vì sao volatile chậm — nó bị cấm làm cái tối ưu rẻ nhất trong sách.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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), khô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.
Hệ quả 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õ.
Hệ quả thứ ba là bài học đo lường của cả sê-ri, và nó khép vòng: volatile SINK là công cụ tôi dùng để đo trung thực — vì volatile chặn khử mã chết. Mỗi con số trong 36 bài qua đứng vững được là nhờ một biến volatile buộc compiler tính thật thay vì xóa phép đo đi. Hiểu volatile chặn tối ưu chính là hiểu vì sao nó là cái neo của đo lường vi mô. Con số mang theo: volatile buộc mỗi truy cập là bộ nhớ thật (không gộp, không giữ thanh ghi, không sắp lại, không khử mã chết) nên nó CHẶN tối ưu và CHẬM hơn — đo được 5,1 lần (1,3232 so 0,2577 ns; objdump: biến thường giữ acc trong thanh ghi, volatile load+store mỗi vòng); đúng và cần cho MMIO, cờ ngắt, và volatile SINK chặn khử mã chết khi đo, NHƯNG volatile KHÔNG phải atomic và KHÔNG an toàn đa luồng — đồng bộ luồng phải dùng atomic/mutex. Dùng volatile cho phần cứng và đo lường; đừng nhầm nó với công cụ tăng tốc hay đồng bộ.
Thử ba mươi giây
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ỗ.