Đồng Bộ Hóa
(Synchronization)
Trong Java, đồng bộ hóa (synchronization) là một cơ chế quan trọng để kiểm soát quyền truy cập của nhiều luồng (threads) vào tài nguyên chia sẻ. Nó được xây dựng xung quanh một thực thể đặc biệt gọi là khóa nội tại (intrinsic lock) hoặc khóa giám sát (monitor lock). Trong tài liệu API, khóa này thường được gọi đơn giản là "monitor". Khóa nội tại đóng vai trò quan trọng trong việc:
↳ Đảm bảo rằng chỉ một luồng có thể truy cập một đối tượng tại một thời điểm, ngăn chặn các vấn đề như xung đột dữ liệu.
↳ Thiết lập một mối quan hệ "happens-before" để đảm bảo rằng các hành động của các luồng được thực thi theo một thứ tự xác định, đảm bảo tính nhất quán của dữ liệu.
Ⅰ. Khóa nội tại (Intrinsic lock) là gì?
Khóa nội tại (Intrinsic lock) là một cơ chế đồng bộ hóa tích hợp trong Java, được cung cấp bởi từ khóa synchronized. Đây là một công cụ đơn giản và tự động để đảm bảo rằng chỉ một luồng có thể truy cập vào một đoạn mã hoặc tài nguyên chia sẻ tại một thời điểm.
Cơ chế hoạt động:
↳ Khi bạn sử dụng synchronized để đồng bộ hóa một phương thức hoặc khối mã, bạn đang sử dụng một khóa nội tại gắn liền với đối tượng mà bạn đồng bộ hóa.
↳ Mỗi đối tượng trong Java có một khóa nội tại. Khi một luồng đạt được khóa nội tại của một đối tượng, các luồng khác sẽ bị chặn và không thể đạt được khóa đó cho đến khi khóa được mở.
Ví dụ: Nếu bạn có một phương thức synchronized trong một đối tượng, khi một luồng gọi phương thức này, nó phải chiếm được khóa nội tại của đối tượng trước. Khi phương thức đang được thực thi, các luồng khác muốn gọi phương thức này sẽ bị chặn cho đến khi luồng hiện tại hoàn thành và giải phóng khóa.
Đặc Điểm:
↳ Tính tái nhập (Reentrancy): Nếu một luồng đã có khóa nội tại, nó có thể tiếp tục đạt được khóa đó nhiều lần mà không bị chặn.
↳ Tự động quản lý khóa: Khi luồng ra khỏi khối synchronized, khóa nội tại được tự động mở. Điều này giúp giảm nguy cơ lỗi liên quan đến việc quên mở khóa.
↳ Không hỗ trợ thời gian chờ hoặc hủy bỏ: synchronized không cung cấp các tính năng như thời gian chờ (timeout) hay khả năng hủy bỏ khi chờ đạt khóa.
Tuy nhiên, đồng bộ hóa có thể gây ra cạnh tranh luồng (thread contention), xảy ra khi hai hoặc nhiều luồng cố gắng truy cập cùng một tài nguyên đồng thời, khiến Java runtime phải thực thi một hoặc nhiều luồng chậm hơn, hoặc thậm chí tạm dừng thực thi của chúng. Starvation và livelock là những dạng cạnh tranh luồng. Để biết thêm thông tin, hãy xem phần vấn đề đa luồng.
Tại sao cần sử dụng đồng bộ hóa?
↳ Ngăn chặn sự can thiệp của luồng: Đảm bảo rằng một luồng không làm gián đoạn công việc của luồng khác khi truy cập các tài nguyên chia sẻ.
↳ Ngăn chặn vấn đề về tính nhất quán: Bảo vệ tính toàn vẹn của dữ liệu khi nhiều luồng cố gắng đọc/ghi dữ liệu chia sẻ.
Ⅱ. Các loại đồng bộ hóa (Types of synchronization)
Trong Java, đồng bộ hóa giúp kiểm soát việc nhiều tiến trình hoặc luồng cùng truy cập vào một tài nguyên chung, tránh lỗi và đảm bảo dữ liệu chính xác. Có hai loại đồng bộ hóa chính: Đồng bộ hóa tiến trình (Process Synchronization) và đồng bộ hóa luồng (Thread Synchronization). Bài viết này sẽ giúp bạn hiểu rõ hơn về từng loại đồng bộ hóa và cách sử dụng chúng trong Java.
1. Đồng bộ hóa tiến trình (Process Synchronization)
Mô tả: Đây là kỹ thuật đồng bộ hóa được sử dụng để quản lý truy cập đến tài nguyên chung giữa các tiến trình (processes) khác nhau. Đồng bộ hóa tiến trình thường được thực hiện thông qua các cơ chế như:
↳ Semaphore (Semafore): Một đối tượng cho phép quản lý số lượng tiến trình có thể truy cập tài nguyên đồng thời.
↳ Mutex (Mutual Exclusion): Một cơ chế đảm bảo chỉ có một tiến trình có thể truy cập tài nguyên tại một thời điểm.
↳ Shared Memory: Kỹ thuật cho phép nhiều tiến trình truy cập vào cùng một vùng nhớ, với sự đồng bộ hóa để tránh các vấn đề về đồng bộ.
↳ Ứng dụng: Sử dụng trong các hệ thống đa tiến trình, như các hệ điều hành hoặc các ứng dụng cần tương tác giữa nhiều tiến trình.
2. Đồng bộ hóa luồng (Thread Synchronization)
Mô tả: Đây là kỹ thuật đồng bộ hóa được sử dụng để quản lý truy cập đến tài nguyên chia sẻ giữa các luồng (threads) trong cùng một tiến trình. Đồng bộ hóa luồng giúp đảm bảo rằng các luồng có thể thực hiện các tác vụ đồng thời mà không gây ra các vấn đề như:
↳ Race Condition (Điều kiện đua): Khi hai hoặc nhiều luồng truy cập và thay đổi tài nguyên chung mà không đồng bộ hóa, dẫn đến kết quả không chính xác.
↳ Deadlock (Khóa chặt): Khi các luồng chờ nhau để giải phóng tài nguyên, dẫn đến tình trạng các luồng đều bị kẹt.
Đồng bộ hóa luồng được chia thành hai loại: Loại trừ lẫn nhau (Mutual Exclusion) và giao tiếp giữa các luồng (Inter-thread communication).
1. Loại trừ lẫn nhau (Mutual Exclusion)
Mô tả: Ngăn chặn các luồng can thiệp lẫn nhau bằng cách kiểm soát truy cập vào tài nguyên chia sẻ.
Phương thức đồng bộ hóa:
↳ Phương thức đồng bộ hóa (Synchronized Method): Sử dụng từ khóa synchronized để đồng bộ hóa phương thức, đảm bảo chỉ một luồng có thể thực thi phương thức tại một thời điểm.
↳ Khối đồng bộ hóa (Synchronized Block): Sử dụng từ khóa synchronized để đồng bộ hóa một phần của mã trong phương thức, cho phép linh hoạt hơn so với phương thức đồng bộ hóa.
↳ Đồng bộ hóa tĩnh (Static Synchronization): Sử dụng từ khóa synchronized để đồng bộ hóa các phương thức tĩnh, đảm bảo rằng chỉ một luồng có thể truy cập phương thức tĩnh tại một thời điểm.
Chúng ta sẽ tìm hiểu chi tiết từng phương thức đồng bộ hóa của loại trừ lẫn nhau (Mutual Exclusion) tại:Loại Trừ Thread
2. Giao tiếp giữa các luồng (Inter-thread communication)
Mô tả: Cho phép các luồng giao tiếp và phối hợp hành động với nhau.
Các cơ chế:
↳ wait(): Đặt luồng hiện tại vào trạng thái chờ cho đến khi một luồng khác gọi notify() hoặc notifyAll() trên đối tượng mà luồng đang chờ.
↳ notify(): Thông báo cho một luồng đang chờ trên đối tượng mà nó có thể tiếp tục thực hiện. Chỉ một luồng sẽ được thông báo và tiếp tục hoạt động.
↳ notifyAll(): Thông báo cho tất cả các luồng đang chờ trên đối tượng để tiếp tục thực hiện. Tất cả các luồng đang chờ sẽ được đánh thức và kiểm tra lại điều kiện của chúng.
↳ Condition Variables: Sử dụng trong Java với java.util.concurrent.locks.Condition để thay thế wait() và notify() với nhiều tính năng hơn.
Chúng ta sẽ tìm hiểu chi tiết từng cơ chế của giao tiếp giữa các luồng (Inter-thread communication) tại:Giao Tiếp Thread
Ⅲ. Race Condition (Điều kiện đua) và Deadlock (Khóa chặt)
Trong lập trình đa luồng, việc xử lý nhiều luồng cùng thao tác trên các tài nguyên chung là một thách thức lớn. Hai trong số những vấn đề phức tạp và thường gặp nhất trong quá trình này là Race Condition (Điều kiện đua) và Deadlock (Khóa chặt). Hiểu rõ và xử lý đúng hai vấn đề này là yếu tố quan trọng để đảm bảo tính ổn định và hiệu suất của ứng dụng.
Race Condition xảy ra khi hai hoặc nhiều luồng cùng truy cập và thay đổi tài nguyên chung mà không có sự đồng bộ hóa phù hợp. Kết quả của quá trình xử lý trở nên không thể đoán trước, có thể dẫn đến những lỗi logic nghiêm trọng hoặc kết quả không chính xác.
Deadlock xảy ra khi hai hoặc nhiều luồng bị kẹt trong tình trạng chờ nhau để giải phóng tài nguyên. Điều này khiến các luồng không thể tiếp tục công việc của mình, dẫn đến việc toàn bộ hệ thống bị đình trệ hoặc treo.
Ví dụ về Race Condition (Điều kiện đua)
Dưới đây là một ví dụ đơn giản về Race Condition (Điều kiện đua) trong Java. Trong ví dụ này, hai luồng đồng thời thay đổi giá trị của biến count mà không sử dụng đồng bộ hóa, dẫn đến kết quả không chính xác.
Giả sử chúng ta có một lớp Counter với một biến đếm count và hai luồng đang cố gắng tăng giá trị của biến này. Dưới đây là mã nguồn ví dụ:
Ví dụ: Example.java
class Counter {
private int count = 0;
// Phương thức tăng giá trị của biến đếm
public void increment() {
count++;
}
// Phương thức lấy giá trị của biến đếm
public int getCount() {
return count;
}
public static void main(String[] args) {
Counter counter = new Counter();
// Tạo hai luồng, mỗi luồng sẽ thực hiện phương thức increment
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
// Bắt đầu hai luồng
t1.start();
t2.start();
// Chờ các luồng kết thúc
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// In ra giá trị cuối cùng của biến đếm
System.out.println("Số lượng cuối cùng: " + counter.getCount());
}
}
Kết quả của chương trình là
Kết quả không đúng: Do không có đồng bộ hóa, giá trị cuối cùng của count có thể thấp hơn 2000 (1000 từ mỗi luồng). Điều này xảy ra vì hai luồng có thể đọc giá trị hiện tại của count, tăng nó lên, và ghi giá trị mới mà không kiểm tra các thay đổi của nhau. Để giải quyết vấn đề này, chúng ta cần sử dụng đồng bộ hóa.
Ví dụ về Deadlock (Khóa chặt)
Trong ví dụ này, có hai đối tượng khóa (lock1 và lock2) và hai luồng (Thread1 và Thread2). Cả hai luồng đều cố gắng chiếm hai khóa nhưng theo thứ tự ngược nhau, dẫn đến tình trạng chờ chéo và không thể tiếp tục.
Ví dụ: Example.java
public class Example {
// Hai đối tượng khóa
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public static void main(String[] args) {
Example example = new Example();
// Tạo và bắt đầu hai luồng
Thread t1 = new Thread(() -> example.method1());
Thread t2 = new Thread(() -> example.method2());
t1.start();
t2.start();
}
// Phương thức 1 cố gắng chiếm lock1 rồi lock2
public void method1() {
synchronized (lock1) {
System.out.println("Thread 1: đang Giữ lock1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Đang chờ lock2...");
synchronized (lock2) {
System.out.println("Thread 1: đã có được lock2!");
}
}
}
// Phương thức 2 cố gắng chiếm lock2 rồi lock1
public void method2() {
synchronized (lock2) {
System.out.println("Thread 2: đang Giữ lock2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Đang chờ lock1...");
synchronized (lock1) {
System.out.println("Thread 2: đã có được lock1!");
}
}
}
}
Kết quả của chương trình là
Thread 2: đang Giữ lock2...
Thread 1: Đang chờ lock2...
Thread 2: Đang chờ lock1...
Ví dụ đơn giản này minh họa rõ ràng cách mà deadlock có thể xảy ra và cách các luồng có thể bị mắc kẹt trong trạng thái chờ không thể kết thúc. Luồng 1 không thể tiếp tục vì nó đợi Luồng 2 giải phóng lock2, trong khi Luồng 2 không thể tiếp tục vì nó đợi Luồng 1 giải phóng lock1. Kết quả là cả hai luồng đều bị kẹt trong trạng thái chờ lẫn nhau, dẫn đến deadlock.
Trong chương này, chúng ta sẽ thảo luận về các phương thức đồng bộ hóa trong Java. Các phương thức này rất quan trọng để đảm bảo rằng các luồng không can thiệp vào nhau khi truy cập các tài nguyên chia sẻ. Chúng ta sẽ xem xét ba phương pháp chính của đồng bộ hóa luồng
Ⅳ. Đồng bộ hóa tái nhập (Reentrant Synchronization)
Đồng bộ hóa tái nhập (Reentrant Synchronization) trong Java cho phép một luồng (thread) sử dụng lại cùng một khóa nội tại (monitor) cho các phương thức đồng bộ hóa khác nhau nếu phương thức này được gọi từ chính phương thức đã giữ khóa đó. Đây là một tính năng quan trọng trong Java giúp đơn giản hóa việc đồng bộ hóa và giảm thiểu các vấn đề liên quan đến khóa.
Ưu điểm của khóa đồng bộ hóa tái nhập
↳ Loại bỏ khả năng khóa chặt (deadlock) đơn luồng: Một luồng không bao giờ bị kẹt (deadlock) khi cố gắng sở hữu cùng một khóa nhiều lần, điều này giúp đơn giản hóa việc lập trình và quản lý đồng bộ hóa.
Ví dụ về khóa tái nhập
Hãy cùng xem một ví dụ để hiểu rõ hơn về khóa tái nhập trong Java:
Ví dụ: Example.java
public class Example {
private int counter = 0;
public synchronized void methodA() {
counter++;
System.out.println("Method A: Count = " + count);
methodB(); // Gọi phương thức đồng bộ hóa khác
}
public synchronized void methodB() {
counter--;
System.out.println("Method B: Count = " + count);
}
public static void main(String[] args) {
Example example = new Example();
example.methodA(); // Gọi phương thức đồng bộ hóa đầu tiên
}
}
Kết quả của chương trình là
Method B: Count = 0
Trong ví dụ trên, phương thức MethodA() gọi phương thức MethodB(), cả hai đều được đồng bộ hóa. Vì cùng sử dụng khóa nội tại của đối tượng Counter, nên luồng hiện tại có thể gọi MethodB() mà không bị chặn.
Nếu methodA() được đồng bộ hóa và methodB() không được đồng bộ hóa, bạn có thể gặp phải vấn đề về tính nhất quán dữ liệu nếu methodB() ảnh hưởng đến các tài nguyên dùng chung mà methodA() cũng sử dụng. Để đảm bảo tính nhất quán và tránh các vấn đề đồng bộ hóa, tốt nhất là đồng bộ hóa tất cả các phương thức liên quan đến tài nguyên dùng chung hoặc đảm bảo rằng việc đồng bộ hóa được thực hiện đúng cách.