1. Giới thiệu về IO bất đồng bộ
Hãy làm rõ thuật ngữ ngay. Trong I/O cổ điển (đồng bộ), khi bạn gọi phương thức đọc hoặc ghi, luồng thực thi của bạn (ví dụ, luồng chính của chương trình) sẽ dừng lại và chờ cho đến khi thao tác hoàn tất. Nó giống như bạn gọi cho một người bạn và, cho đến khi họ nhấc máy, bạn chỉ đứng đợi, nhìn chằm chằm vào điện thoại.
I/O bất đồng bộ (AIO) — là khi bạn giao phó thao tác đọc/ghi cho hệ thống, còn bạn tiếp tục làm việc. Khi thao tác hoàn tất — họ sẽ “gọi lại” cho bạn (ví dụ, gọi phương thức callback của bạn hoặc trả về kết quả thông qua Future).
Khi nào cần dùng?
- Ứng dụng phía máy chủ: để không lãng phí luồng trong khi đĩa “đang nghĩ”.
- Xử lý hàng loạt tệp lớn: để không chặn luồng chính.
- Ứng dụng có UI: để giao diện không “đơ” trong lúc đọc/ghi.
Hãy tưởng tượng bạn đặt pizza. Trong thế giới đồng bộ, bạn sẽ đứng chờ ở cửa cho đến khi người giao đến nơi. Trong thế giới bất đồng bộ — bạn làm việc của mình, và khi pizza tới, họ sẽ gọi và nói: “Pizza đã đến!”
2. Tổng quan về AsynchronousFileChannel
Trong Java, I/O bất đồng bộ được triển khai trong gói java.nio.channels từ phiên bản 7. Nhân vật chính là lớp AsynchronousFileChannel.
Nó làm được gì?
- Đọc và ghi dữ liệu vào tệp một cách bất đồng bộ.
- Làm việc với bộ đệm (ByteBuffer).
- Sử dụng nhiều cách để nhận kết quả: qua Future hoặc qua CompletionHandler.
- Cho phép chỉ định rõ thread pool (ExecutorService) để xử lý sự kiện.
Các phương thức chính
- read(ByteBuffer dst, long position): trả về Future<Integer>.
- read(ByteBuffer dst, long position, A attachment, CompletionHandler<Integer, ? super A> handler).
- write(ByteBuffer src, long position): trả về Future<Integer>.
- write(ByteBuffer src, long position, A attachment, CompletionHandler<Integer, ? super A> handler).
- static open(Path file, Set<OpenOption> options, ExecutorService executor, FileAttribute<?>... attrs) — mở kênh.
Cách sử dụng:
- Qua Future: bạn khởi chạy thao tác và có thể chờ nó hoàn tất sau đó.
- Qua CompletionHandler: bạn truyền “trình xử lý” sẽ được gọi khi thao tác hoàn tất (hoặc xảy ra lỗi).
Ví dụ mở tệp để đọc/ghi bất đồng bộ
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.EnumSet;
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("data.txt"),
EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE)
);
Bạn cũng có thể chỉ định rõ thread pool để xử lý sự kiện:
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
ExecutorService executor = Executors.newFixedThreadPool(4);
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("data.txt"),
EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE),
executor
);
Thông tin thú vị:
Nếu không chỉ định ExecutorService, Java sẽ tạo một thread pool nội bộ để phục vụ các sự kiện I/O. Với tác vụ đơn giản thì đủ, nhưng với ứng dụng máy chủ, tốt hơn là tự quản lý pool.
3. Thread pool (ExecutorService) và vai trò của chúng
Khi bạn làm việc với kênh bất đồng bộ, đâu đó phía sau hậu trường, Java phải chạy các callback của bạn hoặc hoàn tất Future. Nó không phải phép thuật, mà dùng các luồng làm việc đặc biệt — executor service.
Nếu bạn không truyền thread pool riêng, Java sẽ tạo một pool nội bộ — thường là một luồng cho mỗi CPU. Tiện, nhưng không phải lúc nào cũng an toàn. Khi bạn muốn tự kiểm soát số lượng luồng, mức độ ưu tiên và cách phân bổ tải, tốt hơn là tạo ExecutorService của riêng bạn và truyền vào open.
Trong ứng dụng máy chủ, điều này đặc biệt quan trọng. Nếu không có pool riêng, bạn có thể gặp các đợt tải tăng đột biến — và thay vì chạy mượt, máy chủ sẽ nghẹt thở.
Ví dụ:
ExecutorService pool = Executors.newFixedThreadPool(8);
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("huge.log"),
EnumSet.of(StandardOpenOption.READ),
pool
);
Ảnh hưởng của việc chọn pool:
- Nhiều luồng — song song cao hơn, nhưng tải hệ thống cũng lớn hơn.
- Ít luồng — ít thao tác đồng thời, nhưng ít overhead.
- Nếu bạn khởi chạy hàng nghìn thao tác bất đồng bộ, hãy cân nhắc cân bằng!
4. Thực hành: Đọc tệp bất đồng bộ
Đọc đồng bộ (để so sánh)
import java.nio.file.Files;
import java.nio.file.Path;
byte[] data = Files.readAllBytes(Path.of("input.txt"));
System.out.println("Đã đọc byte: " + data.length);
Vấn đề ở đây là luồng chỉ chờ cho đến khi toàn bộ tệp được đọc xong. Nếu tệp lớn hoặc đĩa chậm, chương trình cũng sẽ “chậm” — mọi thứ khác lúc này đều bị tạm dừng.
Đọc bất đồng bộ với AsynchronousFileChannel và Future
import java.nio.channels.AsynchronousFileChannel;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
public class AsyncReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("input.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // đọc theo từng 1 KB
Future<Integer> result = channel.read(buffer, 0);
// Có thể làm việc khác song song!
System.out.println("Đọc đã bắt đầu...");
// ... rồi chờ kết quả
int bytesRead = result.get(); // chặn luồng cho đến khi thao tác hoàn tất
System.out.println("Đã đọc byte: " + bytesRead);
buffer.flip();
// Chuyển mảng byte thành chuỗi (nếu là văn bản)
byte[] data = new byte[bytesRead];
buffer.get(data, 0, bytesRead);
String text = new String(data);
System.out.println("Nội dung: " + text);
}
}
}
- channel.read(buffer, 0) — khởi chạy đọc bất đồng bộ từ vị trí 0.
- Trả về Future<Integer>, có thể dùng để chờ kết quả.
- Trong khi thao tác chưa xong, có thể thực hiện công việc khác.
- result.get() chặn luồng, nhưng chỉ khi kết quả chưa sẵn sàng.
Đọc bất đồng bộ với CompletionHandler
(Sẽ phân tích chi tiết trong bài kế tiếp, còn bây giờ...)
import java.nio.channels.AsynchronousFileChannel;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.channels.CompletionHandler;
public class AsyncReadWithHandler {
public static void main(String[] args) throws Exception {
Path path = Path.of("input.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer bytesRead, ByteBuffer buf) {
buf.flip();
byte[] data = new byte[bytesRead];
buf.get(data, 0, bytesRead);
String text = new String(data);
System.out.println("Đọc bất đồng bộ: " + text);
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("Lỗi đọc: " + exc.getMessage());
}
});
// Không để chương trình kết thúc ngay (nếu không callback chưa kịp chạy)
Thread.sleep(100); // Trong ứng dụng thực tế — nên đồng bộ bằng latch, future, v.v.
}
}
}
5. Những lưu ý hữu ích
So sánh: Đọc bất đồng bộ vs đọc đồng bộ
| Đặc trưng | IO đồng bộ ( Files.readAllBytes ) | IO bất đồng bộ ( AsynchronousFileChannel ) |
|---|---|---|
| Chặn luồng | Có | Không (nếu không gọi get()) |
| Khả năng mở rộng | Thấp | Cao |
| Phù hợp cho UI/máy chủ | Không | Có |
| Độ phức tạp mã | Đơn giản | Phức tạp hơn một chút |
| Quản lý tài nguyên | Đơn giản | Quan trọng: đừng quên đóng kênh! |
Sơ đồ hoạt động của IO bất đồng bộ
sequenceDiagram
participant Main as Luồng của bạn
participant OS as Hệ điều hành
participant Disk as Đĩa
Main->>OS: Bắt đầu đọc bất đồng bộ (read)
OS->>Disk: Đọc dữ liệu
Main->>Main: Thực hiện công việc khác
OS-->>Main: Thông báo hoàn tất (Future/CompletionHandler)
Main->>Main: Xử lý kết quả
6. Những lỗi thường gặp khi làm việc với AsynchronousFileChannel
Lỗi số 1: quên đóng kênh.
AsynchronousFileChannel — là tài nguyên cần phải đóng. Nếu quên đóng kênh (channel.close() hoặc try-with-resources), có thể gây rò rỉ descriptor và lỗi truy cập tệp. Hãy dùng try-with-resources bất cứ khi nào có thể.
Lỗi số 2: gọi get() chặn trong luồng chính.
Nếu bạn dùng Future và gọi get() trong luồng chính (ví dụ ứng dụng UI), bạn đã mất ý nghĩa của I/O bất đồng bộ — luồng vẫn phải chờ. Hãy dùng CompletionHandler hoặc một luồng riêng để chờ kết quả.
Lỗi số 3: làm việc không đúng với ByteBuffer.
Sau khi ghi vào buffer, đừng quên gọi flip() để chuẩn bị cho việc đọc. Sau khi đọc — gọi clear() hoặc compact() nếu bạn sẽ dùng lại nó.
Lỗi số 4: quên xử lý lỗi.
Các thao tác bất đồng bộ có thể kết thúc với lỗi (ví dụ tệp không tồn tại, không có quyền). Nếu không xử lý ngoại lệ trong CompletionHandler hoặc không kiểm tra lỗi trên Future, chương trình sẽ “im lặng” không thực hiện thao tác.
Lỗi số 5: không tính đến tính song song.
Nếu bạn khởi chạy nhiều thao tác trên cùng một kênh đồng thời, hãy đảm bảo mã của bạn an toàn luồng và không xảy ra tranh chấp về buffer hoặc vị trí tệp.
GO TO FULL VERSION