Bài viết này nằm trong series Java concurrency, bàn về một vấn đề mà bất cứ lập trình viên nào khi làm việc với các hệ thống concurrency đều gặp phải đó là: race condition. Vậy race condition là gì?
Race condition là một vấn đề về concurrency (đồng thời) có thể xảy ra bên trong một critical section. Race conditions xảy ra khi hai hoặc nhiều tiến trình hoặc luồng thực thi cùng truy cập và thao tác trên một tài nguyên dùng chung (như bộ nhớ, biến, hoặc tệp) mà kết quả cuối cùng phụ thuộc vào thứ tự thực thi của các luồng.
Critical section là đoạn mã trong chương trình nơi một tài nguyên dùng chung được truy cập. Đây là nơi dễ xảy ra race conditions nếu không được quản lý cẩn thận.
Ví dụ:
Hãy tưởng tượng hai luồng cùng cập nhật giá trị của một biến đếm:
Luồng A đọc giá trị hiện tại là 10.
Luồng B cũng đọc giá trị hiện tại là 10.
Cả hai luồng cộng 1 vào giá trị đọc được.
Luồng A và B ghi lại giá trị mới là 11.
Kết quả đúng lẽ ra phải là 12, nhưng do cả hai luồng cùng thao tác không đồng bộ, kết quả cuối cùng chỉ là 11.
Như bạn thấy đó, nếu không có sự đồng bộ hóa đúng cách, các luồng có thể ghi đè dữ liệu của nhau, dẫn đến kết quả không mong muốn hoặc lỗi.
Two Types of Race Conditions
Race Conditions có thể xảy ra khi hai hoặc nhiều luồng (threads) cùng đọc và ghi vào một biến theo một trong hai mô hình sau:
Đọc-sửa-ghi (Read-modify-write)
Kiểm tra-rồi-hành động (Check-then-act)
Mô hình Read-modify-write có nghĩa là hai hoặc nhiều luồng đầu tiên đọc giá trị của một biến, sau đó sửa đổi giá trị này và ghi lại giá trị mới vào biến đó. Để gây ra vấn đề, giá trị mới phải phụ thuộc theo một cách nào đó vào giá trị trước đó. Vấn đề có thể xảy ra khi hai luồng cùng đọc giá trị (lưu vào thanh ghi CPU), sau đó cùng sửa đổi giá trị (trong các thanh ghi CPU) và cuối cùng ghi lại giá trị này vào biến. Tình huống này sẽ được giải thích chi tiết hơn sau.
Mô hình Check-then-act có nghĩa là hai hoặc nhiều luồng kiểm tra một điều kiện, chẳng hạn như kiểm tra xem một
Map
có chứa giá trị cụ thể nào đó không, và sau đó hành động dựa trên thông tin này, ví dụ như lấy giá trị từMap
. Vấn đề có thể xảy ra nếu hai luồng đồng thời kiểm traMap
để tìm một giá trị cụ thể, cả hai đều thấy giá trị đó tồn tại, và sau đó cả hai cùng cố gắng lấy (hoặc xóa) giá trị đó. Tuy nhiên, chỉ một trong hai luồng có thể thực sự lấy được giá trị này, luồng còn lại sẽ nhận về giá trịnull
. Tình huống tương tự cũng có thể xảy ra nếu sử dụng mộtQueue
thay vìMap
.
Read-Modify-Write Critical Sections
Như đã đề cập ở trên, một critical section Đọc-Sửa-Ghi có thể dẫn đến race condition. Trong phần này, tôi sẽ xem xét kỹ hơn lý do tại sao lại như vậy.
Dưới đây là một ví dụ về mã Java cho critical section Đọc-Sửa-Ghi có thể gặp lỗi nếu được thực thi bởi nhiều luồng đồng thời:
public class Counter {
protected long count = 0;
public void add(long value){
this.count = this.count + value;
}
}
Hãy tưởng tượng nếu hai luồng, A và B, đang thực thi phương thức add
trên cùng một instance của lớp Counter
. Không có cách nào để biết khi nào hệ điều hành chuyển đổi giữa hai luồng. Phương thức add()
không được máy ảo Java thực thi như một lệnh atomic. Thay vào đó, nó được thực thi như một tập hợp các lệnh nhỏ hơn, tương tự như sau:
Đọc
this.count
từ bộ nhớ vào thanh ghi.Thêm
value
vào thanh ghi.Ghi thanh ghi vào bộ nhớ.
Hãy quan sát điều gì xảy ra với việc thực thi xen kẽ sau đây của luồng A và B:
this.count = 0;
A: Đọc this.count vào thanh ghi (0)
B: Đọc this.count vào thanh ghi (0)
B: Thêm giá trị 2 vào thanh ghi
B: Ghi giá trị thanh ghi (2) trở lại bộ nhớ. this.count bây giờ bằng 2
A: Thêm giá trị 3 vào thanh ghi
A: Ghi giá trị thanh ghi (3) trở lại bộ nhớ. this.count bây giờ bằng 3
Hai luồng muốn thêm các giá trị 2 và 3 vào bộ đếm. Do đó, giá trị phải là 5 sau khi hai luồng hoàn thành việc thực thi. Tuy nhiên, vì việc thực thi hai luồng được xen kẽ, kết quả cuối cùng lại khác.
Trong ví dụ về trình tự thực thi được liệt kê ở trên, cả hai luồng đều đọc giá trị 0 từ bộ nhớ. Sau đó, chúng thêm các giá trị riêng lẻ của chúng, 2 và 3, vào giá trị đó và ghi kết quả trở lại bộ nhớ. Thay vì 5, giá trị còn lại trong this.count
sẽ là giá trị được ghi bởi luồng cuối cùng ghi giá trị của nó. Trong trường hợp trên là luồng A, nhưng cũng có thể là luồng B.
Check-Then-Act Critical Sections
Nếu hai luồng cùng kiểm tra một điều kiện, sau đó hành động dựa trên điều kiện đó và hành động này làm thay đổi điều kiện, thì rất dễ xảy ra xung đột.
Ví dụ, nếu hai luồng đồng thời kiểm tra điều kiện, sau đó một luồng thay đổi điều kiện, thì luồng còn lại có thể đưa ra hành động không chính xác do điều kiện đã bị thay đổi.
Ví dụ minh họa
Hãy xem đoạn mã sau đây để hiểu cách một phần quan trọng kiểu kiểm tra-rồi-hành động có thể dẫn đến race conditions:
public class CheckThenActExample {
public void checkThenAct(Map<String, String> sharedMap) {
if (sharedMap.containsKey("key")) {
String val = sharedMap.remove("key");
if (val == null) {
System.out.println("Value for 'key' was null");
}
} else {
sharedMap.put("key", "value");
}
}
}
Giải thích
Nếu hai hoặc nhiều luồng gọi phương thức
checkThenAct()
trên cùng một đối tượngCheckThenActExample
và sử dụng cùng mộtsharedMap
, các vấn đề sau có thể xảy ra:Cả hai luồng đồng thời thực thi câu lệnh
if (sharedMap.containsKey("key"))
.- Giả sử điều kiện này trả về true cho cả hai luồng.
Cả hai luồng tiến vào khối mã bên trong
if
.- Nhiều luồng cùng cố gắng gọi
sharedMap.remove("key")
, nhưng chỉ một luồng thực sự có thể loại bỏ cặp key-value thành công.
- Nhiều luồng cùng cố gắng gọi
Các luồng còn lại sẽ nhận được giá trị null khi cố gắng loại bỏ
key
, dẫn đến thông báo "Value for 'key' was null" được in ra.
Giải pháp Đồng bộ hóa
Để ngăn chặn race conditions trong phần check-then-act, bạn cần sử dụng các cơ chế đồng bộ hóa như:
Khóa (Locks):
Sử dụng
synchronized
hoặc các lớp nhưReentrantLock
để đảm bảo chỉ một luồng được truy cập đoạn mã tại một thời điểm.public class CheckThenActExample { public synchronized void checkThenAct(Map<String, String> sharedMap) { if (sharedMap.containsKey("key")) { String val = sharedMap.remove("key"); if (val == null) { System.out.println("Value for 'key' was null"); } } else { sharedMap.put("key", "value"); } } }
Concurrent Data Structures:
Sử dụng các cấu trúc dữ liệu đồng thời như
ConcurrentHashMap
để xử lý các thao tác trênsharedMap
một cách an toàn mà không cần đồng bộ hóa thủ công.Atomic Operations:
Khi có thể, sử dụng các thao tác nguyên tử (atomic operations) để kiểm tra và thay đổi điều kiện trong cùng một bước.
Việc xử lý đúng Check-Then-Act là rất quan trọng để đảm bảo tính toàn vẹn của dữ liệu và tránh lỗi trong các ứng dụng đa luồng.
Preventing Race Conditions
Để ngăn chặn race conditions, bạn cần đảm bảo rằng phần quan trọng (critical section) được thực thi dưới dạng một thao tác nguyên tử (atomic instruction). Điều này có nghĩa là khi một luồng đang thực thi phần quan trọng, không có luồng nào khác được phép thực thi nó cho đến khi luồng đầu tiên thoát khỏi phần quan trọng.
Cách ngăn chặn Race Conditions
1. Đồng bộ hóa luồng (Thread Synchronization):
Race conditions có thể được tránh bằng cách thực hiện đồng bộ hóa luồng trong các phần quan trọng. Điều này đảm bảo rằng tại bất kỳ thời điểm nào, chỉ có một luồng được phép truy cập vào phần quan trọng.
2. Các cơ chế đồng bộ hóa trong Java:
Khối đồng bộ hóa (Synchronized Block):
Sử dụng từ khóa
synchronized
để bao bọc phần mã quan trọng, đảm bảo rằng chỉ một luồng có thể truy cập vào nó tại một thời điểm.public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }
Sử dụng Locks:
Java cung cấp các lớp như
ReentrantLock
để kiểm soát truy cập vào phần quan trọng một cách chi tiết hơn.import java.util.concurrent.locks.ReentrantLock; public class Counter { private int count = 0; private ReentrantLock lock = new ReentrantLock(); public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } public int getCount() { lock.lock(); try { return count; } finally { lock.unlock(); } } }
Sử dụng biến nguyên tử (Atomic Variables):
Các biến nguyên tử như
AtomicInteger
trong Java cho phép thực hiện các thao tác trên biến một cách nguyên tử mà không cần sử dụng đồng bộ hóa thủ công.import java.util.concurrent.atomic.AtomicInteger; public class Counter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }
Tóm lại
Ngăn chặn race conditions đòi hỏi phải đảm bảo rằng chỉ một luồng được phép truy cập phần quan trọng tại một thời điểm. Bạn có thể sử dụng các cơ chế như đồng bộ hóa (synchronized
), khóa (ReentrantLock
), hoặc biến nguyên tử (AtomicInteger
) để đạt được điều này. Việc lựa chọn phương pháp phụ thuộc vào yêu cầu và độ phức tạp của ứng dụng.
Critical Section Throughput
Critical Section Throughput đề cập đến khả năng xử lý nhiều luồng đồng thời mà không làm giảm hiệu suất hoặc gây ra race conditions trong các phần quan trọng. Để cải thiện throughput, bạn có thể tối ưu hóa cách xử lý các phần quan trọng, đặc biệt khi chúng lớn hoặc phức tạp.
Ví dụ giải thích
Trường hợp 1: Synchronize toàn bộ Critical Section
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
public void add(int val1, int val2){
synchronized(this){ // Đồng bộ hóa toàn bộ phương thức
this.sum1 += val1;
this.sum2 += val2;
}
}
}
Hạn chế:
Phương thức
add()
đồng bộ hóa toàn bộ, nghĩa là chỉ một luồng có thể thực thi bất kỳ thao tác nào trong phần quan trọng.Kể cả khi
sum1
vàsum2
là độc lập, các luồng khác vẫn phải chờ.
Trường hợp 2: Chia nhỏ Critical Section thành các synchronize block riêng biệt
public class TwoSums {
private int sum1 = 0;
private int sum2 = 0;
private final Object sum1Lock = new Object();
private final Object sum2Lock = new Object();
public void add(int val1, int val2){
synchronized(sum1Lock){ // Khóa riêng cho sum1
this.sum1 += val1;
}
synchronized(sum2Lock){ // Khóa riêng cho sum2
this.sum2 += val2;
}
}
}
Cải thiện:
Hai luồng có thể thực thi song song:
Một luồng cập nhật
sum1
.Luồng kia cập nhật
sum2
.
Điều này giảm thời gian chờ và tăng throughput tổng thể.
Kết luận
Việc chia nhỏ phần quan trọng giúp giảm độ chờ giữa các luồng và tăng throughput.
Tuy nhiên, cần đảm bảo rằng các phần nhỏ không phụ thuộc vào nhau để tránh tạo ra race conditions mới.
Đây là một kỹ thuật hữu ích nhưng đòi hỏi hiểu rõ luồng và tài nguyên được chia sẻ để triển khai hiệu quả.
Nguồn tham khảo: https://jenkov.com/tutorials/java-concurrency/race-conditions-and-critical-sections.html