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
| Метод | Опис |
|---|---|
|
Зчитує частину файлу в буфер |
|
Записує частину буфера у файл |
|
Дозволяє переміститися до потрібної позиції у файлі |
|
Розмір файлу в байтах |
|
Поточна позиція у файлі |
Приклад використання 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. Навіть якщо це іноді «працює», стабільності ви не досягнете.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ