1. Khi đa luồng hữu ích
Đa luồng cần thiết khi có nhiều việc có thể làm đồng thời. Ví dụ, nếu cần xử lý hàng chục tệp — sao chép, tính toán lại hoặc phân tích — thì giao các phần khác nhau cho các luồng khác nhau sẽ đơn giản hơn là làm tuần tự. Giống như thay vì một người bạn lục lọi kho ảnh của bạn, bạn gọi luôn năm người: công việc sẽ nhanh hơn và thú vị hơn.
Đa luồng đặc biệt hữu ích khi bạn xử lý theo lô các tệp, tải xuống hoặc sao chép các tệp lớn theo từng phần, hoặc khi sau khi đọc dữ liệu cần tính toán song song cho các phần khác nhau.
Nhưng đa luồng không phải lúc nào cũng giúp ích. Nếu bạn chỉ có một tệp nhỏ, khởi chạy cả chục luồng cho nó là vô nghĩa. Nếu đĩa hoặc mạng đã bị tải nặng, các luồng mới chỉ làm chậm quá trình. Và nếu nhiều luồng đồng thời ghi vào cùng một tệp mà không có đồng bộ — bạn có thể nhận được một mớ hỗn độn với dữ liệu bị hỏng.
Nói ngắn gọn, đa luồng là một công cụ. Như cái búa: có thể dùng để đóng đinh, cũng có thể đập vào ngón tay. Điều quan trọng — biết khi nào và cách sử dụng nó.
2. Công cụ Java cho IO đa luồng
Bạn hẳn đã biết Java có vài cách để chạy tác vụ song song:
- Thread cổ điển — tự tạo luồng thủ công.
- Pool luồng qua ExecutorService — cách hiện đại, linh hoạt và tiện lợi.
- CompletableFuture và stream song song (Stream API) — cho các bài toán nâng cao (sẽ xem kỹ hơn trong các bài giảng sau).
Bắt đầu với điều đơn giản nhất: xử lý nhiều tệp ở các luồng khác nhau.
Ví dụ 1: Thread cổ điển
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Tệp đã được sao chép: " + source);
} catch (IOException e) {
System.err.println("Lỗi sao chép " + source + ": " + e.getMessage());
}
}
}
// Khởi chạy nhiều lần sao chép trong các luồng riêng biệt
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
Ưu điểm: Đơn giản, dễ hiểu.
Nhược điểm: Khó quản lý nhiều luồng bằng tay, không kiểm soát được số luồng chạy đồng thời.
Ví dụ 2: ExecutorService — pool luồng
ExecutorService cho phép bạn ủy thác tác vụ cho một pool luồng, pool sẽ tự quyết định dùng bao nhiêu luồng đồng thời.
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // tối đa 4 luồng
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Đã sao chép: " + file);
} catch (IOException e) {
System.err.println("Lỗi: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // không nhận thêm tác vụ
executor.awaitTermination(1, TimeUnit.MINUTES); // chờ tất cả tác vụ hoàn thành
}
}
Ưu điểm:
- Dễ mở rộng (có thể đặt số lượng luồng cần thiết).
- Thuận tiện kiểm soát việc hoàn thành tác vụ (các phương thức shutdown(), awaitTermination(...)).
- Phù hợp để xử lý hàng trăm, hàng nghìn tệp.
3. Vấn đề và giới hạn của IO đa luồng
Cạnh tranh tài nguyên
Nếu bạn cố gắng đồng thời đọc hoặc ghi cùng một tệp từ nhiều luồng mà không có đồng bộ — kết quả sẽ không thể đoán trước. Giống như hai người cùng viết trên một trang sách: sẽ thành mớ lộn xộn. Để phối hợp, hãy dùng, ví dụ, synchronized, khóa tường minh hoặc một luồng ghi riêng.
Giới hạn của hệ thống tệp và hệ điều hành
- Không phải mọi hệ thống tệp đều hỗ trợ tốt việc ghi đồng thời vào một tệp.
- Hệ điều hành có thể giới hạn số tệp mở đồng thời.
- Ổ đĩa cứng (đặc biệt HDD) hoạt động kém khi có nhiều truy cập ngẫu nhiên.
Đồng bộ khi ghi vào tài nguyên dùng chung
Nếu nhiều luồng ghi vào một tệp (ví dụ, log), nhất thiết phải dùng đồng bộ (ví dụ qua synchronized, khóa, hoặc các luồng ghi chuyên dụng).
Kém hiệu quả với tệp nhỏ
Với các tệp nhỏ, chi phí tạo luồng và chuyển ngữ cảnh có thể lớn hơn lợi ích từ song song hóa.
4. Ví dụ thực tiễn
Sao chép tệp song song
Giả sử chúng ta có một thư mục chứa nhiều log cần sao chép vào thư mục lưu trữ.
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... thêm bao nhiêu tệp tùy ý
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Đã sao chép: " + file);
} catch (IOException e) {
System.err.println("Lỗi: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
Bình luận:
- Chúng ta dùng pool gồm 4 luồng — thường là đủ để tải đĩa mà không làm quá tải hệ thống.
- Với 1000 tệp có thể tăng pool lên 8, nhưng không nên đặt quá lớn.
Xử lý các dòng của tệp song song bằng Stream API
Từ Java 8 có thể dùng stream song song để xử lý nội dung tệp:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines trả về Stream<String> — luồng các dòng của tệp
Files.lines(path)
.parallel() // chuyển thành luồng song song
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("Lỗi: " + line));
}
}
Lưu ý:
- Stream song song tăng tốc xử lý nếu tác vụ tốn nhiều tính toán (CPU-bound), chứ không phải quá trình đọc (IO-bound).
- Nếu việc xử lý dòng đơn giản (ví dụ chỉ in qua System.out.println), có thể không có cải thiện.
Đọc/ghi các phần khác nhau của cùng một tệp lớn
Java cho phép đọc hoặc ghi các vùng khác nhau của cùng một tệp đồng thời bằng FileChannel và các phương thức định vị. Đây là mức nâng cao, nhưng nguyên tắc đơn giản: mỗi luồng làm việc với phần riêng của tệp.
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long size) {
this.path = path;
this.position = position;
this.size = size;
}
@Override
public void run() {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) size);
channel.read(buffer, position);
System.out.println("Đã đọc khối từ vị trí " + position + " với kích thước " + size);
// Ở đây có thể xử lý buffer
} catch (IOException e) {
System.err.println("Lỗi đọc khối: " + e.getMessage());
}
}
}
// Ví dụ chạy: đọc tệp theo từng khối 1 MB bằng 4 luồng
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
Bình luận:
- Mỗi luồng đọc phần riêng của tệp, không cản trở nhau.
- Cách tiếp cận này được dùng, ví dụ, trong torrent và các trình tải xuống.
Đồng bộ khi ghi vào một tệp chung
Nếu nhiều luồng ghi vào cùng một tệp (ví dụ, log), cần đồng bộ truy cập để không nhận một “món lộn xộn” của các dòng:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
Bình luận:
- Phương thức log được đánh dấu synchronized để tại mỗi thời điểm chỉ một luồng ghi vào tệp.
- Cách này hoạt động, nhưng khi có nhiều luồng có thể trở thành nút thắt — tốt hơn là ghi ra các tệp khác nhau rồi hợp nhất.
5. Khi không nên dùng đa luồng
Đa luồng rất hấp dẫn: nhiều luồng hơn thì mọi thứ phải “bay” hơn! Nhưng thực tế không luôn như vậy. Nếu bạn xử lý vài tệp nhỏ, thì làm tuần tự sẽ đơn giản và đáng tin cậy hơn. Thời gian bạn bỏ ra để khởi chạy luồng và phối hợp giữa chúng có thể không bù lại được.
Đôi khi vấn đề không nằm ở đĩa mà ở mạng — khi đó thêm luồng cũng không tăng tốc, vì nút thắt cổ chai (bottleneck) ở chỗ khác. Một cái bẫy nữa là ghi song song vào cùng một tệp. Nếu bạn ít kinh nghiệm với đồng bộ, tốt nhất đừng thử: rất dễ làm hỏng dữ liệu.
Và cuối cùng, nếu đĩa hoặc hệ thống tệp của bạn không chịu nổi khi bị hàng chục luồng truy cập đồng thời, — đa luồng sẽ không cứu vãn mà còn làm tình hình tệ hơn.
Nói ngắn gọn, nếu bạn nghĩ “càng nhiều luồng càng tốt”, — thường là không phải vậy. Đôi khi một luồng “điềm tĩnh” làm việc gọn gàng, nhanh và đáng tin cậy hơn cả chục luồng vội vã.
6. Làm quen nhanh với FileChannel cho các bài toán nâng cao
FileChannel trong gói java.nio.channels — là công cụ làm việc với tệp ở mức thấp, cho phép đọc và ghi dữ liệu theo các vị trí tùy ý. Nhờ đó có thể triển khai, ví dụ, tải xuống song song hoặc xử lý tệp lớn theo từng phần.
Ví dụ:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // đọc 1024 byte từ vị trí 0
// xử lý buffer
}
Lưu ý:
- FileChannel không được đồng bộ — nếu nhiều luồng làm việc với cùng một kênh, bạn phải tự triển khai đồng bộ.
- Để làm việc song song, đơn giản hơn là mở một kênh riêng cho mỗi luồng.
7. Các lỗi thường gặp khi IO đa luồng
Lỗi số 1: Truy cập ghi vào cùng một tệp không được đồng bộ.
Kết quả là — dữ liệu bị hỏng, ký tự lạ, đôi khi tệp không đọc được. Luôn đồng bộ truy cập hoặc ghi ra các tệp riêng.
Lỗi số 2: Quá nhiều luồng.
Nếu bạn mở 1000 luồng chỉ để sao chép 1000 tệp, máy của bạn có thể “giận dỗi” (OutOfMemoryError, giật lag, sập). Hãy dùng pool luồng (ExecutorService) và giới hạn số lượng.
Lỗi số 3: Không đóng luồng/tệp.
Mỗi luồng hoặc tệp mở là tài nguyên của hệ điều hành. Nếu không đóng, bạn có thể gặp lỗi “Too many open files”. Hãy dùng try-with-resources hoặc đừng quên gọi close().
Lỗi số 4: Kết thúc chương trình quá sớm.
Nếu không đợi tất cả các luồng hoàn thành (ví dụ, không gọi executor.awaitTermination(...)), chương trình có thể kết thúc trước khi sao chép xong tất cả tệp.
Lỗi số 5: Ghi song song vào cùng một vùng của tệp mà không tính đến vị trí.
Nếu nhiều luồng ghi vào cùng một vùng của tệp — dữ liệu sẽ bị trộn lẫn. Để ghi theo vị trí, hãy dùng channel và chia dải rõ ràng.
GO TO FULL VERSION