Hình dung một cửa hàng sắp khai trương. Chủ tiệm xếp hàng lên kệ (khởi tạo từng món), rồi mới treo tấm biển "MỞ CỬA" ngoài cửa. Khách đi ngang chỉ cần liếc tấm biển: thấy "MỞ CỬA" là bước vào mua, không phải gõ cửa hỏi ai (không cần khóa). Rất nhanh, rất gọn. Nhưng có một điều kiện sống còn: tấm biển "MỞ CỬA" phải được treo sau khi kệ đã xếp xong. Nếu vì lý do nào đó tấm biển lên trước khi hàng lên kệ, khách sẽ bước vào một cửa hàng trống trơn. Double-checked locking là đúng cái cửa hàng đó — và cái bẫy của nó là thứ tự giữa "treo biển" và "xếp kệ".
Double-checked locking (DCL) là một mẫu kinh điển để khởi tạo lười một singleton mà không phải lấy khóa ở mỗi lần truy cập. Ý tưởng: kiểm con trỏ, nếu đã khởi tạo thì dùng ngay (không khóa); chỉ khi chưa khởi tạo mới lấy khóa, kiểm lại lần nữa (double-check), rồi khởi tạo. Nghe rất khéo, và nó nhanh thật. Nhưng DCL cũng nổi tiếng là một mẫu bị tuyên bố "hỏng" trong Java trước 2004 và cần cẩn thận đặc biệt trong C++ — vì một lý do tinh vi về thứ tự bộ nhớ. Tôi đo cả cái lợi lẫn cái bẫy, và cái bẫy là một bài học sâu về vì sao "chạy thử thấy chạy" không đủ.
Fast-path và cái bẫy publish
DCL trông như thế này:
Obj* p = atomic_load(&inst); // kiểm KHÔNG khóa (fast-path)
if (!p) { // chưa khởi tạo?
lock(mx);
if (!inst) { // KIỂM LẠI dưới khóa (double-check)
Obj* o = new_obj(); // khởi tạo (ghi các field)
inst = o; // publish con trỏ
}
unlock(mx);
}
Đường thường (singleton đã khởi tạo): chỉ một lần đọc atomic con trỏ, không lấy khóa — đúng cái liếc tấm biển từ ngoài. Đường hiếm (lần đầu): lấy khóa, kiểm lại (vì hai luồng có thể cùng thấy null), khởi tạo một lần. Câu if(!inst) thứ hai là lý do có tên "double-checked".
Nhưng đây là cái bẫy chết người: thao tác inst = o (treo biển MỞ CỬA) phải xảy ra sau khi mọi field của o đã được ghi xong (xếp hết hàng lên kệ). Trực giác thì "tất nhiên, dòng inst = o viết sau dòng khởi tạo mà". Nhưng CPU và trình biên dịch được phép sắp xếp lại các thao tác ghi (như ta đã đo ở rào bộ nhớ phần cứng và memory ordering) — miễn là trên luồng của chính nó kết quả không đổi. Store inst = o có thể trở nên nhìn thấy được với luồng khác trước khi các store field của o nhìn thấy được. Khi đó, luồng khác chạy fast-path thấy inst != null, dùng nó — nhưng o chưa khởi tạo xong: nó đọc một object nửa vời, đúng ông khách bước vào tiệm trống. Tôi đo trong container gcc:13 (ARM AArch64, bộ nhớ yếu).
Đo (a): fast-path nhanh hơn always-lock 314 lần
Trước hết, cái lợi — vì sao người ta muốn DCL. So throughput truy cập một singleton đã khởi tạo theo hai cách: DCL (đọc atomic con trỏ, không khóa) và always-lock (lấy khóa mỗi lần truy cập):
N | DCL fast-path | always-lock | DCL/lock
1 | 2.521 M/s | 220 M/s | 11×
4 | 9.037 | 45 | 202×
8 | 10.551 | 34 | 314×
DCL fast-path scale (2.521 → 10.551 triệu/giây khi thêm luồng) vì mỗi luồng chỉ đọc một con trỏ atomic — không ghi chung, không tranh khóa. Always-lock sụp (220 → 34 triệu/giây) vì mọi luồng tranh một mutex cho mỗi lần truy cập — đúng lock convoy ta đã đo. Ở 8 luồng, DCL nhanh hơn always-lock 314 lần. Đây là lý do DCL tồn tại: với singleton đọc rất nhiều, tránh khóa đường thường là chênh lệch hàng trăm lần.
Đo (b): cái bẫy reorder — có thật nhưng khó bắt
Giờ đến cái bẫy. Tôi dựng một kịch bản message-passing đúng bản chất bug DCL: một luồng writer ghi data = MAGIC rồi flag = 1 (treo biển); một luồng reader đọc flag, nếu thấy 1 thì đọc data và kiểm có bằng MAGIC không. Nếu store bị sắp lại (flag nhìn thấy trước data), reader sẽ thấy flag=1 mà data chưa phải MAGIC — đúng cái "thấy biển MỞ CỬA mà kệ chưa có hàng".
relaxed (không rào) : KHÔNG ép được (0 lần quan sát trên máy này)
acquire/release : 0 (đúng — có rào thì không bao giờ nửa vời)
Và đây là chỗ tôi phải nói thật: tôi không ép được cái reorder này hiện thành một con số dương trên máy ARM ảo hóa này. Cửa sổ đua để quan sát nó hẹp cỡ vài nano giây, và bộ lập lịch ảo hóa không phơi bày nó ra trong các lần chạy của tôi — đúng như phần rào bộ nhớ phần cứng, nơi reorder chỉ hiện 11 lần trong 60 triệu vòng với đồng bộ nhẹ, và bản có rào cho 0. Điều đó không có nghĩa bug không tồn tại. Nó tồn tại về mặt mô hình bộ nhớ — kiến trúc ARM (và chuẩn C++11) cho phép store reorder này, nên nó sẽ xảy ra trên phần cứng thật, tải thật, đúng lúc tệ nhất. DCL đã bị tuyên bố "hỏng" trong Java chính vì lý do này (sửa bằng volatile, thêm rào acquire/release trong Java Memory Model 5.0).
(Môi trường đo: container gcc:13 trên host ARM AArch64 bộ nhớ yếu. Con số throughput đổi theo máy; điều bền vững: fast-path không khóa scale còn always-lock sụp, và reorder publish tuy hiếm lộ vẫn được mô hình bộ nhớ cho phép — thiếu rào là sai kể cả khi phép thử im lặng.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin. Thứ nhất: "kiểm null, khóa, kiểm lại — double-check thế là đủ đúng". Sai — logic double-check đúng (chống hai luồng cùng khởi tạo), nhưng nó không giải quyết vấn đề thứ tự bộ nhớ: reader ở fast-path có thể thấy con trỏ đã publish mà object chưa hoàn thành. Thứ hai: "DCL chỉ là một mẹo tránh khóa, không có gì tinh vi". Sai — nó tinh vi đến mức các chuyên gia Java mất nhiều năm mới thống nhất rằng bản DCL "ngây thơ" là sai, và sửa đúng đòi hiểu memory model.
Hai niềm tin gặp nhau ở một chủ đề xuyên suốt sê-ri: tính đúng của code đồng thời đến từ việc tuân thủ hợp đồng/mô hình bộ nhớ, không phải từ việc chạy thử thấy chạy. Cái bug DCL này có ba tính chất khiến nó cực nguy: nó phụ thuộc phần cứng (x86 bộ nhớ mạnh gần như không bao giờ lộ; ARM/POWER bộ nhớ yếu thì có), nó phụ thuộc timing (cửa sổ nano giây), và nó im lặng (đọc object nửa vời, không sập ngay). Một lập trình viên test trên laptop x86 thấy "chạy hoàn hảo" rồi triển khai lên server ARM và gặp lỗi không tài nào tái hiện. Đây là cùng một bài học với spurious wakeup, deadlock, ABA: với đồng thời, "đo thấy chạy" là điều kiện cần, không phải đủ — phải phân tích theo mô hình bộ nhớ. Cách sửa DCL đúng đắn — đo được là miễn phí ở fast-path — là release lúc publish, acquire lúc đọc con trỏ (chính là atomic_load(acquire) mà bản (a) của tôi dùng, và vẫn nhanh 314 lần): treo biển sau khi xếp xong kệ, và khách tin trọn tấm biển.
Về đến code hằng ngày
Điều đầu tiên: nếu viết DCL, bắt buộc dùng acquire/release (hoặc volatile trong Java / std::atomic trong C++). Con trỏ singleton phải là biến atomic; publish bằng store release, đọc fast-path bằng load acquire. Rào release đảm bảo mọi ghi field xong trước khi con trỏ nhìn thấy được; rào acquire đảm bảo reader thấy con trỏ thì cũng thấy các field. Không có cặp rào này, DCL sai — và cái sai đó ẩn cho tới production trên phần cứng bộ nhớ yếu.
Điều thứ hai: cân nhắc dùng cơ chế có sẵn thay vì tự viết DCL. Đây có lẽ là lời khuyên đáng giá nhất, vì gần như mọi ngôn ngữ đã đóng gói sẵn phần rào cho bạn. Go có sync.Once — once.Do(func(){ inst = newObj() }) lo trọn phần release/acquire, đúng một lần, không cần bạn nghĩ tới memory model. C++11 có std::call_once và cả biến-tĩnh-cục-bộ (magic statics — trình biên dịch tự chèn rào và bảo vệ khởi tạo). Rust có OnceCell/LazyLock. Java thì bài viết kinh điển "Double-Checked Locking is Broken Declaration" (ký tên hàng loạt chuyên gia JMM) khuyên hẳn: đừng tự viết DCL, dùng initialization-on-demand holder idiom (nhờ cơ chế nạp lớp lười của JVM tự đồng bộ) hoặc enum singleton — cả hai né sạch bài toán rào. Mẫu số chung: khởi tạo lười an toàn là việc đã được giải, và tự chép lại DCL bằng tay chỉ nên khi bạn thật sự hiểu memory model và có lý do.
Muốn tự soát thì nếu bạn có (hoặc thấy trong code) một singleton khởi tạo lười bằng tay — kiểm null, khóa, kiểm lại, khởi tạo — dừng lại hỏi: con trỏ đó có phải biến atomic với publish bằng release và đọc bằng acquire không? Nếu con trỏ là biến thường (không atomic, không volatile), bạn có bug DCL kinh điển: một luồng có thể thấy con trỏ đã set mà object chưa khởi tạo xong. Nó sẽ không hiện ra khi bạn test trên máy x86 (bộ nhớ mạnh giấu nó), nhưng sẽ cắn trên ARM (điện thoại, Mac Apple Silicon, server Graviton). Cách chữa nhanh nhất: đừng tự viết — dùng sync.Once, std::call_once, biến tĩnh cục bộ C++11, OnceCell của Rust, hay holder idiom của Java. Vài phút kiểm "con trỏ có atomic + rào không" cứu bạn khỏi một bug ẩn hàng tháng — và nhắc rằng trong đồng thời, một mẫu thiết kế "trông đúng và chạy tốt trên máy tôi" vẫn có thể sai theo mô hình bộ nhớ, và mô hình bộ nhớ mới là trọng tài.