Trình biên dịch mặc định cho rằng nó kiểm soát toàn bộ giá trị của một biến — nếu không có gì trong chương trình ghi vào một biến, giá trị nó không đổi, nên trình dịch tự do giữ nó trong thanh ghi, bỏ các lần đọc lại, xóa các lần ghi thừa. Nhưng một số biến đổi giá trị ngoài tầm kiểm soát của chương trình: thanh ghi phần cứng, biến bị signal handler sửa, cờ do luồng khác đặt. Từ khóa volatile bảo trình dịch: "đừng tối ưu các truy cập vào biến này". Bài này đo volatile làm gì trong assembly — và tôi vấp một quan niệm sai rất phổ biến về nó.

volatile ngăn tối ưu

volatile: mọi truy cập phải xảy ra thật

Không có volatile, trình dịch được phép: cache giá trị biến trong thanh ghi (đọc một lần, không đọc lại), gộp các lần đọc/ghi thừa, và hoist một lần đọc ra khỏi vòng lặp. Với volatile, mỗi lần đọc và mỗi lần ghi trong mã nguồn phải trở thành một lệnh truy cập bộ nhớ thật, đúng thứ tự, không bị xóa hay gộp. Tôi đo ba tình huống, đọc thẳng assembly ở -O2. Điểm chung của cả ba: volatile không thêm một chỉ thị đặc biệt nào — nó chỉ cấm trình dịch bỏ bớt các lệnh đọc/ghi mà lẽ ra phải có. Nói cách khác, nó không làm gì thêm; nó ngăn trình dịch làm bớt.

Vòng chờ cờ. Hàm while(!done); chờ một biến done được đặt (thường là bởi luồng khác hay signal). Không volatile:

ldr  w0, [done]      ; đọc done ĐÚNG MỘT LẦN
cbnz w0, exit         ; nếu != 0 thì thoát
b    .L3              ; nếu == 0: nhảy vòng vô hạn (KHÔNG đọc lại done)

Trình dịch đọc done một lần; nếu nó bằng 0, chương trình rơi vào vòng lặp vô hạn b .L3không bao giờ đọc lại done. Dù luồng khác có đặt done = 1, vòng lặp không thấy — nó treo vĩnh viễn. Với volatile, assembly có một lần ldr done bên trong vòng lặp, đọc lại mỗi vòng, nên nó thấy được thay đổi và thoát.

Đọc/ghi lặp. int a = g; int b = g; (hai lần đọc g): không volatile, trình dịch gộp còn một lệnh ldr (đọc một lần, dùng cho cả hai). Với volatile, đủ hai lệnh ldr. Tương tự g = 1; g = 2;: không volatile chỉ còn một lệnh str (lần ghi g=1 bị xóa vì không ai đọc trước khi đè — dead store); với volatile, đủ hai lệnh str. volatile tắt sạch các tối ưu liên quan tới truy cập biến.

Một lần tôi đo hớ: volatile không làm biến "an toàn cho đa luồng"

Vòng while(!done) treo vô hạn ở -O2, tôi thêm volatile thì nó chạy đúng, và phản xạ đầu tiên của tôi là kết luận: "vậy volatile là cách làm một biến chia sẻ giữa các luồng — nó khiến biến an toàn cho đa luồng". Đọc assembly mới thấy volatile thực sự làm gì: nó chỉ ép trình dịch đọc lại done mỗi vòng lặp (sửa đúng cái bệnh trình dịch cache giá trị), không hơn. Nó không cung cấp tính nguyên tử, cũng không cung cấp bảo đảm về thứ tự bộ nhớ giữa các luồng.

Lý do vòng cờ "trông như chạy đúng" nhờ volatile là một sự trùng hợp may mắn của phần cứng: đọc/ghi một biến rộng một từ (như int) thường là nguyên tử trên phần cứng, nên với một cờ đơn giản, việc reload là đủ để thấy giá trị mới. Điều đó tạo ra ảo tưởng "volatile = thread-safe" — một nửa sự thật nguy hiểm. Với bất cứ thứ gì phức tạp hơn — một bộ đếm count++ (đọc-sửa-ghi, không nguyên tử dù có volatile), hai biến phải nhất quán với nhau (không có bảo đảm thứ tự) — volatile không an toàn cho đa luồng.

Bài học đo lường: volatile nghĩa chính xác là "đừng tối ưu bỏ truy cập này", không nhiều hơn, không ít hơn. Tôi đã lẫn "truy cập luôn xảy ra thật" (cái volatile bảo đảm) với "nguyên tử và có thứ tự" (cái đồng bộ đa luồng cần). Đó là hai thuộc tính khác nhau, và volatile chỉ cho cái đầu. Công cụ đúng cho đồng bộ đa luồng thật sự là _Atomic (C11) hoặc mutex — chúng cho cả tính nguyên tử lẫn thứ tự bộ nhớ, thứ mà volatile không hứa. Đọc assembly một lần là đủ để phân biệt: volatile thêm/giữ các lệnh ldr/str, nhưng không thêm một rào cản bộ nhớ hay lệnh nguyên tử nào.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là biết đúng chỗ dùng volatile: truy cập thanh ghi phần cứng ánh xạ vào bộ nhớ (memory-mapped I/O — mỗi lần đọc/ghi phải xảy ra thật vì nó là một hành động phần cứng), biến chia sẻ với một signal handler (sig_atomic_t volatile), và những vòng bận chờ một cờ đơn giản mà trình dịch có thể cache. Trong nhúng và lập trình hệ thống, đây là những ca volatile bắt buộc — thiếu nó, mã "đúng" bị tối ưu thành sai (đọc thanh ghi phần cứng một lần rồi cache một giá trị đã cũ). Một trạng thái thiết bị được cập nhật bởi phần cứng, nếu đọc không có volatile, sẽ bị trình dịch đọc một lần rồi lặp trên bản sao cũ mãi mãi — đúng cái bẫy vòng chờ đã đo, nhưng lần này thủ phạm là phần cứng chứ không phải luồng.

Hệ quả thứ hai là đừng dùng volatile để đồng bộ đa luồng. Đây là lỗi kinh điển của người mới với C đa luồng: đánh dấu vài biến chia sẻ là volatile rồi tưởng đã an toàn. volatile không ngăn được đua tranh dữ liệu (data race), không làm count++ nguyên tử, không tạo quan hệ "xảy ra trước" giữa các luồng. Dùng _Atomic int (C11) cho biến chia sẻ, hoặc mutex cho vùng găng — chúng mới cho các bảo đảm bạn cần.

Hệ quả thứ ba là một góc nhìn về sự đối xứng của cả sê-ri: volatile là công tắc bạn dùng để tắt chính những tối ưu mà các bài trước đo được. Loại bỏ mã chết (bài 8) xóa một memset; volatile giữ nó lại. Vòng lặp cache một giá trị; volatile ép đọc lại. Con số mang theo: volatile ép mọi đọc/ghi biến thành lệnh truy cập bộ nhớ thật (đo được: vòng chờ reload mỗi vòng thay vì treo, 2 lần đọc thay vì 1, 2 lần ghi thay vì 1) — nó CHỈ đảm bảo truy cập xảy ra, KHÔNG đảm bảo nguyên tử hay thứ tự, nên đúng cho thanh ghi phần cứng/signal/cờ đơn giản nhưng KHÔNG phải công cụ đồng bộ đa luồng (dùng _Atomic/mutex). Hiểu volatile là hiểu ranh giới giữa "truy cập phải xảy ra" và "truy cập phải nguyên tử và có thứ tự".

Thử ba mươi giây

Viết int done = 0; int main(){ while(!done); return 0; } và dịch gcc -O2 -S a.c -o a.s. Mở a.s tìm hàm main: bạn sẽ thấy một vòng lặp b vô hạn không có lệnh đọc done bên trong — nếu chạy, nó treo mãi. Giờ đổi thành volatile int done = 0;, dịch lại, xem .s: giờ có một lệnh ldr đọc done bên trong vòng lặp. Chỉ hai lần đọc assembly là bạn thấy volatile ép trình dịch đọc lại — và hiểu vì sao nó cần cho cờ phần cứng, nhưng cũng thấy nó chỉ thêm ldr, không thêm gì để đồng bộ luồng.