CodeGym /Các khóa học /C# SELF /Tối ưu hóa làm việc với các tệp lớn

Tối ưu hóa làm việc với các tệp lớn

C# SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Khi làm việc với tệp nhỏ, thường bạn không để ý: ghi, đọc — rồi quên. Nhưng ngay khi kích thước tệp bắt đầu vượt quá ít nhất 100–500 MB, chưa kể đến gigabyte, các hiệu ứng thú vị xuất hiện:

  • Các thao tác trở nên chậm hơn, đặc biệt nếu làm "thẳng" — ví dụ, File.ReadAllBytes() hoặc File.WriteAllText() đột nhiên làm toàn bộ chương trình bị ì.
  • RAM có thể không đủ, xuất hiện OutOfMemoryException.
  • Hệ thống bắt đầu dùng swap (bộ nhớ ảo), điều này có thể làm chậm toàn bộ hệ thống.
  • Các tác vụ song song có thể tạo tải thừa lên ổ đĩa.

Trong bài toán thực tế điều này gặp khá thường xuyên:

  • Log của server (gigabyte mỗi ngày).
  • Xử lý CSV hoặc XML lớn khi import-export.
  • Làm việc với video, audio, archive, tệp nhị phân.
  • Sao chép các assembly lớn hoặc backup.

2. Chiến lược tối ưu khi làm việc với tệp lớn

Trước khi nhảy vào tối ưu — hãy xác định rõ bạn muốn tăng tốc hoặc cải thiện điều gì. Đây là các nhiệm vụ thường gặp:

  • "Đọc/ghi tệp nhanh nhất có thể và không làm chết hệ thống".
  • "Xử lý tệp theo phần để không tải đầy RAM".
  • "Không tạo bản sao dữ liệu thừa trong bộ nhớ".
  • "Xử lý tệp lớn song song (nếu khả thi)".

Các cách tiếp cận chung:

  • Đọc/ghi theo luồng: dùng streams và buffers, đọc/ghi theo khối (FileStream, BufferedStream).
  • Thao tác tệp trực tiếp trên đĩa — không dùng bản sao trung gian trong bộ nhớ.
  • Quản lý cẩn thận buffer và bộ nhớ: không giữ toàn bộ tệp trong RAM.
  • Thao tác bất đồng bộ — nếu cần không block thread chính (chi tiết ở bài tiếp theo).

3. Đọc/ghi theo luồng: pattern cơ bản

Nguyên tắc chính: Làm việc với tệp theo từng phần! Trong C# hiện đại điều này dễ làm với các lớp FileStream, BufferedStream, hay bất kỳ thao tác nào qua streams.


// Mở stream để đọc:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1 MB
int bytesRead;

// Đọc cho đến khi hết file
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
    // Xử lý dữ liệu đã đọc ở đây!
    // Ví dụ, tính tổng tất cả các byte (chỉ để luyện tập)
    long sum = 0;
    for (int i = 0; i < bytesRead; i++)
        sum += buffer[i];

    Console.WriteLine($"Đã đọc {bytesRead} byte, tổng: {sum}");
}

Mẹo: Kích thước buffer (ví dụ 64 KB, 128 KB, 1 MB) hãy chọn bằng thử nghiệm. Quá nhỏ — nhiều lần truy cập đĩa. Quá lớn — không nhất thiết nhanh hơn nhưng tốn bộ nhớ.

Tại sao như vậy? Đọc tệp bằng File.ReadAllBytes() hoặc File.ReadAllText() mà không chia phần cố gắng load toàn bộ nội dung vào RAM. Nếu tệp rất lớn — kết quả rõ ràng: bạn sẽ gặp OutOfMemoryException.

4. BufferedStream: vì sao và khi nào cần

Chúng ta đã biết lớp này ở bài trước, nhưng nhắc lại: BufferedStream là wrapper quanh bất kỳ stream nào, cho phép đọc/ghi theo block thay vì từng byte một.

Ví dụ sử dụng:


using var fileStream = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
using var bufferedStream = new BufferedStream(fileStream, 1024 * 128);

byte[] buffer = new byte[1024 * 128];
int bytesRead;
while ((bytesRead = bufferedStream.Read(buffer, 0, buffer.Length)) > 0)
{
    // Xử lý dữ liệu
}

Trong một số trường hợp dùng BufferedStream tăng tốc, đặc biệt khi bạn đọc vài byte một lần, trong khi filesystem thích truy cập theo block.

Sự thật thú vị: Trong FileStream của .NET mới hơn đã có buffer tích hợp, nên dùng BufferedStream thủ công mang lại lợi ích nhất khi làm việc với "raw" streams, ví dụ network hoặc thiết bị không chuẩn.

5. Đọc và ghi các tệp văn bản lớn

Với tệp nhị phân thì rõ ràng: đọc/ghi theo block. Nhưng với tệp văn bản lớn như CSV, log, JSON thì sao?

Ở đây StreamReader để đọc và StreamWriter để ghi sẽ cứu giúp.

Đọc theo dòng:


using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    // Xử lý dòng
    if (line.Contains("ERROR"))
        Console.WriteLine("Phát hiện ERROR: " + line);
}

Tại sao tốt?

  • Chúng ta không giữ toàn bộ tệp trong bộ nhớ.
  • StreamReader đã có buffer được cấu hình hợp lý.

Ghi theo dòng:


using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
    writer.WriteLine($"Đây là dòng số {i}");

Kiến trúc đọc theo luồng


   [Tệp trên đĩa]
          |
     [FileStream]
          |
  [BufferedStream (tùy chọn)]
          |
   [StreamReader/StreamWriter (cho text)]
          |
   [Code của bạn: xử lý dữ liệu]

6. Áp dụng thực tế

Tiếp tục phát triển ứng dụng làm việc với log. Giả sử bây giờ nén các log cũ (cũ hơn 7 ngày) không chỉ di chuyển mà compress chúng vào một archive. Nếu tệp rất lớn — ta sẽ đọc/ghi theo phần để không "đè" RAM.

Để đơn giản dùng copy chuẩn bằng stream:


void CopyLargeFile(string sourcePath, string destPath)
{
    using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
    using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write);
    byte[] buffer = new byte[1024 * 256]; // 256 KB
    int bytesRead;
    while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0)
    {
        destStream.Write(buffer, 0, bytesRead);
        // Có thể thêm progress-bar ở đây!
    }
}

Dùng trong đâu?

  • Sao chép backup
  • Gộp logs theo ngày hoặc tháng
  • Tiền xử lý tệp (ví dụ lọc dòng)

7. Đánh giá hiệu quả của buffer

Đôi khi muốn biết: "Nhanh hơn bao nhiêu?" Dễ kiểm tra bằng đo thời gian thực hiện:


var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"Thời gian sao chép: {watch.Elapsed.TotalSeconds} giây");

Giá trị buffer tiêu biểu:

  • 4 KB — block tối thiểu của filesystem.
  • 64 KB/128 KB — thường hoạt động tốt trong hầu hết trường hợp.
  • 1 MB trở lên — hợp lý chỉ với SSD rất nhanh và tệp rất lớn.

Thử với nhiều kích thước buffer khác nhau!

8. Tìm kiếm và xử lý dữ liệu trong tệp lớn

Nếu cần không chỉ sao chép mà tìm một dòng, số, cụm từ? Với tệp lớn hiệu quả nhất là làm theo phần.

Ví dụ: tìm tất cả các dòng lỗi trong log khổng lồ


using var reader = new StreamReader("server.log");
using var writer = new StreamWriter("errors.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    if (line.Contains("ERROR"))
        writer.WriteLine(line); // Chỉ ghi những dòng cần thiết vào kết quả
}

Cách này cho phép xử lý log hàng gigabyte trở lên mà không tốn nhiều bộ nhớ.

9. Đặc điểm khi làm việc với tệp rất lớn (>2 GB)

.NET và Windows làm việc tốt với tệp kích thước lớn (thậm chí chục terabyte) nếu bạn dùng đọc theo luồng. Nhưng có vài lưu ý!

  • Ứng dụng 32-bit bị giới hạn 2 GB địa chỉ bộ nhớ — hãy dùng x64!
  • Luôn dùng nền tảng 64-bit cho tệp lớn (AnyCPU hoặc x64).
  • Với tệp lớn hơn 4 GB hệ file FAT32 không phù hợp — cần NTFS/exFAT.

Xử lý lặp với tệp lớn


+------------------+
|    Start         |
+------------------+
        |
        v
+------------------------------+
| Mở stream để đọc            |
+------------------------------+
        |
        v
+------------------------------+
| Trong khi chưa đến cuối tệp  |
+------------------------------+
        |
        v
+---------------------------+
| Đọc một khối dữ liệu      |
+---------------------------+
        |
        v
+---------------------------+
| Xử lý khối                |
+---------------------------+
        |
        v
+--------------------------+
| Khối tiếp theo            |
+--------------------------+
        |
        v
+--------------------+
| Đóng stream        |
+--------------------+

10. Các chi tiết hữu ích

Tệp như một stream: các phương thức quan trọng của FileStream

Phương thức Mô tả
Read
Đọc phần của tệp vào buffer
Write
Ghi phần buffer vào tệp
Seek
Cho phép nhảy tới vị trí cần trong tệp
Length
Kích thước tệp tính bằng byte
Position
Vị trí hiện tại trong tệp

Ví dụ dùng Seek():


using var stream = new FileStream("bigfile.bin", FileMode.Open);
// Nhảy tới trước 1 GB!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);

byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// Bây giờ đọc dữ liệu từ giữa tệp!

Ứng dụng:

  • Lập chỉ mục tệp
  • Truy cập nhanh tới khối cần thiết (ví dụ trong các CSDL lớn)

Xử lý tệp bằng nhiều luồng (multi-threading)

Nếu bài toán cho phép, tệp lớn có thể xử lý song song: chia thành block và đọc/ghi các phần khác nhau đồng thời. Nhưng cần biết rằng ổ cứng cơ (HDD) xử lý ngẫu nhiên chậm, trong khi SSD nhanh hơn nhiều.

Trong các trường hợp đơn giản, nếu không chắc, hãy làm tuần tự. Song song hữu ích khi xử lý nhiều tệp cùng lúc, chứ không phải một tệp (nếu không lợi ích không rõ ràng).

11. Các lỗi thường gặp khi làm việc với tệp lớn

Lỗi #1: đọc cả tệp vào bộ nhớ.
Người mới thường dùng File.ReadAllBytes() hoặc File.ReadAllText() cho tệp rất lớn. Nếu tệp vài gigabyte, ứng dụng sẽ crash — RAM không đủ. Dùng đọc theo luồng.

Lỗi #2: dùng buffer quá nhỏ.
Đọc với buffer tí hon — giống như uống canh bằng thìa. Chương trình sẽ tốn nhiều thời gian truy cập đĩa, thay vì làm việc — chỉ chờ. Chọn kích thước buffer hợp lý.

Lỗi #3: quên đóng stream.
Nếu không đóng stream sau khi xong, file descriptor sẽ bị chiếm. Điều này cản trở chương trình khác và có thể gây lỗi ở cấp OS. Luôn dùng using — an toàn và gọn.

Lỗi #4: truy cập đồng thời cùng một tệp.
Cố đọc và ghi cùng một tệp từ nhiều chỗ trong chương trình — con đường dẫn tới IOException. Dù đôi khi "chạy", nhưng bạn sẽ không có tính ổn định.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Sao chép tệp lớn
Sao chép tệp lớn
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Nguyên lý buffer hóa dữ liệu
Tối ưu hóa nhập-xuất
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION