1. Vấn đề của mã đồng bộ
Hãy tưởng tượng: bạn có một chương trình cần tải dữ liệu từ Internet hoặc đọc một tệp lớn. Bạn viết một thứ như:
String data = readFromFile("bigfile.txt");
System.out.println("Dữ liệu: " + data);
Mọi thứ sẽ ổn, nhưng nếu tệp lớn hoặc mạng chậm, chương trình sẽ bị treo ở dòng đọc. Người dùng nhìn vào giao diện “bị treo”, máy chủ không thể phục vụ các yêu cầu khác, còn lập trình viên... buồn bã.
Tình huống này gọi là blocking: luồng (ví dụ, luồng chính của ứng dụng) buộc phải chờ cho tới khi thao tác hoàn tất. Và nếu có nhiều thao tác như vậy — xin chào độ trễ và hiệu năng thấp.
Giống như bạn tới quán, gọi đồ và... phải đứng ở quầy cho tới khi cà phê được pha xong. Những khách khác đứng sau cũng phải chờ cho tới khi barista xong phần của bạn. Không hiệu quả chút nào, phải không?
Bất đồng bộ: nó “cứu cánh” như thế nào
Lập trình bất đồng bộ là cách tiếp cận trong đó các thao tác tốn thời gian (ví dụ: đọc tệp, gọi tới máy chủ, truy vấn cơ sở dữ liệu) được thực thi ở luồng nền, còn luồng chính tiếp tục làm việc: phục vụ người dùng, nhận yêu cầu mới, phản hồi sự kiện.
Tức là bạn gọi món (khởi chạy tác vụ), rồi đi làm việc của mình, và khi cà phê xong (tác vụ hoàn thành), bạn chỉ được thông báo: “Xong rồi!”
Trong Java, trước khi CompletableFuture xuất hiện, việc này không mấy thuận tiện. Hãy xem mọi thứ đã phát triển thế nào.
2. Các cách tiếp cận lịch sử: Future và các hạn chế của nó
Trong Java 5 xuất hiện giao diện Future — nỗ lực đầu tiên để làm việc với tác vụ bất đồng bộ tiện hơn một chút. Nó cho phép giao phó tác vụ cho pool luồng và một lúc nào đó nhận kết quả.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Cẩn thận: luồng sẽ bị chặn cho tới khi tác vụ xong!
Ý tưởng có vẻ ổn, nhưng trong thực tế, Future giống như một hòm thư cũ: bạn đã gửi thư, nhưng để biết có hồi âm chưa, bạn phải liên tục mở ra xem.
Nó không biết tự thông báo khi có kết quả, không hỗ trợ xâu chuỗi hành động kiểu “làm cái này rồi làm cái kia”, không cho phép xử lý lỗi đẹp đẽ. Tất cả đều tắc ở lời gọi blocking get(), khiến bất đồng bộ lại biến thành chờ đợi.
3. Sự xuất hiện của CompletableFuture: phong cách bất đồng bộ mới
Trong Java 8, thay cho Future đã lỗi thời, có một “anh hùng” bất đồng bộ thực thụ — CompletableFuture. Lớp này thuộc gói java.util.concurrent đã trở thành công cụ đa năng cho những ai mệt mỏi vì phải “tự đi lấy” kết quả và muốn viết mã bất đồng bộ gọn gàng, rõ ràng.
CompletableFuture làm được gần như mọi thứ. Nó có thể chạy tác vụ ở luồng khác, xâu chuỗi chúng — ví dụ: đầu tiên tính kết quả, sau đó xử lý, rồi làm gì đó nữa. Nó dễ dàng kết hợp nhiều tác vụ: có thể chờ tất cả cùng xong hoặc chỉ tác vụ nào xong trước. Lỗi cũng được xử lý một cách thanh lịch — không cần quá nhiều try-catch. Phong cách làm việc tiến gần hơn với lập trình hàm: thay vì những lời gọi chán ngắt và chờ đợi, xuất hiện các phương thức biểu đạt như thenApply, thenAccept và nhiều cái khác.
flowchart LR
A[Khởi chạy tác vụ bất đồng bộ] --> B[Xử lý kết quả]
B --> C[Bước tiếp theo]
C --> D[Xử lý lỗi]
Như vậy, CompletableFuture đã biến bất đồng bộ từ một “lao động nặng nhọc” thành công cụ tiện lợi và linh hoạt, giúp mã “dễ thở” hơn hẳn.
4. Ví dụ đơn giản nhất: bước đầu vào thế giới CompletableFuture
Hãy xem một ví dụ tối giản về tác vụ bất đồng bộ:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Khởi chạy tác vụ bất đồng bộ
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Lấy kết quả (chặn luồng!)
try {
int result = future.get();
System.out.println("Kết quả: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Đoạn mã này đã thực thi phép tính ở một luồng riêng — luồng chính không bị chặn tại thời điểm khởi chạy tác vụ. Nhưng lời gọi get() vẫn chặn luồng cho tới khi có kết quả.
Làm thế nào để KHÔNG chặn luồng?
Rất đơn giản: dùng các phương thức callback, được gọi khi tác vụ hoàn thành:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Kết quả: " + result));
System.out.println("Tôi không bị chặn và vẫn có thể làm việc khác!");
Kết luận:
- thenAccept — “đăng ký nhận kết quả”: khi tác vụ hoàn thành, hãy gọi đoạn mã này.
- Luồng chính không chờ tác vụ hoàn thành mà tiếp tục làm việc.
Trực quan hóa (pseudocode các sự kiện)
[Luồng chính] --> [Khởi chạy tác vụ]
| |
v v
[Làm việc khác] [Luồng nền tính 2+2]
| |
v v
[In "Tôi không bị chặn..."]
| |
v v
[Khi tính xong — thenAccept được gọi]
5. Điều này trông như thế nào trong ứng dụng?
Hãy tưởng tượng bạn phát triển một ứng dụng console, nơi người dùng có thể yêu cầu tải dữ liệu (ví dụ từ cơ sở dữ liệu hoặc máy chủ), và trong khi dữ liệu đang tải — chương trình không “treo” mà vẫn tiếp tục nhận lệnh.
Ví dụ: mô phỏng tác vụ lâu
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Bắt đầu tải dữ liệu...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Mô phỏng tải lâu
try {
Thread.sleep(2000); // 2 giây
} catch (InterruptedException e) {
return "Lỗi tải dữ liệu";
}
return "Dữ liệu đã được tải thành công!";
});
// Đăng ký nhận kết quả
dataFuture.thenAccept(result -> System.out.println("Kết quả: " + result));
// Chương trình tiếp tục chạy
System.out.println("Trong lúc dữ liệu đang tải, tôi có thể làm việc khác!");
// Để chương trình không kết thúc quá sớm (chỉ cho mục đích demo!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Bạn sẽ thấy gì trên console:
Bắt đầu tải dữ liệu...
Trong lúc dữ liệu đang tải, tôi có thể làm việc khác!
[sau 2 giây]
Kết quả: Dữ liệu đã được tải thành công!
6. Những lưu ý hữu ích
Một chút về các luồng “bên dưới”
Khi bạn viết CompletableFuture.supplyAsync(...), theo mặc định tác vụ sẽ chạy trong ForkJoinPool — một pool luồng đặc biệt mà Java dùng cho các tác vụ song song. Nếu bạn cần kiểm soát nhiều hơn (ví dụ, tự dùng ExecutorService của bạn), có thể truyền nó làm tham số thứ hai:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Nhưng với các tác vụ đơn giản, pool mặc định là đủ.
Lấy kết quả: get(), join(), thenAccept
- get() — chặn luồng cho tới khi có kết quả (ném checked exception).
- join() — cũng chặn, nhưng ném unchecked exception (RuntimeException).
- thenAccept(), thenApply() và các phương thức khác — KHÔNG chặn mà gọi hàm đã truyền khi kết quả sẵn sàng.
Trong các ứng dụng bất đồng bộ thực tế, hãy tránh get()/join() trên luồng chính!
7. Những lỗi thường gặp khi bắt đầu với CompletableFuture
Lỗi № 1: Dùng get() hoặc join() trên luồng chính.
Làm vậy bạn lại chặn chương trình và mất hết lợi ích của bất đồng bộ. Thay vào đó, hãy dùng thenAccept, thenApply và các phương thức khác để xử lý kết quả.
Lỗi № 2: Quên xử lý lỗi.
Nếu có ngoại lệ phát sinh trong tác vụ bất đồng bộ, nó sẽ không “bật” ra luồng chính. Nếu không xử lý qua exceptionally hoặc handle, bạn sẽ không biết có sự cố xảy ra.
Lỗi № 3: Không đợi chương trình kết thúc.
Trong các ví dụ demo, ta thường phải “làm chậm” luồng main bằng Thread.sleep — nếu không chương trình sẽ kết thúc trước khi tác vụ hoàn thành. Trong ứng dụng thực tế (ví dụ trên máy chủ web) điều này không phải vấn đề, nhưng với demo console — hãy lưu ý.
Lỗi № 4: Nhầm lẫn thenAccept và thenApply.
thenAccept — dành cho “tác dụng phụ” (không trả về gì), thenApply — để biến đổi kết quả (trả về kết quả mới).
Lỗi № 5: Trộn lẫn mã bất đồng bộ và đồng bộ một cách không cần thiết.
Nếu đã viết theo hướng bất đồng bộ — đừng kéo về đồng bộ bằng get()/join(), trừ khi thực sự cần (ví dụ trong kiểm thử).
GO TO FULL VERSION