LongAdder & VarHandle: đếm dưới contention ghi cao
Vì sao AtomicLong nghẽn khi hàng chục thread cùng đếm, LongAdder rải sọc thắng thế nào, sum() yếu-nhất-quán ra sao, và VarHandle áp CAS thẳng lên field.
TL;DR: AtomicLong bắt mọi thread tranh đúng một ô nhớ; dưới contention ghi cao, cache line của ô đó nảy qua lại giữa các core và CAS thất bại liên tục — một thao tác lẽ ra rẻ thành nút cổ chai. LongAdder (Java 8) né bằng striping: rải tổng ra nhiều cell, mỗi thread cộng vào cell riêng, sum() gộp lại khi cần. Đổi lại tốn bộ nhớ và sum() không phải snapshot nguyên tử. Tiêu chí chọn: ghi rất nhiều, đọc thưa (metric, throughput counter) thì LongAdder; cần đọc chính xác từng bước hay cần compareAndSet thật thì AtomicLong. LongAccumulator tổng quát hóa cho phép giao hoán-kết hợp; VarHandle áp CAS thẳng lên một field sẵn có, khỏi bọc object.
Một bộ đếm metric toàn cục — số request phục vụ — bị hàng chục thread cùng incrementAndGet mỗi mili giây. Trên máy 4 core nó chạy êm; lên 32 core, throughput giảm dù thêm core. Không phải bug logic: tất cả thread đang tranh đúng một ô nhớ, và chính sự tranh chấp đó là nút cổ chai.
Bài 10 đã cảnh báo điểm yếu này: khóa biến tranh chấp thành chờ, CAS biến nó thành làm lại — và làm lại dưới contention cao là đốt CPU. Bài này mổ cách LongAdder tản chỗ tranh, khi nào nó thắng AtomicLong, và VarHandle để áp CAS lên field trần.
1. Vì sao AtomicLong nghẽn khi nhiều thread cùng đếm?
AtomicLong gói một vòng lặp CAS quanh đúng một ô nhớ. Với một thread nó rẻ. Nhưng khi nhiều thread cùng dồn dập tăng, hai chi phí chồng lên nhau. Một: CAS chỉ một người thắng mỗi vòng, kẻ thua phải retry — càng đông càng nhiều vòng quay không tải. Hai, và nặng hơn: mỗi lần một thread ghi thành công, ô đó đổi giá trị, làm vô hiệu bản cache của nó trên mọi core khác. Core khác muốn đọc/ghi lại phải kéo cache line về — cache line nảy qua lại giữa các core như quả bóng.
Đây là cache contention: nhiều core tranh cùng một cache line. Nó biến một phép cộng lẽ ra vài nano-giây thành thao tác đồng bộ xuyên core, và càng thêm core càng tệ. Đó là lý do bộ đếm ở đầu bài chậm đi khi lên 32 core.
2. LongAdder — đừng bắt các thread tranh một ô
LongAdder (Java 8) né bằng một ý tưởng đẹp: nếu tranh một ô gây đau thì đừng bắt các thread tranh một ô. Nó rải tổng ra một mảng nhiều ô — cells — mỗi thread dưới contention cộng vào ô riêng (chọn ô theo một hash của thread). Đây là striped counter, bộ đếm chia sọc; khi cần giá trị thật, sum() cộng tất cả các ô cộng với một base.
private final LongAdder requestsServed = new LongAdder();
public void onRequest() { requestsServed.increment(); } // cong vao o rieng theo thread, it dung do
public long total() { return requestsServed.sum(); } // cong don tat ca o — KHONG phai snapshot
Đặt hai thiết kế cạnh nhau:
flowchart TD
subgraph AL[AtomicLong: moi thread tranh MOT o]
T1[Thread 1] --> V[value - CAS lien tuc that bai]
T2[Thread 2] --> V
end
subgraph LA[LongAdder: moi thread hash vao cell rieng]
U1[Thread 1] --> C0[Cell 0]
U2[Thread 2] --> C1[Cell 1]
C0 --> S["sum() = base + Cell 0 + Cell 1"]
C1 --> S
B0[base] --> S
end
style V fill:#FCA5A5
style S fill:#6EE7B7Với một thread duy nhất, AtomicLong hay LongAdder nhanh hơn? Còn với hàng chục thread cùng dồn dập tăng thì bên nào? Viết ra dự đoán cho từng trường hợp kèm lý do trước khi đọc tiếp — nghĩ về số ô nhớ mỗi bên đụng tới.
Với một thread, hai bên gần như ngang nhau — AtomicLong còn nhỉnh hơn chút vì không phải quản lý mảng cell (chỉ một ô, không có cache contention để né). Nhưng đẩy lên hàng chục thread cùng dồn dập, AtomicLong tụt dốc: mọi thread tranh một cache line, còn LongAdder giữ throughput gần như phẳng vì mỗi thread phần lớn chỉ đụng cell riêng. Chênh lệch phụ thuộc số core và mức dồn dập, nên đừng tin con số truyền miệng — tự đo bằng JMH trên máy đích: contention ghi càng cao, lợi thế stripe càng lớn; không contention thì stripe chỉ tốn thêm bộ nhớ.
3. Cái giá: bộ nhớ và một sum() yếu-nhất-quán
Sự khéo léo đó có giá. Một, LongAdder tốn bộ nhớ cho mảng cell (mảng lớn dần theo mức contention). Hai, quan trọng hơn: sum() không phải snapshot nguyên tử. Nó duyệt cộng từng ô trong khi ô khác vẫn có thể đang được ghi, nên trả về một ước lượng tại một thời điểm gần, không phải giá trị chốt tại một lằn ranh. Nếu đang có ghi đồng thời, hai lần sum() liên tiếp có thể lệch nhau, và không có compareAndSet trên LongAdder — nó không cho phép "chỉ tăng nếu tổng còn bằng X".
4. Choose: LongAdder hay AtomicLong?
Ghép ba điểm trên lại thành tiêu chí chọn:
| Tình huống | Chọn | Vì sao |
|---|---|---|
| Ghi rất nhiều, đọc thưa (metric, throughput, đếm thống kê) | LongAdder | Striping né cache contention; đọc thưa nên sum() không-nguyên-tử chấp nhận được |
| Cần đọc chính xác ngay sau mỗi ghi | AtomicLong | get() trả đúng giá trị hiện tại; sum() chỉ là ước lượng |
Cần compareAndSet thật trên bộ đếm (vd cấp ID tuần tự, chặn theo ngưỡng) | AtomicLong | LongAdder không có CAS — không "chỉ tăng nếu còn bằng X" |
| Contention thấp hoặc một thread | AtomicLong | Đơn giản hơn, không tốn mảng cell |
Nói gọn: LongAdder là bộ đếm ghi nhanh, đọc mờ; AtomicLong là ô chính xác, có CAS. Chọn theo việc đọc của bạn cần chính xác tới đâu và có cần điều kiện hóa phép ghi không.
5. LongAccumulator — tổng quát hóa cho hàm giao hoán-kết hợp
LongAccumulator là bản tổng quát: thay vì cố định phép cộng, nó nhận một hàm kết hợp hai toán hạng (LongBinaryOperator) và một giá trị gốc — LongAdder chẳng qua là LongAccumulator với phép cộng và gốc 0. Với nó ta gom max, min, hay tích theo cùng kiểu chia sọc:
// theo doi so ve ban duoc trong mot lan giao dich cao nhat, duoi tai ghi nang
LongAccumulator peakBatch = new LongAccumulator(Long::max, 0);
peakBatch.accumulate(batchSize); // gop bang max, rai soc nhu LongAdder
long peak = peakBatch.get();
Ràng buộc: vì hàm kết hợp được áp theo thứ tự không xác định trên các ô, nó phải giao hoán và kết hợp để kết quả không phụ thuộc thứ tự gộp — max, min, cộng, nhân thỏa; phép trừ thì không (a - b khác b - a).
6. VarHandle — áp CAS thẳng lên field sẵn có
Đến giờ ta luôn thao tác qua một object Atomic* bọc lấy giá trị. Đôi khi ta muốn áp chính ngữ nghĩa CAS lên một field bình thường đã có — tiết kiệm một lớp object, giữ field ở dạng nguyên thủy. VarHandle (Java 9) làm việc đó, thay cho bộ ba field updater cũ (AtomicIntegerFieldUpdater và họ hàng) vốn cồng kềnh vì dựng qua reflection.
public class Node {
private volatile int state; // field thuong, van volatile
private static final VarHandle STATE;
static {
try { STATE = MethodHandles.lookup().findVarHandle(Node.class, "state", int.class); }
catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
}
public boolean tryActivate() {
return STATE.compareAndSet(this, 0, 1); // CAS thang tren field, khong can wrapper
}
}
Ngoài CAS trên field trần, VarHandle còn phơi ra cả một quang phổ chế độ truy cập với memory ordering ở nhiều mức (plain, opaque, acquire/release, volatile) — thứ các lớp Atomic* không cho chọn. Acquire/release cho đúng "nửa hàng rào" mà nhiều thuật toán lock-free cần, rẻ hơn volatile hai chiều. Nhưng chọn sai mức là lỗi không lộ trên CPU này mà bung ra trên CPU khác có mô hình bộ nhớ lỏng hơn — nên các mức dưới volatile là lãnh địa của thư viện và chuyên gia. Ở mức ứng dụng, dùng VarHandle chủ yếu để có compareAndSet trên field mà khỏi bọc Atomic*.
7. Liên hệ các bài khác
- Bài 10 — Atomic & CAS: nền của bài này — vòng lặp CAS và điểm yếu retry-quay-không-tải dưới contention chính là thứ
LongAdderné. - Bài 08 — volatile & synchronized: field trong ví dụ
VarHandlevẫnvolatile; bốn mức memory ordering củaVarHandlelà tinh chỉnh happens-before mà bài này định nghĩa. - Bài 13 — Delegation & concurrent collections:
ConcurrentHashMapdùng chính CAS + striping (nhưLongAdder) — hưởng lock-free mà khỏi tự viết retry loop.
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 193 — Variable Handles — đề xuất gốc của
VarHandle: vì sao cần thaysun.misc.Unsafevà họ field updater, bốn mức memory ordering (plain/opaque/acquire-release/volatile) đến từ đâu (vay mô hình từ C/C++11). - Javadoc
LongAdder(Java 21) — nói thẳng: "This class is usually preferable toAtomicLongwhen multiple threads update a common sum that is used for purposes such as collecting statistics, not for fine-grained synchronization control."
9. Tóm tắt
AtomicLongbắt mọi thread tranh một cache line: dưới contention cao, line nảy giữa các core và CAS trượt liên tục, throughput tụt khi thêm core.LongAdderrải tổng ra nhiều cell, mỗi thread cộng vào cell riêng nên gần như không tranh;sum()gộp các cell — không phải snapshot nguyên tử, và không cócompareAndSet.- Choose
LongAdderkhi ghi rất nhiều - đọc thưa (metric, throughput); chooseAtomicLongkhi cần đọc chính xác từng bước hoặc cầncompareAndSetthật. LongAccumulatortổng quát cho hàm giao hoán-kết hợp (max/min/nhân); hàm không giao hoán (phép trừ) cho kết quả phụ thuộc thứ tự striping nên không dùng được.VarHandleáp CAS thẳng lên field sẵn có (thay bộ field-updater cũ) và mở quang phổ memory ordering — mức dướivolatiledành cho thư viện.
10. Tự kiểm tra
Q1Vì sao AtomicLong tụt throughput khi số core tăng, dù mỗi thao tác tăng chỉ là một phép cộng?▸
Vì mọi thread tăng cùng một ô nhớ, nên chúng tranh cùng một cache line. Mỗi lần một core ghi thành công, bản cache của line đó trên mọi core khác bị vô hiệu; core khác muốn thao tác tiếp phải kéo line về — cache line nảy qua lại giữa các core.
Đây là cache contention: chi phí không nằm ở phép cộng mà ở việc đồng bộ một dòng cache xuyên core. Thêm core chỉ thêm bên tranh chấp, nên throughput giảm thay vì tăng. CAS cũng trượt nhiều hơn, chồng thêm vòng retry vô ích.
Q2LongAdder né cache contention bằng cách nào? Vì sao sum() không phải một snapshot nguyên tử?▸
Thay vì một ô, `LongAdder` giữ một mảng cell và cho mỗi thread (dưới contention) cộng vào cell riêng, chọn theo hash của thread. Các thread khác nhau phần lớn chạm cache line khác nhau nên không vô hiệu cache của nhau — điểm nóng bị tản ra.
sum() duyệt cộng tất cả cell cộng base, nhưng trong lúc nó duyệt, các cell chưa cộng vẫn có thể đang bị ghi. Kết quả là tổng tại "một khoảng thời gian gần" chứ không phải giá trị chốt tại một lằn ranh — hai lần sum() liên tiếp khi đang có ghi có thể lệch nhau.
Q3Bạn cần một bộ cấp ID tuần tự (mỗi lần gọi trả một số duy nhất, tăng dần, không trùng). Chọn LongAdder hay AtomicLong? Vì sao?▸
AtomicLong. Cấp ID cần đọc-và-tăng nguyên tử trả về đúng giá trị của lần tăng này (getAndIncrement) — một thao tác chính xác trên một ô. LongAdder không cung cấp điều đó: nó chỉ có increment/add/sum, và sum() là ước lượng không nguyên tử, không dùng để lấy một giá trị duy nhất được.
Quy tắc: cần giá trị trả về chính xác hoặc compareAndSet thì dùng AtomicLong; chỉ khi bộ đếm là "ghi nhiều, thỉnh thoảng đọc tổng gần đúng" (metric) mới đổi sang LongAdder.
Q4Vì sao hàm truyền vào LongAccumulator phải giao hoán và kết hợp? Vì sao max thỏa còn phép trừ thì không?▸
Vì LongAccumulator rải các đóng góp vào nhiều cell theo thread, rồi get() gộp các cell theo thứ tự không xác định (phụ thuộc thread nào vào cell nào và mảng được duyệt ra sao). Kết quả chỉ ổn định khi mọi cách nhóm và mọi thứ tự gộp đều cho cùng đáp số — chính là tính giao hoán và kết hợp.
max(a, b) thỏa cả hai: max của một tập không phụ thuộc thứ tự duyệt. Phép trừ thì (a - b) - c khác a - (b - c) và a - b khác b - a — hai lần chạy cùng dữ liệu có thể ra hai kết quả khác nhau tùy lịch sử striping.
Q5Khi nào nên dùng VarHandle thay cho một object Atomic*? Nó cho thêm điều gì mà Atomic* không có?▸
Khi bạn muốn áp CAS lên một field đã có sẵn mà không muốn bọc nó trong một object Atomic* riêng — tiết kiệm một lớp object và giữ field ở dạng nguyên thủy. VarHandle (Java 9) thay bộ field-updater cũ vốn cồng kềnh vì dựng qua reflection.
Thứ nó cho thêm là cả một quang phổ memory ordering (plain, opaque, acquire/release, volatile) — chọn được mức hàng rào bộ nhớ, thứ Atomic* luôn cố định ở mức volatile. Acquire/release rẻ hơn volatile cho các thuật toán chỉ cần nửa hàng rào, nhưng chọn sai mức là lỗi tinh vi phụ thuộc kiến trúc CPU, nên các mức dưới volatile chủ yếu dành cho thư viện.
Bài tiếp theo: ReentrantLock và Condition — khóa tường minh khi synchronized không đủ
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên