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 đủ.

Double-checked locking: cần rào bộ nhớ

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 — nhanh. Đườ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 (publish con trỏ) phải xảy ra sau khi mọi field của o đã được ghi xong. 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ứngmemory 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. 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 (publish); 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=1data chưa phải MAGIC — đúng cái "thấy con trỏ mà object chưa khởi tạo".

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 báo trung thực: 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ột lần tôi đo hớ: double-check không đủ, cần rào

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.

Bài học đo lường, và là 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).

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

Hệ quả đầ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.

Hệ quả thứ hai: cân nhắc dùng cơ chế có sẵn thay vì tự viết DCL. Nhiều ngôn ngữ có cách khởi tạo lười an toàn sẵn: std::call_once / hàm-tĩnh-cục-bộ trong C++11 (trình biên dịch tự lo rào), lazy_static/OnceCell trong Rust, holder idiom / enum singleton trong Java. Chúng đã giải quyết đúng phần rào bộ nhớ; tự viết DCL bằng tay chỉ nên khi bạn thật sự hiểu memory model và có lý do.

Hệ quả thứ ba là tinh thần đo lường: đúng theo mô hình bộ nhớ, không theo phép thử. Con số mang theo: DCL fast-path (đọc atomic con trỏ, KHÔNG khóa) nhanh 314× always-lock ở 8 luồng (tránh khóa đường thường -> scale) — lợi thật; NHƯNG DCL ĐÚNG cần MEMORY ORDERING (acquire/release), không chỉ double-check: thiếu rào, store publish con trỏ có thể bị sắp TRƯỚC store field, nên luồng khác thấy con trỏ != null mà object CHƯA khởi tạo xong -> đọc nửa vời. Reorder này cực hiếm, tôi KHÔNG ép được thành số trên ARM ảo hóa (như phần 11 rào phần cứng) nhưng CÓ THẬT theo mô hình bộ nhớ — 'chạy thử thấy ổn' (nhất là x86 mạnh) KHÔNG chứng minh đúng trên ARM yếu.

Thử ba mươi giây

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 std::call_once, biến tĩnh cục bộ C++11, OnceCell của Rust, hay holder idiom của Java; chúng lo phần rào cho bạn. Ba mươi giây 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.