Mutex & atomic (CAS) — bảo vệ critical section khỏi race
Hai công cụ chặn race: phép toán atomic (CAS) và mutex (khoá vùng găng) — cơ chế, chi phí, khi nào dùng cái nào, và lựa chọn thứ ba: đừng chia sẻ.
TL;DR: Để chặn race, bạn có hai công cụ và một lối thoát. Atomic (như AtomicLong) dựa trên một lệnh phần cứng — CAS (compare-and-swap): "nếu giá trị vẫn là X thì đổi thành Y, nếu không thì thử lại". Nó lock-free, không block luồng, rẻ khi ít tranh chấp — hợp cho thao tác đơn giản trên một biến. Mutex (khoá — synchronized, ReentrantLock) khoanh một critical section để mỗi lần chỉ một luồng vào; luồng khác block chờ. Mutex mạnh hơn (bảo vệ được cả chuỗi nhiều biến) nhưng đắt hơn khi tranh chấp vì phải park/wake qua kernel. Lối thoát thứ ba, thường tốt nhất: đừng chia sẻ — immutable, thread-local, hoặc message passing — không chia sẻ thì không cần đồng bộ.
Ở bài trước, demo hai luồng đếm cho ra 1348925 thay vì 2000000 vì count++ bị chia cắt. Giờ ta sửa nó. Nhưng "sửa" không chỉ có một cách, và chọn sai cách khiến code hoặc chậm, hoặc vẫn sai, hoặc sinh bug mới (deadlock — bài sau). Bài này đặt ba lựa chọn cạnh nhau: khoá lại, làm nguyên tử, hay tránh chia sẻ — kèm cơ chế và cái giá của từng cách để bạn chọn có căn cứ.
1. Analogy — nhà vệ sinh một phòng và tấm bảng đặt bàn
Hai cách điều phối một tài nguyên dùng chung:
Mutex — nhà vệ sinh một phòng có khoá. Ai vào thì khoá cửa; người tiếp theo tới thấy khoá thì ngồi chờ ngoài (block) cho tới khi người trong mở khoá ra. Chỉ một người trong phòng tại một thời điểm (mutual exclusion). Chờ đợi là "thật" — người chờ ngồi không làm gì, tốn thời gian.
Atomic (CAS) — đặt bàn nhà hàng qua app. Bạn thấy "bàn số 5 còn trống", bấm đặt. App kiểm tra: nếu bàn 5 vẫn trống thì gán cho bạn; nếu ai đó vừa đặt mất, app báo "trượt rồi, chọn lại" — bạn thử bàn khác ngay, không ngồi chờ. Đó chính là CAS: "nếu giá trị vẫn như tôi thấy thì đổi, không thì báo trượt để tôi thử lại".
| Đời thường | Cơ chế đồng bộ |
|---|---|
| Nhà vệ sinh một phòng | Critical section được mutex bảo vệ |
| Khoá cửa khi vào | lock() / vào synchronized |
| Ngồi chờ ngoài khi thấy khoá | Luồng block (bị park) chờ khoá |
| Đặt bàn qua app, trượt thì chọn lại | CAS — thử, thất bại thì retry (không block) |
| "Nếu bàn còn trống thì đặt" | compareAndSet(expected, new) |
Mutex = chờ (block cho tới lượt). Atomic/CAS = thử lại (không chờ, trượt thì làm lại). Chờ hợp khi việc trong phòng dài; thử-lại hợp khi việc rất ngắn và ít khi đụng độ.
2. Critical section và mutual exclusion
Critical section (vùng găng) là đoạn code truy cập shared mutable state mà phải chạy như một khối nguyên vẹn — không được để luồng khác chen vào giữa. Trong demo bài trước, cả count++ là một critical section: ba bước load/add/store phải liền mạch.
Đồng bộ hoá nghĩa là đảm bảo mutual exclusion (loại trừ lẫn nhau): tại mọi thời điểm, tối đa một luồng ở trong critical section cho cùng một dữ liệu. Khi đó không còn interleaving xấu, vì không có hai luồng nào cùng ở trong đó để giẫm chân nhau.
Hai họ công cụ đạt mutual exclusion theo hai cách khác nhau: atomic làm cho thao tác bản thân nó không thể bị chia cắt; mutex dựng một cánh cửa quanh đoạn code để mỗi lần một luồng đi qua. Ta xét từng cái.
3. Atomic — CAS và vòng lặp thử lại
Phần cứng đa lõi cung cấp một lệnh nền tảng: compare-and-swap (CAS). Ngữ nghĩa, thực thi nguyên tử trong một lệnh:
CAS(diachi, expected, newValue):
doc gia tri hien tai tai diachi
NEU no == expected:
ghi newValue vao diachi
tra ve true (thanh cong)
NGUOC LAI:
khong doi gi
tra ve false (that bai -> nguoi goi thu lai)
Vì cả "so sánh" và "ghi" gói trong một lệnh phần cứng, không luồng nào chen vào giữa được. Trên đó, các phép cập nhật tổng quát như AtomicLong.updateAndGet(fn) / getAndUpdate(fn) / accumulateAndGet(x, fn) xây một vòng lặp CAS:
// Y tuong ben trong AtomicLong.updateAndGet(fn)
long prev, next;
do {
prev = get(); // doc gia tri hien tai
next = fn.applyAsLong(prev); // tinh gia tri moi tu ham cap nhat
} while (!compareAndSet(prev, next)); // ghi neu chua ai doi; that bai -> lap lai
return next;
flowchart TB
A["doc prev = get()"] --> B["tinh next = fn(prev)"]
B --> C{"CAS(prev, next)<br/>gia tri van la prev?"}
C -->|"true: khong ai chen"| D["xong, tra ve next"]
C -->|"false: luong khac da doi"| AĐọc vòng lặp này: đọc prev, tính next, rồi CAS — nếu giá trị vẫn là prev (không ai chen) thì ghi next, xong. Nếu có luồng khác vừa đổi, CAS trả false, vòng lặp chạy lại với prev mới. Không luồng nào bị block; luồng "trượt" chỉ đơn giản thử lại. Đây là ý nghĩa của lock-free: một luồng chậm/bị treo không chặn luồng khác tiến tới.
Sửa demo bài trước bằng atomic:
Đoán trước: đổi int count thành AtomicLong và count++ thành incrementAndGet() — giờ chạy lại hai lần, output còn khác nhau giữa các lần chạy không?
import java.util.concurrent.atomic.AtomicLong;
public class AtomicDemo {
static AtomicLong count = new AtomicLong(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 1_000_000; i++) {
count.incrementAndGet(); // nguyen tu, khong lost update
}
};
Thread a = new Thread(task), b = new Thread(task);
a.start(); b.start(); a.join(); b.join();
System.out.println(count.get()); // luon in 2000000
}
}
Kết quả giờ luôn là 2000000, mọi lần chạy. Atomic hợp nhất cho: bộ đếm, cờ, tham chiếu đổi bằng CAS — các thao tác đơn trên một biến.
CAS chỉ nguyên tử trên một ô nhớ. Nếu bất biến của bạn trải trên nhiều biến — ví dụ "chuyển tiền: trừ tài khoản A và cộng tài khoản B phải cùng lúc" — atomic đơn lẻ không đủ. Bạn không thể CAS hai biến trong một lệnh. Lúc đó cần mutex để khoanh cả chuỗi. Atomic mạnh cho thao tác đơn; mutex mạnh cho chuỗi nhiều bước.
4. Mutex — khoá critical section
Mutex (mutual exclusion lock) khoanh một critical section: luồng phải lấy khoá trước khi vào, và nhả khoá khi ra. Luồng thứ hai tới thấy khoá đang giữ thì block (bị đình chỉ) cho tới khi khoá được nhả.
Java có hai cách chính. synchronized (khoá ngầm trên một object monitor):
static int count = 0;
static final Object lock = new Object();
static void inc() {
synchronized (lock) { // lay khoa; luong khac cho o day
count++; // critical section -- chi 1 luong vao
} // ra khoi block -> nha khoa tu dong
}
Và ReentrantLock (khoá tường minh, linh hoạt hơn):
import java.util.concurrent.locks.ReentrantLock;
static final ReentrantLock lock = new ReentrantLock();
static void inc() {
lock.lock();
try {
count++; // critical section
} finally {
lock.unlock(); // LUON nha trong finally, ke ca khi exception
}
}
Sức mạnh của mutex là bảo vệ được cả chuỗi nhiều biến, thứ atomic không làm được:
// Chuyen tien: hai buoc phai cung nguyen khoi -> can mutex, atomic don khong du
synchronized (lock) {
accountA -= amount;
accountB += amount;
}
Khác biệt then chốt với atomic: khi tranh chấp, luồng chờ mutex bị block (nhường CPU, không chạy) thay vì bận thử lại. Điều đó vừa là ưu (không đốt CPU khi phải chờ lâu) vừa là nhược (block/wake tốn — mục sau).
synchronized đủ và gọn cho phần lớn trường hợp. ReentrantLock thêm khả năng: tryLock() (thử lấy khoá, không được thì bỏ đi thay vì chờ mãi — dùng để phá deadlock ở bài 03), tryLock(timeout) (chờ có hạn), lockInterruptibly(), và tuỳ chọn fair lock (cấp khoá theo thứ tự chờ). Cả hai đều reentrant: luồng đang giữ khoá có thể lấy lại chính khoá đó mà không tự deadlock.
5. Khoá tốn kém ở đâu — uncontended hay contended?
Đây là phần quyết định chọn công cụ nào. Chi phí đồng bộ chia làm hai chế độ, cách nhau rất xa:
Uncontended (không tranh chấp) — chỉ một luồng, không ai đang giữ khoá. Cả atomic lẫn mutex ở chế độ này đều rẻ: chỉ một thao tác CAS trên vùng nhớ (với synchronized, JVM thực thi nhanh trong user space, không đụng kernel). Chi phí cỡ nanosecond — vài đến vài chục nanosecond.
Contended (có tranh chấp) — nhiều luồng cùng giành. Đây là lúc hai công cụ tách nhau:
- Atomic: luồng trượt CAS chỉ thử lại — vẫn ở user space, tốn thêm vài vòng lặp. Đắt lên nhưng không phải gọi kernel. Trừ khi tranh chấp cực gắt (nhiều luồng đập vào cùng một biến), atomic vẫn khá rẻ. (Riêng
incrementAndGet()trên x86 hiện đại được JIT dịch thẳng thành một lệnhlock xadd— luôn thành công trong một lần, không cần vòng retry; mô hình CAS-loop mô tả ở mục 3 đúng chocompareAndSetvà các phép cập nhật tổng quát hơn.) - Mutex: luồng không lấy được khoá phải block — bị đình chỉ và cất đi. Trên Linux, việc này đi qua futex (fast userspace mutex): đường không tranh chấp nằm trong user space bằng lệnh atomic, chỉ khi phải thật sự chờ mới gọi syscall vào kernel để park luồng; và khi nhả khoá, kernel wake luồng đang chờ. Một cặp park/wake kèm context switch (Module 3) tốn cỡ hàng trăm nanosecond tới vài microsecond — tức đắt hơn đường uncontended khoảng hàng chục tới hàng trăm lần.
Chi phi tuong doi (thu tu do lon, tuy CPU/JVM):
uncontended CAS / lock |# ~ vai - vai chuc ns
contended atomic retry |### ~ nhieu chuc ns
contended mutex (park + context switch + wake) |################ ~ hang tram ns - vai us
Điểm nóng cực gắt và LongAdder. Khi rất nhiều luồng cùng đập vào một AtomicLong, hai chi phí ẩn nổi lên: các phép cập nhật tổng quát dựa trên compareAndSet bị trượt và lặp lại liên tục (đốt CPU), và tệ hơn, cache line chứa biến đó nảy qua nảy lại giữa các core mỗi khi một core ghi — chính là cache-line contention (true sharing — nhiều core cùng ghi ĐÚNG một biến, cache line bị giành qua lại giữa chúng). Đây là họ hàng với false sharing — khi hai biến KHÁC NHAU vô tình chung một cache line — mà khoá Bộ nhớ đào sâu. LongAdder né cả hai: nó rải bộ đếm ra nhiều cell, mỗi luồng cộng vào cell riêng nên gần như không đụng nhau, chỉ khi gọi sum() mới cộng dồn các cell lại. Đánh đổi là giá trị không chính xác tức thời mỗi lần đọc — nên LongAdder hợp cho điểm nóng ghi nhiều, đọc thưa (như metric), còn AtomicLong hợp khi tranh chấp vừa hoặc cần giá trị chính xác ngay mỗi thao tác.
Bài học rút ra: chi phí đồng bộ nằm ở tranh chấp, không ở bản thân khoá. Một khoá gần như không bao giờ bị tranh (uncontended) thì rẻ dù là mutex. Ngược lại, một điểm nóng nhiều luồng đập vào sẽ đắt dù bạn dùng atomic. Vì thế tối ưu quan trọng nhất không phải "chọn atomic hay mutex" mà là giảm tranh chấp: thu nhỏ critical section, chia nhỏ dữ liệu (sharding), hoặc — tốt nhất — không chia sẻ.
Có cám dỗ viết vòng lặp while (!flag) {} để "chờ nhanh" thay vì dùng khoá block. Với chờ rất ngắn (vài chục ns) spin có thể thắng vì né được context switch. Nhưng chờ lâu mà spin thì đốt 100% một core làm việc vô ích, cướp CPU của chính luồng bạn đang chờ. Để JVM/OS quyết: synchronized và ReentrantLock đã dùng chiến lược spin-ngắn-rồi-park hợp lý. Đừng tự viết busy-wait cho chờ dài.
6. Lựa chọn thứ ba — đừng chia sẻ
Cả atomic lẫn mutex đều là cách quản lý việc chia sẻ dữ liệu. Nhưng race chỉ tồn tại ở shared mutable state. Bỏ đi chữ "shared" hoặc chữ "mutable" thì vấn đề biến mất — không cần đồng bộ gì cả. Đây thường là thiết kế tốt nhất:
Immutable (bất biến). Nếu dữ liệu không bao giờ đổi sau khi tạo, nhiều luồng đọc chung hoàn toàn an toàn — không có ghi thì không có race. Trong Java: record, String, List.copyOf(...), field final. Muốn "đổi" thì tạo object mới thay vì sửa tại chỗ.
// Immutable: chia se thoai mai giua cac luong, khong can khoa
record Point(int x, int y) {} // khong the sua sau khi tao
Thread confinement (mỗi luồng một bản). Giữ dữ liệu cục bộ trong một luồng, không để lộ ra ngoài. Biến local trên stack tự nhiên đã confined. Khi cần trạng thái per-thread, ThreadLocal cho mỗi luồng một bản riêng — không chia sẻ nên không cần khoá.
Message passing. Thay vì nhiều luồng cùng sửa một dữ liệu chung, cho mỗi dữ liệu một "chủ" duy nhất và các luồng khác gửi thông điệp qua hàng đợi (BlockingQueue) để yêu cầu thay đổi. Chỉ luồng chủ chạm dữ liệu → không chia sẻ ghi → không race. Đây là triết lý của Go ("share memory by communicating") và actor model. Nó cũng là cầu nối tới bài 04: khi hai tiến trình cần phối hợp, message passing qua IPC là lối đi tự nhiên, và nó tránh được deadlock do tranh khoá (bài 03) — dù vẫn có thể kẹt kiểu khác nếu hai bên cùng block chờ nhận thông điệp của nhau (communication deadlock).
- Không chia sẻ (immutable / confinement / message passing) — an toàn nhất, rẻ nhất. 2) Nếu buộc chia sẻ và chỉ thao tác đơn trên một biến → atomic. 3) Nếu cần bảo vệ cả chuỗi nhiều biến → mutex, giữ critical section nhỏ nhất có thể. Đừng nhảy thẳng tới khoá khi có thể tránh chia sẻ.
7. Áp dụng vào code của bạn
Biến ba cơ chế trên thành một quy tắc chọn nhanh khi bạn đứng trước một đoạn shared mutable state:
- Counter / metric / cờ đơn → atomic. Một biến với thao tác read-modify-write đơn (đếm, tăng, hoán cờ)? Dùng
AtomicLong/AtomicInteger/AtomicReference— lock-free, không block, không rủi ro deadlock. - Bất biến trải nhiều biến → mutex. Chuỗi thao tác phải nhất quán như một khối (chuyển tiền từ A sang B, cập nhật nhiều field liên quan)? Khoanh bằng
synchronizedhoặcReentrantLock, giữ critical section nhỏ nhất có thể. - Có thể không chia sẻ → đừng chia sẻ. Dữ liệu có thể làm immutable (
record, fieldfinal), giữ cục bộ (biến local,ThreadLocal), hay đẩy qua message passing (BlockingQueue)? Chọn cách đó trước tiên — không chia sẻ thì khỏi cần cả atomic lẫn mutex.
Một refactor thường gặp: dùng synchronized cho một biến đơn là lấy dao mổ trâu giết gà. Thay bằng atomic vừa gọn vừa rẻ hơn khi tranh chấp:
// TRUOC: mutex cho mot bien don -> block khong can thiet
static long hits = 0;
static final Object lock = new Object();
static void onHit() {
synchronized (lock) { hits++; } // 1 bien, 1 thao tac -> khong can mutex
}
// SAU: atomic -> lock-free, dung cong cu nhe nhat du viec
static final AtomicLong hits = new AtomicLong();
static void onHit() {
hits.incrementAndGet();
}
Nhưng đừng làm ngược: nếu hai biến phải cùng đổi (số dư A và số dư B), tách thành hai atomic là sai — đó đúng là lúc cần một mutex khoanh cả chuỗi (xem Nhầm 1 ngay dưới).
8. Pitfall tổng hợp
❌ Nhầm 1 — dùng atomic cho bất biến trải nhiều biến:
// SAI: hai atomic rieng le KHONG lam hai buoc thanh mot nguyen khoi
balanceA.addAndGet(-amount);
balanceB.addAndGet(+amount); // luong khac co the xen giua -> thay tong sai
✅ Mỗi addAndGet nguyên tử riêng nó, nhưng cặp thì không. Một luồng khác quan sát giữa hai lệnh sẽ thấy tiền "bốc hơi". Bất biến trên nhiều biến cần một mutex khoanh cả chuỗi, không phải ghép nhiều atomic.
❌ Nhầm 2 — critical section quá to, nuốt cả việc chậm:
synchronized (lock) {
var data = fetchFromNetwork(); // I/O cham GIU khoa -> moi luong khac ket
cache.put(key, data);
}
✅ Giữ khoá khi làm I/O khiến mọi luồng khác chờ dài — biến uncontended thành contended nặng. Thu nhỏ critical section: chỉ khoá đúng lúc chạm shared state (cache.put), làm phần chậm (fetchFromNetwork) ngoài khoá.
❌ Nhầm 3 — quên nhả khoá khi có exception:
lock.lock();
count++; // neu nem exception o day...
lock.unlock(); // ...dong nay KHONG chay -> khoa bi giu mai -> treo
✅ Luôn unlock() trong finally (xem mục 4). Với synchronized thì JVM tự nhả khi ra khỏi block kể cả lúc exception — đó là một lý do synchronized an toàn hơn cho trường hợp đơn giản.
9. 📚 Deep Dive — futex và CAS
Nguồn chính:
- futex(2) — Linux man page — cơ chế fast userspace mutex mà mutex trên Linux dựa vào. Đọc phần mô tả: đường không tranh chấp dùng lệnh atomic trong user space, "the kernel maintains no information about the lock state"; chỉ khi phải chờ lâu mới gọi
futex()vào kernel. Đây là lý do uncontended lock rẻ còn contended lock đắt. - OSTEP — Chapter 28: Locks — xây khoá từ số 0: test-and-set, compare-and-swap, và ba tiêu chí đánh giá khoá (correctness, fairness, performance). Mục "Evaluating Locks" giải thích chính cái trade-off spin-vs-block ở mục 5.
Ghi chú: Hiểu futex giải thích một điều phản trực giác: synchronized "chậm" là huyền thoại cũ. Trên JVM hiện đại, khoá không tranh chấp gần như miễn phí; chi phí thật đến từ tranh chấp. Nên tối ưu đúng chỗ là giảm tranh chấp, không phải né khoá bằng mọi giá.
10. Liên hệ các bài khác
- Bài 01 — Race condition: vấn đề mà cả bài này đi giải; critical section chính là chỗ
count++cần được bảo vệ. - Bài 03 — Deadlock: mặt trái của mutex — dùng nhiều khoá sai thứ tự sinh ra deadlock;
tryLock(giới thiệu ở đây) là một cách phá. - Bài 04 — IPC: message passing (mục 6) mở rộng thành cách hai tiến trình phối hợp mà không chia sẻ bộ nhớ.
- Module 3 — Trạng thái & context switch: "block chờ khoá" chính là chuyển luồng sang trạng thái blocked; chi phí park/wake là một context switch.
11. Tóm tắt
- Critical section là đoạn chạm shared mutable state cần chạy nguyên khối; đồng bộ = đảm bảo mutual exclusion (mỗi lần một luồng).
- Atomic dựa trên CAS phần cứng ("nếu vẫn là X thì đổi thành Y, không thì báo trượt");
AtomicLongxây vòng lặp CAS retry — lock-free, không block, hợp cho thao tác đơn trên một biến. - Mutex (
synchronized,ReentrantLock) khoá critical section, luồng khác block chờ — bảo vệ được cả chuỗi nhiều biến, mạnh hơn nhưng đắt hơn khi tranh chấp. - Chi phí nằm ở tranh chấp: uncontended cả hai ~ nanosecond; contended mutex phải park/wake qua futex + context switch, đắt hơn hàng chục tới hàng trăm lần.
- Tối ưu quan trọng nhất là giảm tranh chấp (thu nhỏ critical section, sharding), không phải chọn atomic hay mutex.
- Lối thoát thứ ba, thường tốt nhất: đừng chia sẻ — immutable, thread confinement, message passing. Không chia sẻ thì không cần đồng bộ.
- Thứ tự ưu tiên: tránh chia sẻ → atomic (thao tác đơn) → mutex (chuỗi nhiều biến).
12. Tự kiểm tra
Q1Một bộ đếm metric bị 40 luồng cùng tăng liên tục (ghi nhiều, đọc thưa). Bạn chọn synchronized, AtomicLong hay LongAdder? Vì sao CAS-retry ảnh hưởng lựa chọn khi tranh chấp cao?▸
synchronized, AtomicLong hay LongAdder? Vì sao CAS-retry ảnh hưởng lựa chọn khi tranh chấp cao?Với ghi rất dày từ 40 luồng, LongAdder là lựa chọn tốt nhất. Loại synchronized đầu tiên: nó bắt luồng block/park qua kernel khi tranh chấp — đắt nhất, và bộ đếm đơn không cần bảo vệ chuỗi nhiều biến nên dùng mutex là thừa. Còn lại là hai lựa chọn lock-free.
AtomicLong đúng và đơn giản, nhưng cả 40 luồng cùng đập vào một ô nhớ: dù incrementAndGet trên x86 là một lệnh lock xadd không cần retry, cache line chứa biến đó vẫn bật qua bật lại giữa các core (cache-line contention), và với các phép cập nhật tổng quát dựa trên compareAndSet, tranh chấp cao còn khiến CAS trượt và lặp lại nhiều lần, đốt CPU. LongAdder né cả hai: nó rải giá trị ra nhiều cell, mỗi luồng cộng vào cell riêng nên gần như không đụng nhau; chỉ khi sum() mới cộng dồn các cell. Đổi lại giá trị không "tức thời chính xác" mỗi lần đọc — nhưng metric ghi-nhiều-đọc-thưa thì đó là đánh đổi hời. Quy tắc: AtomicLong khi tranh chấp vừa hoặc cần giá trị chính xác mỗi thao tác; LongAdder khi là điểm nóng ghi bởi nhiều luồng.
Q2Bạn cần "chuyển tiền: trừ tài khoản A và cộng tài khoản B phải cùng lúc". Vì sao hai AtomicLong riêng lẻ không đủ, và dùng gì thay thế?▸
AtomicLong riêng lẻ không đủ, và dùng gì thay thế?CAS chỉ nguyên tử trên một ô nhớ. Hai AtomicLong.addAndGet riêng lẻ, mỗi cái nguyên tử độc lập, nhưng cặp hai lệnh thì không: giữa lúc trừ A và cộng B, một luồng khác có thể quan sát và thấy tổng số dư sai (tiền như bốc hơi). Bất biến ở đây trải trên hai biến, không thể bảo vệ bằng atomic đơn.
Cần một mutex khoanh cả chuỗi: synchronized hoặc ReentrantLock quanh cả hai dòng, để không luồng nào quan sát được trạng thái nửa vời. Đây chính là ranh giới: atomic cho thao tác đơn một biến, mutex cho chuỗi nhiều biến phải cùng nguyên khối.
Q3Vì sao người ta nói chi phí của khoá "nằm ở tranh chấp, không ở bản thân khoá"? Phân biệt uncontended và contended.▸
Uncontended: không luồng nào khác đang giữ khoá. JVM xử lý bằng một lệnh atomic ngay trong user space, không đụng kernel — chi phí cỡ vài đến vài chục nanosecond, gần như miễn phí. Contended: luồng không lấy được khoá phải block; trên Linux việc này đi qua futex, gọi syscall vào kernel để park luồng, và khi nhả khoá thì kernel wake luồng chờ — kèm một context switch, tốn hàng trăm ns tới vài microsecond.
Khoảng cách giữa hai chế độ là hàng chục tới hàng trăm lần, và nó phụ thuộc có tranh chấp hay không, chứ không phải bản thân việc "có khoá". Một khoá gần như không bị tranh thì rẻ dù là mutex; một điểm nóng nhiều luồng đập vào thì đắt dù dùng atomic. Vì thế tối ưu đúng là giảm tranh chấp (thu nhỏ critical section, sharding), không phải né khoá.
Q4Khi nào bạn chọn atomic, khi nào chọn mutex? Cho một ví dụ mỗi loại.▸
Chọn atomic khi thao tác là đơn trên một biến và bạn muốn tránh chi phí block: bộ đếm request (AtomicLong.incrementAndGet), một cờ trạng thái, hoán đổi một tham chiếu bằng CAS. Nó lock-free nên một luồng chậm không chặn luồng khác.
Chọn mutex khi cần bảo vệ cả chuỗi nhiều bước / nhiều biến như một khối nguyên vẹn: chuyển tiền giữa hai tài khoản, cập nhật nhiều field liên quan phải nhất quán, hay bất kỳ bất biến nào atomic đơn không diễn đạt được. Đánh đổi: mutex đắt hơn khi tranh chấp (block/wake) nhưng biểu đạt được nhiều hơn. Nguyên tắc: dùng công cụ yếu nhất đủ việc — atomic trước, mutex khi buộc phải.
Q5"Đừng chia sẻ" chống race bằng cách nào, và kể ba kỹ thuật cụ thể trong Java.▸
Race chỉ sống ở shared mutable state. Nếu dữ liệu không được chia sẻ, hoặc không thay đổi, thì không có hai luồng nào cùng ghi một chỗ — race biến mất về mặt cấu trúc, không cần khoá hay atomic. Đây là cách trị tận gốc thay vì quản lý triệu chứng.
Ba kỹ thuật: (1) Immutable — dùng record, field final, List.copyOf; muốn "đổi" thì tạo object mới. Nhiều luồng đọc chung an toàn. (2) Thread confinement — giữ dữ liệu cục bộ trong một luồng (biến local trên stack, hoặc ThreadLocal cho mỗi luồng một bản). (3) Message passing — mỗi dữ liệu có một luồng chủ duy nhất, luồng khác gửi yêu cầu qua BlockingQueue thay vì tự sửa. Cả ba đều loại bỏ chia sẻ ghi.
Q6Vì sao giữ khoá trong lúc gọi I/O chậm (ví dụ synchronized quanh một request mạng) là thiết kế tệ?▸
synchronized quanh một request mạng) là thiết kế tệ?Trong khi một luồng giữ khoá và chờ I/O (có thể hàng chục mili-giây), mọi luồng khác muốn vào critical section đó đều bị block chờ. Bạn vừa biến một khoá lẽ ra hiếm tranh chấp (uncontended, rẻ) thành điểm nghẽn tranh chấp nặng (contended, đắt) — hàng loạt luồng xếp hàng park/wake, throughput sụp.
Tệ hơn, thời gian giữ khoá bị chi phối bởi độ trễ mạng bạn không kiểm soát. Cách đúng: làm phần chậm (fetchFromNetwork) ngoài khoá, chỉ khoá đúng khoảnh khắc chạm shared state (ghi vào cache). Nguyên tắc chung: critical section phải nhỏ nhất có thể và không bao giờ chứa thao tác blocking/I/O.
Bài tiếp theo: Deadlock — bốn điều kiện và cách phá
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