Atomic & CAS: Đồng bộ lock-free cho thao tác đơn
Đồng bộ lock-free cho thao tác đơn: CAS, AtomicInteger/Long/Reference, ABA problem, LongAdder và VarHandle. Khi nào atomic đủ, khi nào cần lock.
TL;DR: Với thao tác trên đúng một biến, không cần khóa. Compare-and-swap (CAS) — lệnh nguyên tử của CPU — cho phép cập nhật lạc quan theo vòng lặp đọc-tính-thử: thất bại thì đọc lại và làm lại, không thread nào block. Các lớp Atomic* đóng gói vòng lặp đó; AtomicReference ghép với immutable holder để cập nhật nguyên tử cả cụm trường với nhiều writer. Ba góc khuất phải thuộc: ABA (CAS so giá trị, không so lịch sử — cần stamp), lambda trong updateAndGet phải thuần vì có thể chạy lại nhiều lần, và dưới contention ghi nặng thì LongAdder tản stripe thắng AtomicLong tranh một ô. Ranh giới cứng: CAS không gói được invariant trải trên nhiều biến — lúc đó quay về khóa.
1. Giới thiệu
Bài trước khép lại bằng một lời than thầm. Để giữ cho sold của BookingService tăng đúng, ta đã quây nguyên cả object sau một intrinsic lock: mỗi lần một khách đặt vé, mọi khách khác phải xếp hàng, dù việc cần làm chỉ là cộng một vào một bộ đếm. synchronized đúng, nhưng nó thô. Tăng một số nguyên lẽ ra chỉ tốn một lệnh CPU, vậy mà ta trả giá bằng block, context switch và contention - những chi phí của một cơ chế sinh ra để bảo vệ cả một cụm thao tác phức tạp, nay bị bắt đi canh đúng một biến.
Cái khó nằm ở chỗ ta đã thấy rõ ở bài Thread safety: sold++ không nguyên tử. Nó là một read-modify-write gồm ba bước rời rạc - đọc giá trị hiện tại, cộng một, ghi lại - và hai thread có thể chen vào giữa ba bước đó để cùng đọc 9, cùng cộng thành 10, cùng ghi 10, làm bốc hơi một lần tăng. volatile không cứu được vì nó chỉ lo visibility chứ không gói cụm ba bước thành một khối. synchronized cứu được, nhưng bằng cách dựng một hàng rào loại trừ quanh cả khối, đắt hơn nhiều so với thứ ta thật sự cần.
Luận điểm trung tâm của bài này là: với một thao tác trên đúng một biến, ta đạt được atomicity mà không cần khóa. Cơ sở là một chỉ thị phần cứng tên compare-and-swap - viết tắt CAS - mà gần như mọi CPU hiện đại đều cài sẵn. Thay vì chặn các thread khác lại rồi mới sửa, ta để chúng cùng thử, và dùng CAS để bảo đảm chỉ một thử thành công còn những thử khác phát hiện mình đã lỡ nhịp và thử lại. Cách tiếp cận này gọi là lock-free, và với những thao tác đơn nó nhẹ hơn cùng ít contention hơn synchronized. Ta đi từ chính chỉ thị CAS, lên các lớp Atomic* đóng gói nó, rồi tới những góc khuất - ABA, contention dưới tải ghi nặng, và memory ordering tinh chỉnh.
2. CAS là gì
Compare-and-swap là một chỉ thị phần cứng nhận ba đối số: một vị trí bộ nhớ, một giá trị kỳ vọng, và một giá trị mới. Nó làm đúng một việc, nguyên tử trong một lệnh: nếu giá trị đang nằm ở vị trí đó đúng bằng giá trị kỳ vọng, ghi giá trị mới vào và báo thành công; nếu không, không làm gì và báo thất bại. Toàn bộ "so sánh rồi mới ghi" diễn ra như một khối không chia cắt ở mức phần cứng - không thread nào chen được vào giữa lúc so và lúc ghi.
Điểm cốt lõi để hiểu vì sao CAS thay được khóa nằm ở cách ta dùng nó. Đây là một chiến lược lạc quan - optimistic. Khóa là bi quan: nó giả định sẽ có xung đột nên chặn trước rồi mới làm. CAS giả định ngược lại - rằng phần lớn thời gian chẳng có ai tranh - nên cứ đọc giá trị hiện tại, tính giá trị mới dựa trên nó, rồi dùng CAS để ghi lại với điều kiện giá trị chưa hề bị ai khác đổi từ lúc ta đọc. Nếu CAS thành công, ta biết chắc cả quãng từ lúc đọc đến lúc ghi không ai chen vào, và phép cập nhật của ta nguyên tử. Nếu CAS thất bại, nghĩa là có người đã ghi đè trong lúc ta tính toán - ta vứt kết quả vừa tính, đọc lại giá trị mới, và thử lại từ đầu.
Vòng lặp thử-lại đó là khuôn mẫu nền của mọi cập nhật lock-free:
int oldValue, newValue;
do {
oldValue = readCurrent(); // đọc giá trị hiện tại
newValue = oldValue + 1; // tính giá trị mới dựa trên nó
} while (!compareAndSet(oldValue, newValue)); // chỉ ghi nếu chưa ai đổi; thất bại thì lặp lại
flowchart TD
A[Doc gia tri hien tai: v] --> B[Tinh gia tri moi: vNew = v + 1]
B --> C{"CAS(v, vNew) thanh cong?"}
C -- "co: chua ai doi v" --> D[Cap nhat xong, nguyen tu]
C -- "khong: ai do da ghi de" --> E[Vut ket qua vua tinh]
E --> AMột analogy đời thường. Hình dung một cuốn sổ ghi chung treo ở chỗ đông người. Cách "khóa" là: trước khi viết, bạn cầm lấy cây bút duy nhất, ai muốn viết phải chờ bạn trả bút. Cách "CAS" là: ai cũng có bút riêng, bạn cứ đọc dòng cuối, nhẩm dòng kế tiếp, rồi viết - nhưng có một quy ước, bạn chỉ được viết nếu dòng cuối vẫn đúng như lúc bạn đọc; nếu trong lúc bạn nhẩm có người đã viết thêm, bạn phải gạch đi và đọc lại dòng cuối mới rồi nhẩm lại. Không ai phải xếp hàng chờ bút. Cái giá là khi đông người cùng viết, bạn có thể phải nhẩm-rồi-gạch vài lần trước khi viết lọt.
| Cuốn sổ ghi chung | Concept |
|---|---|
| Cây bút duy nhất, ai viết phải chờ | Khóa — chiến lược bi quan |
| Đọc dòng cuối, nhẩm dòng kế tiếp | Đọc giá trị hiện tại, tính giá trị mới |
| Chỉ viết nếu dòng cuối vẫn như lúc đọc | compareAndSet(expected, new) |
| Có người viết chen — gạch đi, đọc lại, nhẩm lại | CAS thất bại, vòng retry |
| Đông người cùng viết, gạch nhiều lần | Contention cao, retry quay không tải |
Đặc tính ấy quyết định khi nào CAS thắng và khi nào không. Khi contention thấp - phần lớn lần thử thành công ngay - CAS gần như không tốn gì, vì không thread nào bị block, không context switch, không đánh thức. Khi contention cao - nhiều thread liên tục tranh cùng một biến - các vòng lặp retry bắt đầu quay không tải, mỗi thread đốt CPU tính rồi bỏ. Đây là một đánh đổi căn bản so với khóa: khóa biến tranh chấp thành chờ đợi (thread ngủ), CAS biến tranh chấp thành làm lại (thread bận). Ta sẽ thấy ở mục LongAdder rằng chính điểm yếu này đẻ ra một thiết kế riêng để né.
Trên JVM, CAS không phải thứ ta gọi trực tiếp ở tầng ứng dụng theo nghĩa viết assembly. Nó được phơi ra qua các method nội tại mà JIT compiler dịch thẳng thành chỉ thị CAS của phần cứng - trên x86 là lock cmpxchg, trên ARM là cặp load-linked/store-conditional. Các lớp trong java.util.concurrent.atomic là lớp áo Java đặt lên trên chính những chỉ thị đó. Một chú thích cho chính xác: vòng lặp CAS-retry là mô hình tổng quát, nhưng JIT còn khôn hơn thế — với phép cộng dồn trên x86, getAndAdd được intrinsify thẳng thành lệnh LOCK XADD (fetch-and-add: cộng và trả giá trị cũ trong một lệnh, không cần retry); CAS-loop là đường fallback cho những phép không có lệnh chuyên dụng.
3. Các lớp Atomic*
java.util.concurrent.atomic gói CAS và vòng lặp retry vào những lớp dễ dùng, để ta không phải tự viết do/while mỗi lần. Bốn lớp xương sống là AtomicInteger, AtomicLong, AtomicBoolean và AtomicReference. Mỗi lớp bọc một giá trị duy nhất và cho ta một bộ thao tác nguyên tử trên nó.
Đây đúng là lời giải lock-free cho bộ đếm vé của TicketFlow, và là một biến thể của bước v1 trong capstone - thay bộ đếm giữ chỗ bằng atomic, để đối chiếu với bản synchronized ở bài trước. Khi mỗi sự kiện chỉ cần một bộ đếm số vé đã bán, AtomicInteger thay được nguyên cả khối synchronized:
public class SeatCounter {
private final AtomicInteger sold = new AtomicInteger(0);
private final int capacity;
public SeatCounter(int capacity) { this.capacity = capacity; }
public int reserve() { // không khóa, vẫn nguyên tử
int current = sold.incrementAndGet(); // CAS bên trong, retry tự lo
return current;
}
public int soldCount() { return sold.get(); } // reader không cần khóa
}
incrementAndGet là getAndIncrement đảo chiều trả về - cả hai nguyên tử, và về ngữ nghĩa chạy đúng cái vòng lặp CAS ở mục trên, chỉ là JDK viết hộ (trên x86, như đã chú thích, JIT dịch chúng thành một lệnh LOCK XADD duy nhất - vòng lặp CAS vẫn là mô hình đúng để suy luận, phần cứng chỉ làm tắt khi có lệnh chuyên dụng). So với bản Monitor Pattern, một khách đặt vé cho sự kiện này không còn block một khách đặt vé cho sự kiện khác chỉ vì cùng đi qua một khóa, và ngay cả hai khách trên cùng một sự kiện cũng không ngủ chờ nhau mà cùng thử CAS.
Bộ method của các lớp này chia thành vài nhóm đáng nhớ. Nhóm cập nhật cộng dồn - getAndIncrement, incrementAndGet, getAndAdd, addAndGet - cho số nguyên. Nhóm CAS trần - compareAndSet(expected, new) - là nơi cơ chế lộ ra trực tiếp: nó trả về boolean đúng như chỉ thị phần cứng, và là viên gạch để tự xây những cập nhật phức tạp hơn. Và nhóm cập nhật theo hàm - updateAndGet, accumulateAndGet, getAndUpdate - nhận một lambda mô tả "giá trị mới tính từ giá trị cũ thế nào", rồi tự chạy vòng retry cho bạn:
private final AtomicInteger sold = new AtomicInteger(0);
// chỉ giữ chỗ nếu chưa hết vé — compound action vẫn nguyên tử nhờ retry quanh lambda
public boolean reserveIfAvailable() {
int prev = sold.getAndUpdate(cur -> cur < capacity ? cur + 1 : cur);
return prev < capacity; // prev < capacity nghĩa là lần này ta giành được một chỗ
}
Có một cảnh báo gắn liền với updateAndGet và họ hàng của nó, và nó là hệ quả trực tiếp của vòng lặp retry: hàm bạn truyền vào có thể bị gọi nhiều lần nếu có contention, vì mỗi lần CAS thất bại nó được chạy lại trên giá trị mới. Vì vậy hàm đó phải là hàm thuần - không gây side effect, không sửa state khác, không in log đếm số lần. Một lambda tưởng vô hại như "cộng một và đồng thời tăng một metric" sẽ tăng metric nhiều hơn số lần cập nhật thật. Đây là một cái bẫy tinh vi không lộ ra trong test đơn luồng.
AtomicReference mở chính cơ chế đó cho một tham chiếu object thay vì một số. Nó là cầu nối thẳng tới bài Immutability: nếu ta gói nhiều trường vào một holder immutable rồi đặt holder đó sau một AtomicReference, ta cập nhật nguyên tử cả cụm trường bằng CAS - cứ tạo một holder mới từ holder cũ rồi compareAndSet, retry nếu có ai chen vào.
public record Availability(int sold, int capacity) {
Availability soldOne() { return new Availability(sold + 1, capacity); }
}
private final AtomicReference<Availability> state =
new AtomicReference<>(new Availability(0, 100));
public boolean reserve() {
Availability prev, next;
do {
prev = state.get();
if (prev.sold() >= prev.capacity()) return false; // hết vé, không retry vô ích
next = prev.soldOne();
} while (!state.compareAndSet(prev, next)); // ai chen vào thì prev cũ → CAS trượt → lặp lại
return true;
}
Đây là chỗ atomic vượt qua được volatile của bài trước: bản volatile chỉ đúng khi một writer, còn bản AtomicReference này đúng với nhiều writer cùng giành chỗ, vì CAS lọc ra đúng một người thắng mỗi vòng. Nhưng để ý kỹ giới hạn vẫn còn đó: ta đang cập nhật nguyên tử một tham chiếu duy nhất. Nếu invariant trải trên hai biến độc lập - hai AtomicReference riêng phải đổi cùng lúc - thì CAS từng cái một không gói được cả hai vào một thao tác, và ta lại phải về với khóa. Ranh giới đó là chủ đề kết bài.
4. ABA problem
CAS hỏi đúng một câu: "giá trị hiện tại có còn bằng giá trị tôi kỳ vọng không?" Phần lớn thời gian đó là câu hỏi đủ tốt. Nhưng có một lớp lỗi tinh vi nằm chính ở chỗ nó chỉ so giá trị chứ không so lịch sử. Nếu một biến đi từ A sang B rồi quay lại A trong lúc thread ta đang tính toán, CAS của ta vẫn thấy A và vẫn thành công - dù thế giới đã thay đổi rồi trở lại, không phải đứng yên. Đây là ABA problem.
Với một bộ đếm số nguyên thuần, ABA thường vô hại: nếu giá trị quay lại đúng số cũ thì kết quả phép cộng vẫn đúng, ta không quan tâm nó đã đi vòng nào. ABA chỉ thành lỗi thật khi giá trị mang theo ý nghĩa về danh tính hoặc cấu trúc - kinh điển nhất là khi CAS trên một tham chiếu node trong một cấu trúc dữ liệu lock-free như ngăn xếp. Một thread đọc node đỉnh A, định CAS đỉnh sang node kế của nó; xen giữa, thread khác pop A, pop cả node kế, rồi push lại A. CAS của thread đầu thấy đỉnh vẫn là A nên thành công, nhưng "node kế của A" mà nó định trỏ tới giờ đã bị gỡ khỏi stack - cấu trúc hỏng âm thầm.
Kịch bản hỏng đó nhìn theo trình tự như sau:
sequenceDiagram
participant T1 as Thread 1
participant S as Stack (top)
participant T2 as Thread 2
T1->>S: doc top = A, next cua A = B
Note over T1: tam dung truoc khi CAS(A, B)
T2->>S: pop A, pop B
T2->>S: push lai A
Note over S: top van la A, nhung B da bi go khoi stack
T1->>S: CAS(top: A -> B) THANH CONG
Note over S: top tro vao node B da chet - cau truc hong am thamLời giải là gắn vào mỗi giá trị một dấu hiệu đổi-mỗi-lần-ghi, để CAS so cả dấu hiệu đó chứ không chỉ so giá trị. JDK cho hai lớp đúng cho việc này. AtomicStampedReference ghép tham chiếu với một con tem số nguyên - stamp - mà ta tăng mỗi lần cập nhật; CAS chỉ thành công khi cả tham chiếu lẫn stamp đều khớp, nên một chuyến đi A→B→A bị lộ ngay vì stamp đã nhảy.
// stamp tăng mỗi lần ghi → chuyến đi A→B→A bị phát hiện vì stamp không còn khớp
AtomicStampedReference<Node> top = new AtomicStampedReference<>(initial, 0);
int[] stampHolder = new int[1];
Node cur = top.get(stampHolder); // đọc kèm stamp hiện tại
int stamp = stampHolder[0];
Node next = cur.next;
top.compareAndSet(cur, next, stamp, stamp + 1); // khớp cả ref lẫn stamp mới ghi
AtomicMarkableReference là biến thể gọn hơn: thay vì con tem đếm, nó gắn một bit boolean - một cái cờ "đã bị đánh dấu" - hữu ích cho những thuật toán chỉ cần biết "node này đã bị logic xóa chưa" chứ không cần đếm số lần đổi. Cả hai đều đắt hơn một CAS trần vì phải đóng gói cặp tham-chiếu-và-dấu, nên chỉ rút chúng ra khi ABA thật sự là rủi ro - tức khi ta tự xây cấu trúc lock-free - chứ không rải mặc định lên mọi AtomicReference.
5. LongAdder và LongAccumulator
Quay lại điểm yếu đã chỉ ra ở mục 2: dưới contention cao, vòng lặp CAS của một AtomicLong bắt đầu quay không tải. Hình dung 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. Tất cả tranh đúng một ô nhớ. Mỗi lần một thread CAS thành công, ô đó đổi, làm vô hiệu bản cache của ô đó trên mọi core khác, buộc chúng đọc lại rồi CAS lại. Đây là cache contention, và nó biến một thao tác lẽ ra rẻ thành một nút cổ chai khi số core tăng.
LongAdder (Java 8) né vấn đề bằng một ý tưởng đẹp: nếu nhiều thread tranh một ô gây đau, thì đừng bắt chúng tranh một ô. Thay vì giữ một biến đếm duy nhất, LongAdder rải tổng ra một mảng nhiều ô - gọi là cells - mỗi thread dưới contention cộng vào một ô riêng của nó, gần như không đụng thread khác. Đây là striped counter - bộ đếm chia sọc. Khi cần giá trị thật, sum() cộng tất cả các ô lại.
private final LongAdder requestsServed = new LongAdder();
public void onRequest() {
requestsServed.increment(); // cộng vào ô riêng theo thread, ít đụng độ
}
public long total() {
return requestsServed.sum(); // cộng dồn tất cả ô — KHÔNG nguyên tử với increment đang chạy
}
Đặt hai thiết kế cạnh nhau cho thấy rõ vì sao chia sọc giải được cache contention — bên trái mọi thread dồn vào một ô, bên phải mỗi thread gõ vào ô của riêng nó:
flowchart TD
subgraph AL[AtomicLong: moi thread tranh MOT o]
T1[Thread 1] --> V[value - CAS lien tuc that bai]
T2[Thread 2] --> V
T3[Thread 3] --> V
T4[Thread 4] --> V
end
subgraph LA[LongAdder: moi thread hash vao cell rieng]
U1[Thread 1] --> B0[base]
U2[Thread 2] --> C0[Cell 0]
U3[Thread 3] --> C1[Cell 1]
U4[Thread 4] --> C2[Cell 2]
B0 --> S["sum() = base + Cell 0 + Cell 1 + Cell 2"]
C0 --> S
C1 --> S
C2 --> S
endKhác biệt hiệu năng giữa hai thiết kế không hề vi tế. Một bức tranh định tính để hình dung: với một thread duy nhất, AtomicLong và LongAdder gần như ngang nhau — AtomicLong thậm chí nhỉnh hơn chút vì không phải quản lý mảng cell. Nhưng đẩy lên hàng chục thread (cỡ 32 thread cùng dồn dập tăng một bộ đếm), AtomicLong tụt dốc rõ rệt: mọi thread tranh đúng một cache line, CAS thất bại liên tục, và cache line đó nảy qua lại giữa các core sau mỗi lần ghi thành công. LongAdder trong cùng điều kiện giữ throughput gần như phẳng nhờ mỗi thread cộng vào cell riêng — chênh lệch thường ở mức cỡ chục lần, tùy số core và mức độ dồn dập. Quy tắc rút gọn: contention ghi càng cao, lợi thế tản stripe càng lớn; còn không có contention thì stripe chỉ là bộ nhớ tốn thêm.
Đánh đổi nằm ngay trong cái khéo đó. Một, LongAdder tốn nhiều bộ nhớ hơn một AtomicLong vì giữ cả một mảng cell. Hai, và quan trọng hơn, sum() không phải một snapshot nguyên tử: nó duyệt các ô và cộng, trong khi các ô khác vẫn có thể đang được ghi, nên giá trị trả về là một ước lượng đúng tại một thời điểm gần chứ không phải con số chính xác đóng băng tại một lằn ranh. Điều đó cho ta đúng tiêu chí chọn: LongAdder thắng khi ghi rất nhiều, đọc thưa - như đếm thống kê, metric, throughput counter - nơi ta cộng liên tục và chỉ thỉnh thoảng đọc tổng. Khi cần đọc giá trị chính xác thường xuyên ngay sau mỗi lần ghi, hoặc cần một compareAndSet thật trên bộ đếm, AtomicLong mới đúng.
LongAccumulator là tổng quát hóa của LongAdder. Thay vì cố định phép cộng, nó nhận một hàm kết hợp hai toán hạng - một LongBinaryOperator - và một giá trị gốc, rồi gộp các đóng góp bằng chính hàm đó. LongAdder chẳng qua là một LongAccumulator với phép cộng và gốc 0. Với nó ta gom được max, min, hay tích theo cùng kiểu chia sọc:
// theo dõi số vé bán được trong một lần giao dịch cao nhất, dưới tải ghi nặng
LongAccumulator peakBatch = new LongAccumulator(Long::max, 0);
peakBatch.accumulate(batchSize); // gộp bằng max, rải sọc như LongAdder
long peak = peakBatch.get();
Vì hàm kết hợp được áp theo thứ tự không xác định trên các ô, nó phải có tính giao hoán và kết hợp để kết quả không phụ thuộc thứ tự gộp - max và cộng thỏa, phép trừ thì không.
6. VarHandle
Cho đến giờ ta luôn thao tác qua một object Atomic* riêng 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ó sẵn của một object, mà không phải bọc nó vào một wrapper riêng - vừa để tiết kiệm một lớp object, vừa để giữ field ở dạng nguyên thủy. VarHandle (Java 9) là công cụ cho việc đó, và nó thay cho bộ ba lớp field updater cũ — AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater — vốn cồng kềnh (dựng qua reflection, kiểm tra access mỗi lần dùng) và chậm hơn.
Một VarHandle là một tham chiếu có kiểu, mạnh, tới một biến - một field, một phần tử mảng - cho phép gọi các thao tác nguyên tử trực tiếp lên biến đó. Ta xin nó một lần qua MethodHandles.lookup() rồi dùng lại:
public class Node {
private volatile int state; // field thường, vẫn 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 thẳng trên field, không cần wrapper
}
}
Nhưng giá trị thật của VarHandle không chỉ là CAS trên field trần - mà là nó phơi ra cả một quang phổ chế độ truy cập với memory ordering ở các mức khác nhau, thứ mà các lớp Atomic* không cho chọn. Đây là phần dành cho người đã nắm chắc happens-before của bài trước, và chỉ nên chạm tới khi đo đạc chứng minh nó đáng.
Có bốn nhóm chế độ, từ lỏng tới chặt. Plain (get/set) - như truy cập field thường, không bảo đảm ordering gì, để compiler tự do tối ưu. Opaque - bảo đảm thao tác thật sự xảy ra và các thao tác opaque trên cùng biến không bị reorder với nhau, nhưng không bắc cầu happens-before sang biến khác. Acquire/Release (getAcquire/setRelease) - đúng ngữ nghĩa của một cặp đọc-ghi volatile nhưng một chiều: một setRelease bảo đảm mọi ghi trước nó hiển thị cho thread nào đọc bằng getAcquire, mà không áp đặt hàng rào hai chiều đầy đủ. Volatile (getVolatile/setVolatile/compareAndSet) - ngữ nghĩa volatile đầy đủ, mạnh nhất và đắt nhất.
Ý nghĩa thực dụng của cái thang này: volatile của bài trước là một hàng rào hai chiều, an toàn nhưng đắt vì cấm cả những reorder thật ra vô hại cho bài toán. Acquire/release cho ta đúng một nửa hàng rào ta cần trong nhiều thuật toán lock-free - chẳng hạn công bố một object đã khởi tạo xong - với chi phí thấp hơn. Nhưng chọn sai mức là một lỗi không bao giờ lộ ra trên một kiến trúc CPU mà lại bung ra trên kiến trúc khác có mô hình bộ nhớ lỏng hơn. Vì vậy ở mức giới thiệu, điều cần nhớ gọn là: VarHandle tồn tại, nó thay họ Atomic*FieldUpdater, nó cho CAS trên field sẵn có, và nó mở cửa tới memory ordering tinh chỉnh - nhưng các mức dưới volatile là lãnh địa của thư viện và chuyên gia, không phải lựa chọn mặc định cho code ứng dụng.
7. 📚 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, và bốn mức memory ordering (plain/opaque/acquire-release/volatile) đến từ đâu. - Package
java.util.concurrent.atomic(Java 21) — javadoc tổng quan, đặc biệt đoạn mô tả memory effects của từng nhóm method. - JLS §17.4 — Memory Model — nền hình thức của happens-before mà mọi thao tác atomic phải tôn trọng.
Ghi chú: JEP 193 đáng đọc vì nó kể thẳng động cơ lịch sử: thư viện lock-free trước Java 9 buộc phải dùng sun.misc.Unsafe không chuẩn; VarHandle chuẩn hóa đúng các thao tác đó kèm mô hình ordering vay từ C/C++11. Đọc nó xong sẽ hiểu vì sao quang phổ access mode ở mục 6 trông "giống C++" đến vậy.
8. Liên hệ các bài khác
- Bài 03 — Thread safety: nơi định nghĩa read-modify-write và chỉ ra
sold++ba bước — chính bài toán mà CAS giải bằng đường lock-free. - Bài 05 — Immutability: immutable holder là nửa kia của mẫu
AtomicReference+ CAS — holder lo nhất quán, CAS lo nhiều writer. - Bài 06 — volatile & synchronized: JVM dùng đúng lệnh CAS này cho thin lock; đọc lại mục mark word ở đó để thấy CAS xuất hiện ở cả hai tầng.
- Bài 08 — ReentrantLock và Condition: khi invariant trải trên nhiều biến, CAS bó tay và explicit lock là bước kế tiếp.
- Bài 10 — Delegation & concurrent collections:
ConcurrentHashMapvà họ hàng dựng trên chính CAS + striping — dùng chúng là hưởng lock-free mà không phải tự viết retry loop.
9. Tổng kết
Với một thao tác trên đúng một biến, ta không cần khóa. Compare-and-swap - một chỉ thị nguyên tử của phần cứng - cho ta cập nhật lạc quan: đọc, tính, rồi ghi với điều kiện chưa ai chen vào, thất bại thì thử lại. Trên nền đó, các lớp Atomic* đóng gói CAS và vòng lặp retry thành những thao tác sẵn dùng - incrementAndGet, compareAndSet, updateAndGet - và AtomicReference mở chính cơ chế ấy cho một tham chiếu, ghép đẹp với holder immutable của bài immutability để cập nhật nguyên tử cả cụm trường mà vẫn không khóa. Đó chính là cách bản v1 lock-free thay bộ đếm giữ chỗ của TicketFlow cho bản synchronized, nhẹ hơn và ít contention hơn khi tải vừa phải.
Quanh con đường lock-free có những góc cần thuộc. ABA nhắc rằng CAS chỉ so giá trị chứ không so lịch sử, và khi danh tính quan trọng ta cần AtomicStampedReference hay AtomicMarkableReference để so cả con tem. LongAdder và LongAccumulator nhắc rằng dưới contention ghi rất nặng, tranh một ô là tự thắt cổ chai, và chia sọc nhiều ô đổi lại bộ nhớ và một sum() không nguyên tử để lấy throughput - đúng cho ghi nhiều đọc thưa. VarHandle cho CAS thẳng trên field sẵn có và mở quang phổ memory ordering, nhưng các mức dưới volatile thuộc về chuyên gia.
Và đây là ranh giới cứng của cả bài, đúng chỗ bài sau sẽ bắt đầu. CAS cho atomicity mà không cần khóa, nhưng chỉ trên đúng một biến. Khi một invariant trải trên nhiều biến phải đổi cùng lúc, hoặc một compound action phải bao trọn vài thao tác như một khối không chia cắt, CAS từng biến một bó tay - lọc được người thắng trên từng ô, nhưng không gói được nhiều ô vào một thao tác nguyên tử. Lúc đó ta buộc quay về khóa. Và synchronized thì, như đã thấy, thiếu nhiều thứ một hệ thống thật cần: không thử-rồi-thôi, không timeout, không hủy được giữa chừng, không tách đọc khỏi ghi. Những khả năng đó là nội dung bài kế tiếp về explicit locks, nơi BookingService của TicketFlow sẽ thử thay khóa của v1 bằng ReentrantLock (rồi read-write lock và StampedLock ở bài 09) để thấy mỗi món mở ra điều gì mà CAS lẫn synchronized đều không cho.
10. Tự kiểm tra
Q1Vì sao lambda truyền vào updateAndGet hoặc accumulateAndGet phải là hàm thuần, không side effect?▸
Vì các method này cài bằng vòng lặp CAS-retry: đọc giá trị hiện tại, chạy lambda để tính giá trị mới, rồi compareAndSet. Khi có contention, CAS thất bại và lambda được chạy lại trên giá trị mới — có thể nhiều lần cho một lần cập nhật logic.
Nếu lambda có side effect (tăng metric, ghi log, sửa state khác), side effect đó xảy ra nhiều hơn số lần cập nhật thật. Bug này không lộ trong test đơn luồng vì khi không tranh chấp, lambda chỉ chạy đúng một lần.
Q2ABA problem là gì? Vì sao bộ đếm số nguyên thường miễn nhiễm còn stack lock-free thì không, và stamp giải quyết thế nào?▸
ABA là tình huống một biến đi từ A sang B rồi quay lại A trong lúc thread ta đang tính toán — CAS chỉ so giá trị, thấy vẫn là A nên thành công, dù thế giới đã thay đổi rồi trở lại.
Với bộ đếm thuần, ABA thường vô hại: giá trị quay về đúng số cũ thì phép cộng tiếp theo vẫn đúng. Với cấu trúc lock-free như stack, giá trị là danh tính node: node A bị pop rồi push lại, nhưng "node kế của A" mà ta đã đọc trước đó có thể đã bị gỡ — CAS thành công và lặng lẽ nối stack vào một node không còn thuộc cấu trúc.
AtomicStampedReference ghép tham chiếu với một con tem tăng mỗi lần ghi; CAS phải khớp cả tham chiếu lẫn stamp. Chuyến đi A-B-A bị lộ vì stamp đã nhảy dù tham chiếu khớp.
Q3Khi nào nên thay AtomicLong bằng LongAdder, và cái giá phải trả là gì?▸
Khi workload là ghi rất nhiều, đọc thưa dưới contention cao — metric, throughput counter, đếm thống kê. AtomicLong bắt mọi thread tranh một ô nhớ: CAS thất bại liên tục và cache line nảy giữa các core. LongAdder tản tổng ra nhiều cell, mỗi thread cộng vào cell riêng, nên throughput giữ gần như phẳng khi số thread tăng.
Giá phải trả: tốn bộ nhớ cho mảng cell, và sum() không phải snapshot nguyên tử — nó duyệt cộng các cell trong khi cell khác vẫn đang được ghi, nên kết quả là ước lượng tại một thời điểm gần. Cần giá trị chính xác từng bước hoặc cần compareAndSet trên bộ đếm thì AtomicLong mới đúng.
Q4Khóa biến tranh chấp thành gì, CAS biến tranh chấp thành gì? Hệ quả của khác biệt đó với CPU và độ trễ?▸
Khóa là chiến lược bi quan: thread thua bị block — đi ngủ qua hệ điều hành, trả giá hai lần context switch nhưng không đốt CPU trong lúc chờ. CAS là chiến lược lạc quan: thread thua làm lại — đốt CPU cho vòng retry nhưng không bao giờ ngủ, không syscall.
Hệ quả: contention thấp thì CAS thắng áp đảo (phần lớn lần thử thành công ngay, độ trễ cỡ một lệnh CPU). Contention rất cao thì retry quay không tải, CPU bị đốt vô ích — lúc đó hoặc tản contention ra (LongAdder), hoặc chấp nhận block bằng khóa.
Q5Vì sao bản reserve() dùng AtomicReference đúng với nhiều writer, trong khi volatile holder ở bài Immutability chỉ đúng với một writer?▸
Bản volatile chỉ là gán đè: hai writer cùng đọc holder cũ, cùng dựng holder mới từ nó, rồi cùng ghi — một cập nhật bị ghi đè mất, đúng kiểu lost update của read-modify-write.
Bản AtomicReference thay phép gán bằng compareAndSet(prev, next): chỉ thành công nếu tham chiếu vẫn là prev đã đọc. Hai writer cùng vòng thì đúng một người thắng; người thua phát hiện CAS trượt, đọc lại holder mới và tính lại. CAS lọc tuần tự hóa các writer mà không cần khóa.
Q6Vì sao hàm kết hợp truyền vào LongAccumulator phải có tính 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 — thứ tự phụ thuộc thread nào hash vào cell nào và duyệt mảng 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à định nghĩa của tính kết hợp và giao hoán.
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.
Q7Ranh giới cứng của CAS là gì? Cho ví dụ một bài toán mà atomic không giải được và phải quay về khóa.▸
CAS chỉ nguyên tử trên đúng một vị trí nhớ. Khi một invariant trải trên nhiều biến độc lập phải đổi cùng lúc, CAS từng biến một không gói được cả cụm thành một thao tác — giữa hai CAS vẫn có cửa sổ mà thread khác quan sát được trạng thái vi phạm invariant.
Ví dụ: chuyển tiền giữa hai tài khoản (trừ bên này, cộng bên kia phải là một khối), hoặc hai AtomicInteger sold và remaining với invariant tổng không đổi. Mẹo gom vào một holder immutable sau AtomicReference cứu được một phần — nhưng chỉ khi mọi đường ghi đi qua đúng tham chiếu đó; hai cấu trúc tách rời thì phải dùng khóa.
Bài tiếp theo: ReentrantLock và Condition — khóa tường minh khi synchronized không đủ
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