1. Giới Thiệu về An Toàn Luồng
Khi phát triển ứng dụng đa luồng, hai khái niệm cốt lõi cần được nắm vững là "an toàn luồng" và "cơ chế khóa". Chúng ta sẽ cùng tìm hiểu sâu hơn về chúng.
1.1. Vấn đề Thực tế
Hãy xem xét một ví dụ đơn giản để minh họa một hiện tượng bất thường thường gặp trong môi trường đa luồng. Giả sử chúng ta có một biến toàn cục tên là sharedCounter:
public class CounterDemo {
public static int sharedCounter = 0;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
System.out.println("Luồng A bắt đầu.");
for (int i = 0; i < 50000; i++) {
sharedCounter++;
}
}, "Luồng-A");
Thread threadB = new Thread(() -> {
System.out.println("Luồng B bắt đầu.");
for (int i = 0; i < 50000; i++) {
sharedCounter++;
}
}, "Luồng-B");
threadA.start();
threadB.start();
threadA.join(); // Chờ Luồng A hoàn thành
threadB.join(); // Chờ Luồng B hoàn thành
System.out.println("Giá trị cuối cùng của sharedCounter: " + sharedCounter);
System.out.println("Chương trình chính kết thúc.");
}
}
Theo logic thông thường, hai luồng, mỗi luồng tăng biến sharedCounter lên 50.000 lần, thì kết quả cuối cùng phải là 100.000. Tuy nhiên, khi chạy đoạn mã này nhiều lần, bạn sẽ thường xuyên thấy kết quả nhỏ hơn 100.000 và thay đổi mỗi lần chạy. Tại sao lại như vậy?
1.2. Phân Tích Sâu Sắc về Lệnh
Vấn đề nằm ở câu lệnh sharedCounter++. Ở cấp độ xử lý của CPU, đây không phải là một thao tác duy nhất mà được chia thành ba lệnh máy tính cơ bản:
- LOAD (Tải): Đọc giá trị hiện tại của
sharedCountertừ bộ nhớ chính (RAM) vào một thanh ghi (register) của CPU. - ADD (Cộng): Tăng giá trị trong thanh ghi lên 1.
- SAVE (Lưu): Ghi giá trị đã sửa đổi từ thanh ghi trở lại bộ nhớ chính (RAM).
Mô phỏng Xung đột Dữ liệu:
Giả sử sharedCounter đang có giá trị 0, và hai luồng (Luồng A và Luồng B) cố gắng thực hiện sharedCounter++ cùng lúc:
- Luồng A thực hiện LOAD, đọc giá trị 0 vào thanh ghi của nó.
- Ngay sau đó, Luồng B giành quyền điều khiển CPU và thực hiện LOAD, cũng đọc giá trị 0 vào thanh ghi của nó.
- Luồng A tiếp tục thực hiện ADD, thanh ghi của nó hiện có giá trị 1.
- Luồng B tiếp tục thực hiện ADD, thanh ghi của nó cũng có giá trị 1.
- Luồng A thực hiện SAVE, ghi giá trị 1 từ thanh ghi của nó vào bộ nhớ chính.
sharedCounterbây giờ là 1. - Luồng B thực hiện SAVE, ghi giá trị 1 từ thanh ghi của nó vào bộ nhớ chính.
sharedCountervẫn là 1.
Kết quả là, dù đã thực hiện hai lần sharedCounter++, giá trị trong bộ nhớ chỉ tăng lên 1 thay vì 2. Công sức của một luồng đã bị ghi đè bởi luồng kia. Đây chính là vấn đề "an toàn luồng" điển hình, cụ thể là một điều kiện tranh chấp (race condition).
1.3. Nguyên nhân gây Mất An Toàn Luồng:
- Lịch trình luồng ngẫu nhiên: Hệ điều hành có thể tạm dừng một luồng bất cứ lúc nào để chuyển sang luồng khác.
- Nhiều luồng cùng sửa đổi một biến chung: Khi các luồng cùng lúc đọc và ghi vào cùng một tài nguyên.
- Thao tác không mang tính nguyên tử: Một thao tác tưởng chừng đơn giản lại được chia thành nhiều bước nhỏ ở cấp độ CPU.
- Vấn đề về tầm nhìn bộ nhớ: Sự thay đổi của một luồng có thể không ngay lập tức hiển thị cho các luồng khác.
- Tái sắp xếp lệnh: Trình biên dịch hoặc CPU có thể thay đổi thứ tự thực hiện các lệnh để tối ưu hóa, miễn là không ảnh hưởng đến kết quả trong một luồng đơn.
1.4. Lập lịch Luồng Ngẫu nhiên (Preemptive Scheduling)
Để đảm bảo công bằng trong việc sử dụng tài nguyên CPU, hệ điều hành áp dụng cơ chế "lập lịch ưu tiên". Điều này có nghĩa là một luồng đang chạy có thể bị tạm dừng bất kỳ lúc nào (ngay cả khi chưa hoàn thành một lệnh) để một luồng khác có thể sử dụng CPU.
1.5. Tại sao Sửa đổi biến chung lại không an toàn?
Nếu nhiều luồng chỉ đọc một biến chung, sẽ không có vấn đề gì vì việc đọc không làm thay đổi dữ liệu. Tuy nhiên, khi thao tác sửa đổi xảy ra, do được chia nhỏ thành nhiều bước và có thể bị gián đoạn, tính nhất quán của dữ liệu dễ dàng bị phá vỡ.
1.6. Tính Nguyên tử của Thao tác (Atomicity)
Tính nguyên tử nghĩa là một thao tác là không thể chia cắt. Nó phải được thực hiện hoàn chỉnh hoặc không thực hiện gì cả. Như chúng ta đã thấy, sharedCounter++ bao gồm ba bước lệnh riêng biệt, vì vậy nó không có tính nguyên tử. Để khắc phục, chúng ta cần "đóng gói" ba bước này thành một khối không thể bị ngắt quãng.
2. Cơ Chế Khóa (Locks)
2.1. Giới thiệu Khóa
Khái niệm khóa khá đơn giản. Hãy hình dung một phòng vệ sinh công cộng với một ổ khóa ở cửa. Khi một người vào, họ sẽ khóa cửa lại. Những người khác muốn vào phải xếp hàng chờ bên ngoài cho đến khi người bên trong ra và mở khóa cửa.
Trong Java, chúng ta có thể sử dụng từ khóa synchronized để bảo vệ các đoạn mã quan trọng.
public class SafeCounterDemo {
public static int safeCounter = 0;
private static final Object lockObject = new Object(); // Tạo một đối tượng khóa
public static void main(String[] args) throws InterruptedException {
Thread workerOne = new Thread(() -> {
System.out.println("Worker One bắt đầu.");
for (int i = 0; i < 50000; i++) {
synchronized(lockObject) { // Khóa tại đây
safeCounter++;
} // Tự động nhả khóa khi ra khỏi khối synchronized
}
}, "Worker-1");
Thread workerTwo = new Thread(() -> {
System.out.println("Worker Two bắt đầu.");
for (int i = 0; i < 50000; i++) {
synchronized(lockObject) { // Khóa tại đây
safeCounter++;
} // Tự động nhả khóa
}
}, "Worker-2");
workerOne.start();
workerTwo.start();
workerOne.join();
workerTwo.join();
System.out.println("Giá trị an toàn của safeCounter: " + safeCounter);
System.out.println("Chương trình chính kết thúc.");
}
}
Khi thêm cơ chế khóa, ba lệnh của safeCounter++ được "đóng gói". Nếu Worker One đang thực hiện ba bước này, Worker Two muốn thực hiện tương tự sẽ bị chặn (Blocked) ở bên ngoài cho đến khi Worker One hoàn tất thao tác SAVE và nhả khóa.
2.2. Sử dụng Từ khóa synchronized
Khóa Đối Tượng (Object Lock)
Cấu trúc cơ bản là:
synchronized(objectMonitor) {
// Đoạn mã cần được bảo vệ bởi khóa
}
Lưu ý quan trọng: Khóa chỉ có tác dụng khi nhiều luồng cùng tranh giành cùng một đối tượng khóa. Nếu bạn gán lockObject1 cho Worker One và lockObject2 cho Worker Two, thì các khóa sẽ khác nhau và xung đột vẫn sẽ xảy ra.
Hãy tưởng tượng, bạn có một tài liệu mật cần được chỉnh sửa. Bạn lấy một chiếc chìa khóa duy nhất để mở tủ chứa tài liệu đó. Khi bạn đang chỉnh sửa, những người khác cũng muốn chỉnh sửa tài liệu đó sẽ phải chờ chìa khóa từ bạn. Nếu mỗi người có một chìa khóa khác nhau để mở các tủ khác nhau, thì tài liệu sẽ không được bảo vệ.
Khóa Phương Thức (Method Lock)
Từ khóa synchronized cũng có thể đặt trực tiếp trên khai báo phương thức:
- Khóa trên phương thức tĩnh:
public static synchronized void incrementStaticCounter() { safeCounter++; }Điều này tương đương với việc sử dụng
synchronized(TênLớp.class). Khóa này bảo vệ mọi cuộc gọi đến phương thức tĩnh này trên toàn bộ lớp. - Khóa trên phương thức thể hiện (instance method):
public synchronized void incrementInstanceCounter() { safeCounter++; }Điều này tương đương với việc sử dụng
synchronized(this). Khóa này bảo vệ mọi cuộc gọi đến phương thức này trên cùng một thể hiện (object) của lớp.
Khi một phương thức được đánh dấu là synchronized, toàn bộ logic bên trong phương thức đó được coi là một đơn vị không thể chia cắt. Trong môi trường máy tính, khóa không thể bị phá vỡ một cách cưỡng bức; các luồng khác phải chờ luồng đang giữ khóa chủ động nhả khóa trước khi chúng có cơ hội giành quyền truy cập.