JavaRush /Курси /C# SELF /Оптимізація роботи з великими файлами

Оптимізація роботи з великими файлами

C# SELF
Рівень 41 , Лекція 4
Відкрита

1. Вступ

Коли працюєте з невеликими файлами, зазвичай нічого не помічаєте: записали, прочитали — і забули. Але щойно розмір файлу починає перевищувати хоча б 100–500 МБ, а тим паче гігабайти, виникають цікаві ефекти:

  • Операції стають повільнішими, особливо, якщо діяти «в лоб»: наприклад, File.ReadAllBytes() або File.WriteAllText() можуть раптом почати гальмувати всю програму.
  • Оперативної памʼяті може не вистачити, зʼявляється OutOfMemoryException.
  • Система починає використовувати файл підкачки (swap), що може призвести до уповільнення всієї системи.
  • Паралельні операції можуть створювати зайве навантаження на диск.

У реальних завданнях це трапляється часто:

  • Логи серверів (гігабайти за добу).
  • Обробка великих CSV або XML-файлів з експортом та імпортом.
  • Робота з відео, аудіо, архівами, бінарними файлами.
  • Копіювання великих збірок або резервне копіювання.

2. Стратегії оптимізації при роботі з великими файлами

Перш ніж кидатися до оптимізації, визначмося, що саме ми хочемо прискорити або покращити. Ось найчастіші завдання:

  • «Прочитати/записати файл максимально швидко і не “вбити” систему».
  • «Обробляти файл шматками, щоб не вантажити памʼять».
  • «Не створювати зайвих копій даних у памʼяті».
  • «Паралельно обробляти великі файли (за можливості)».

Загальні підходи:

  • Потокове читання та запис: використовуємо потоки та буфери, читаємо/пишемо частинами (FileStream, BufferedStream).
  • Операції з файлами напряму на диску — без проміжних копій у памʼяті.
  • Обачно керуємо буферами і памʼяттю: не тримаємо в оперативці весь файл.
  • Асинхронні операції — якщо потрібно не блокувати головний потік (детальніше в наступній лекції).

3. Потокове читання і запис: базовий патерн

Головний принцип: Працюйте з файлами частинами! У сучасному C# це легко робиться за допомогою класів FileStream, BufferedStream та загалом під час будь-якої роботи з потоками.


// Відкриваємо потік на читання:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1 МБ
int bytesRead;

// Читаємо, доки не досягнемо кінця файлу
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
    // Тут обробляємо зчитані дані!
    // Наприклад, порахуємо суму всіх байтів (просто для тренування)
    long sum = 0;
    for (int i = 0; i < bytesRead; i++)
        sum += buffer[i];

    Console.WriteLine($"Зчитано {bytesRead} байт, сума: {sum}");
}

Порада: Розмір буфера (наприклад, 64 КБ, 128 КБ, 1 МБ) підбирайте дослідним шляхом. Надто маленький — буде багато звернень до диска. Надто великий — не завжди дає кращий результат, але споживає памʼять.

Чому так? Класичне читання файлу за допомогою File.ReadAllBytes() або File.ReadAllText() без поділу на частини намагається завантажити весь вміст у памʼять. Якщо файл величезний — результат очевидний: OutOfMemoryException вам гарантований.

4. BufferedStream: навіщо і коли потрібен

Ми вже знайомилися з цим класом у минулій лекції, але нагадаємо: BufferedStream — це обгортка над будь-яким іншим потоком, яка дозволяє читати/писати не по одному байту, а блоками.

Приклад використання:


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)
{
    // Обробка даних
}

У деяких випадках використання BufferedStream дає відчутне прискорення, особливо якщо ви читаєте файл по кілька байтів за раз, а файлова система працює ефективніше із блоковими зверненнями.

Цікавий факт: У класі FileStream у нових версіях .NET уже є вбудована буферизація, тому ручне використання BufferedStream дає найбільший виграш під час роботи з «сирими» потоками, наприклад, мережевими або нестандартними пристроями.

5. Читання і запис великих текстових файлів

З бінарними файлами наче все ясно: читаємо та пишемо блоками. Але що робити з текстовими файлами, особливо якщо це великі CSV, логи, JSON-файли?

Тут виручає клас StreamReader для читання і StreamWriter для запису.

Построкове читання:


using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    // Обробка рядка
    if (line.Contains("ERROR"))
        Console.WriteLine("Виявлено ERROR: " + line);
}

Чому це добре?

  • Ми не тримаємо весь файл у памʼяті.
  • При цьому буферизація всередині StreamReader уже налаштована оптимально.

Построковий запис:


using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
    writer.WriteLine($"Це рядок номер {i}");

Архітектура потокового читання


   [Файл на диску]
          |
     [FileStream]
          |
  [BufferedStream (необовʼязково)]
          |
   [StreamReader/StreamWriter (для тексту)]
          |
   [Ваш код: обробка даних]

6. Застосовуємо на практиці

Продовжімо розвивати наш застосунок для роботи з лог-файлами. Нехай тепер архівування старих логів (старших за 7 днів) виконується не просто переміщенням файлів, а їхнім стисканням в один архів. Якщо файл величезний — то читатимемо та записуватимемо його частинами, щоб не «завалити» памʼять.

Для спрощення використаємо стандартне копіювання за допомогою потоків:


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 КБ
    int bytesRead;
    while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0)
    {
        destStream.Write(buffer, 0, bytesRead);
        // Тут можна додати індикатор виконання!
    }
}

Де це використовується?

  • Копіювання резервних копій
  • Злиття логів за днями або місяцями
  • Попередня обробка файлів (наприклад, фільтрація рядків)

7. Як оцінити ефективність буфера?

Іноді хочеться знати: «А наскільки швидше стало?» Це легко перевірити, вимірявши час виконання операцій:


var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"Час копіювання: {watch.Elapsed.TotalSeconds} секунд");

Типові значення буфера:

  • 4 КБ — мінімальний блок файлової системи.
  • 64 КБ/128 КБ — на практиці працює добре майже завжди.
  • 1 МБ і більше — виправдано лише на дуже швидких SSD та великих файлах.

Спробуйте різні розміри буфера!

8. Пошук і обробка даних у великих файлах

А що, якщо потрібно не просто копіювати, а шукати певний рядок, число, словосполучення? У великих файлах це найефективніше робити порційно.

Приклад: знайти всі рядки з помилками у величезному логу


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); // Лише потрібні рядки записуємо до результату
}

Цей підхід дозволяє обробляти логи обсягом у гігабайт і більше, не витрачаючи купу памʼяті.

9. Особливості роботи з величезними файлами (>2 ГБ)

.NET і Windows чудово працюють із файлами будь-якого розміру (навіть у десятки терабайт), якщо ви використовуєте потокове читання. Але бувають нюанси!

  • 32‑бітні застосунки обмежені 2 ГБ адресації памʼяті — використовуйте x64!
  • Для великих файлів завжди використовуйте 64‑бітну платформу (AnyCPU або x64).
  • Для файлів понад 4 ГБ файлова система FAT32 не підійде — потрібна NTFS/exFAT.

Ітеративна обробка великих файлів


+------------------+
|    Start         |
+------------------+
        |
        v
+------------------------------+
| Відкрити потік для читання   |
+------------------------------+
        |
        v
+------------------------------+
| Поки не досягнуто кінця файлу|
+------------------------------+
        |
        v
+---------------------------+
| Прочитати блок даних     |
+---------------------------+
        |
        v
+---------------------------+
| Обробити блок            |
+---------------------------+
        |
        v
+--------------------------+
| Наступний блок           |
+--------------------------+
        |
        v
+--------------------+
| Закрити потік      |
+--------------------+

10. Корисні нюанси

Файл як потік: ключові методи класу FileStream

Метод Опис
Read
Зчитує частину файлу в буфер
Write
Записує частину буфера у файл
Seek
Дозволяє переміститися до потрібної позиції у файлі
Length
Розмір файлу в байтах
Position
Поточна позиція у файлі

Приклад використання Seek():


using var stream = new FileStream("bigfile.bin", FileMode.Open);
// Перемістимося на 1 ГБ уперед!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);

byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// Тепер читаємо дані з середини файлу!

Де стане в пригоді?

  • Індексація файлів
  • Швидкий доступ до потрібних блоків (наприклад, у великих базах даних)

Робота з файлами у кількох потоках (multi-threading)

Якщо завдання дозволяє, великі файли можна обробляти паралельно: наприклад, розбити його на блоки та читати/писати різні частини одночасно. Але важливо розуміти, що жорсткі диски (HDD) повільні за випадкового доступу, а SSD — значно швидші.

У простих повсякденних завданнях, якщо не впевнені, працюйте послідовно. Паралелізм стане в пригоді для обробки різних файлів одразу, а не одного файла (інакше виграш сумнівний).

11. Типові помилки при роботі з великими файлами

Помилка № 1: читають увесь файл у памʼять.
Новачки часто використовують File.ReadAllBytes() або File.ReadAllText() навіть для величезних файлів. Якщо файл важить гігабайти, застосунок просто завершиться з помилкою — памʼяті не вистачить. Використовуйте потокове читання.

Помилка № 2: використовують надто маленький буфер.
Читати з крихітним буфером — це як їсти суп чайною ложкою. Програма витратить купу часу на звернення до диска і замість роботи — просто чекатиме. Підбирайте розумний розмір буфера.

Помилка № 3: забувають закрити потік.
Якщо не закрити потік після роботи, файловий дескриптор залишиться зайнятий. Це заважає іншим програмам і може спричинити помилки на рівні ОС. Завжди застосовуйте using — так безпечніше і чистіше.

Помилка № 4: одночасний доступ до одного файлу.
Намагатися читати і писати один і той самий файл із різних частин програми — прямий шлях до IOException. Навіть якщо це іноді «працює», стабільності ви не досягнете.

1
Опитування
Принцип буферизації даних, рівень 41, лекція 4
Недоступний
Принцип буферизації даних
Оптимізація вводу-виводу
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ