Khi học về lập trình đa luồng, một trong những ví dụ kinh điển để minh họa vấn đề an toàn luồng chính là bán vé. Qua tình huống này, ta có thể hiểu rõ hơn về điều kiện phát sinh và cách giải quyết vấn đề an toàn luồng.
Tình huống minh họa
- Trường hợp 1: Chỉ có một quầy bán vé duy nhất cho 100 vé. Vì chỉ có một luồng xử lý, nên không xảy ra xung đột — mọi thứ diễn ra tuần tự và an toàn.
- Trường hợp 2: Có 3 quầy, mỗi quầy phụ trách một dải vé riêng biệt (quầy 1: vé 1–30, quầy 2: 31–60, quầy 3: 61–100). Do không chia sẻ tài nguyên chung, nên cũng không có rủi ro về an toàn luồng.
- Trường hợp 3: Cả 3 quầy đều bán chung 100 vé. Khi đó, nếu không kiểm soát truy cập, nhiều quầy có thể đồng thời bán cùng một vé — dẫn đến hiện tượng "bán trùng" hoặc "bán quá số lượng". Đây chính là biểu hiện của vấn đề an toàn luồng, xảy ra khi nhiều luồng cùng truy cập và thay đổi một tài nguyên dùng chung.
Triển khai mã không an toàn
public class TicketSeller implements Runnable {
private int remainingTickets = 100;
@Override
public void run() {
while (true) {
if (remainingTickets > 0) {
System.out.println(Thread.currentThread().getName() +
" đang bán vé số " + remainingTickets);
try {
Thread.sleep(100); // Làm chậm để dễ tái hiện lỗi
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
remainingTickets--;
} else {
System.out.println("Hết vé!");
break;
}
}
}
}
public class Main {
public static void main(String[] args) {
TicketSeller seller = new TicketSeller(); // Dùng chung một đối tượng
Thread window1 = new Thread(seller, "Quầy 1");
Thread window2 = new Thread(seller, "Quầy 2");
Thread window3 = new Thread(seller, "Quầy 3");
window1.start();
window2.start();
window3.start();
}
}
Lưu ý: Việc truyền cùng một đối tượng TicketSeller vào cả ba luồng là thiết yếu — vì chỉ khi đó, biến remainingTickets mới là tài nguyên được chia sẻ. Nếu tạo ba đối tượng riêng biệt, mỗi luồng sẽ có bản sao riêng của biến, và vấn đề an toàn luồng sẽ không xảy ra (vì không có tài nguyên chung).
Khi chạy đoạn mã trên, có thể thấy nhiều luồng in ra cùng một số vé — ví dụ cả "Quầy 1" và "Quầy 3" đều báo đang bán vé số 100 — đây là lỗi do thiếu cơ chế đồng bộ.
Giải pháp: Đồng bộ hóa bằng synchronized
public class SafeTicketSeller implements Runnable {
private int remainingTickets = 100;
@Override
public void run() {
while (true) {
synchronized (this) {
if (remainingTickets > 0) {
System.out.println(Thread.currentThread().getName() +
" đang bán vé số " + remainingTickets);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
remainingTickets--;
} else {
System.out.println("Hết vé!");
break;
}
}
}
}
}
Ở đây, khối synchronized (this) đảm bảo rằng tại một thời điểm, chỉ có một luồng được thực thi đoạn mã bên trong. Đối tượng khóa là this — tức chính đối tượng SafeTicketSeller được chia sẻ giữa các luồng.
Khi một luồng vào khối synchronized, nó chiếm quyền sở hữu (lock) trên đối tượng. Các luồng khác muốn vào phải chờ đến khi luồng hiện tại thoát khỏi khối và giải phóng lock. Nhờ vậy, thao tác kiểm tra và giảm số vé trở thành nguyên tử — loại bỏ hoàn toàn khả năng bán trùng vé.
Cơ chế này dựa trên đặc điểm: mỗi đối tượng Java có một monitor lock (khóa đối tượng) duy nhất. Từ Java 5 trở đi, ngoài synchronized, ta cũng có thể dùng giao diện java.util.concurrent.locks.Lock để đạt hiệu quả tương tự với nhiều tính năng linh hoạt hơn.