1. Làm quen với tình trạng tranh chấp (race condition)
Hãy nhắc lại về tình trạng tranh chấp (race condition) — tình huống mà kết quả của chương trình phụ thuộc vào thứ tự các luồng truy cập dữ liệu hay tài nguyên chung. Nếu thứ tự thực thi thay đổi — kết quả trở nên khó lường. Nó giống như bạn và một người bạn cùng lúc cố gắng chỉnh sửa cùng một tài liệu: ai gõ nhanh hơn thì người đó “thắng”, và văn bản cuối cùng có thể rất kỳ quặc.
Trong Java (và trong bất kỳ ngôn ngữ nào hỗ trợ đa luồng), race condition xuất hiện khi nhiều luồng đồng thời đọc và/hoặc sửa cùng một biến mà không có đồng bộ hoá thích đáng.
Vì sao xảy ra race condition?
Các luồng Java chạy song song. Nếu hai luồng đồng thời truy cập cùng một biến (ví dụ tăng một bộ đếm chung), chúng có thể “đè” lên nhau. Dù thao tác trông có vẻ nguyên tử (chẳng hạn, counter++), thực ra không phải vậy!
Hoạt động như thế nào counter++?
Phép tăng gồm vài bước:
- Đọc giá trị hiện tại của biến từ bộ nhớ.
- Tăng giá trị đó lên một đơn vị.
- Ghi giá trị mới trở lại bộ nhớ.
Nếu cùng lúc một luồng khác cũng thực hiện counter++, cả hai có thể đọc cùng một giá trị, cùng tăng lên và cùng ghi kết quả giống nhau — cuối cùng một lần tăng “bị mất”.
2. Ví dụ race condition: tăng bộ đếm
Hãy viết một chương trình đơn giản khởi chạy nhiều luồng, mỗi luồng tăng bộ đếm chung lên 1. Tưởng như nếu ta khởi chạy 1000 luồng, giá trị cuối cùng phải là 1000. Kiểm tra nhé!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // Thao tác NGUY HIỂM!
});
threadArray[i].start();
}
// Chờ tất cả các luồng kết thúc
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Kỳ vọng: " + threads);
System.out.println("Thực tế: " + counter);
}
}
Kết quả dự kiến:
Kỳ vọng: 1000
Thực tế: 843
Giá trị có thể khác nhau mỗi lần chạy: đôi khi 900, đôi khi 700, và đôi khi là 1000 — nhưng rất hiếm.
Tại sao lại như vậy?
Các luồng đồng thời đọc giá trị của counter, tăng nó lên và ghi lại. Nếu hai luồng đọc cùng một giá trị, cả hai cùng tăng và cùng ghi — một lần tăng sẽ bị mất. Do đó giá trị cuối cùng luôn nhỏ hơn kỳ vọng.
3. Ví dụ khác: ngân hàng không có đồng bộ hoá
Hãy tưởng tượng chúng ta có một tài khoản ngân hàng và hai luồng đồng thời rút tiền.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Mô phỏng tác vụ lâu
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Kỳ vọng: 0 hoặc 100");
System.out.println("Số dư thực tế: " + account.getBalance());
}
}
Đôi khi cả hai luồng đều thấy tài khoản có 100 và cả hai cùng rút. Kết quả là số dư trở thành -100! (Ngoài đời điều này không xảy ra, nhưng trong code — hoàn toàn có thể.)
4. Những điểm hữu ích
Hậu quả của race condition
Race condition không chỉ là những kết quả “kỳ lạ”. Đây là cơn đau đầu thật sự với lập trình viên, vì:
- Lỗi không phải lúc nào cũng xuất hiện. Đôi khi chương trình chạy đúng, đôi khi lại không. Tất cả phụ thuộc vào việc các luồng “kịp” thực thi như thế nào.
- Kiểm thử không đảm bảo thành công. Bạn có thể chạy chương trình nhiều lần — mọi thứ đều ổn, rồi bỗng nhiên mọi thứ hỏng.
- Khó gỡ lỗi. Hành vi phụ thuộc vào tốc độ CPU, tải hệ thống, các chương trình khác đang chạy.
- Có thể xảy ra sự cố nghiêm trọng: mất dữ liệu, tính toán sai, ứng dụng sập.
Ví dụ thực tế
- Ứng dụng tài chính: tính sai số dư, trừ tiền hai lần.
- Server: mất thông điệp, xử lý request không chính xác.
- Game: “teleport” nhân vật, cộng điểm sai.
Vì sao kiểm thử không cứu được race condition?
Race condition là một kiểu “Heisenbug” (bug biến mất khi bạn cố bắt nó). Dù bạn chạy test cả nghìn lần mà không thấy lỗi — không có nghĩa là nó không tồn tại! Tất cả phụ thuộc vào cách hệ điều hành lập lịch cho các luồng. Đôi khi mọi thứ trơn tru, còn đôi khi các luồng “va” vào nhau và vấn đề xuất hiện.
Làm thế nào để tránh race condition?
- Đồng bộ hoá: dùng từ khóa synchronized cho phương thức hoặc khối mã để tại mỗi thời điểm chỉ một luồng có thể thay đổi dữ liệu chung.
- Thao tác nguyên tử: dùng các lớp trong gói java.util.concurrent.atomic (ví dụ, AtomicInteger) để có các thao tác an toàn mà không cần đồng bộ hoá tường minh.
- Tính bất biến: nếu đối tượng không thể bị thay đổi, race condition sẽ không thể xảy ra.
Ví dụ với đồng bộ hoá
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Bây giờ, nếu nhiều luồng gọi increment(), tại mỗi thời điểm chỉ một luồng có thể thực thi phương thức này.
5. Các lỗi thường gặp khi làm việc với biến dùng chung trong các luồng
Lỗi №1: Tin rằng các thao tác đơn giản là an toàn.
Nhiều người nghĩ counter++ là một thao tác duy nhất nên không thể có vấn đề. Thực tế, đó là ba bước và giữa các bước đó, luồng khác có thể “chen vào”.
Lỗi №2: Dùng biến thông thường để trao đổi giữa các luồng.
Nếu nhiều luồng đọc và ghi cùng một biến mà không đồng bộ hoá — xin chào, race condition!
Lỗi №3: Kỳ vọng lỗi sẽ luôn xuất hiện.
Race condition có thể chỉ thỉnh thoảng mới lộ diện, điều này khiến nó đặc biệt xảo quyệt. Đừng trông chờ rằng nếu test chạy ổn thì mọi thứ đều tốt.
Lỗi №4: Bỏ qua đồng bộ hoá khi làm việc với collection.
Các collection thông thường như ArrayList không an toàn cho đa luồng. Nếu nhiều luồng thêm hoặc xoá phần tử — có thể dẫn tới sự cố và thậm chí làm chương trình sập.
Lỗi №5: Cố “chữa” race condition bằng cách chèn độ trễ.
Ví dụ, dùng Thread.sleep(10) hoặc các “khoảng dừng ma thuật” khác. Cách tiếp cận này không giải quyết vấn đề, chỉ che giấu nó. Giải pháp thực sự — đồng bộ hoá hoặc dùng các thao tác nguyên tử.
GO TO FULL VERSION