CodeGym /Kursy /C# SELF /Optymalizacja pracy z dużymi plikami

Optymalizacja pracy z dużymi plikami

C# SELF
Poziom 41, Lekcja 4
Dostępny

1. Wprowadzenie

Kiedy pracujesz z małymi plikami, często nic nie zauważasz: zapisałeś, przeczytałeś — i zapomniałeś. Ale kiedy rozmiar pliku zaczyna przekraczać choćby 100–500 MB, a tym bardziej gigabajty, pojawiają się interesujące efekty:

  • Operacje stają się wolniejsze, szczególnie jeśli pracować z nimi „na pałę” — np. File.ReadAllBytes() lub File.WriteAllText() nagle zaczynają spowalniać cały program.
  • Może zabraknąć pamięci RAM, pojawi się OutOfMemoryException.
  • System zaczyna używać swapu (pamięci wymiany), co może prowadzić do spowolnienia całego systemu.
  • Operacje równoległe mogą generować nadmierne obciążenie dysku.

W rzeczywistych zadaniach to występuje często:

  • Logi serwerów (gigabajty na dobę).
  • Przetwarzanie dużych plików CSV lub XML przy eksporcie-importcie.
  • Praca z wideo, audio, archiwami, plikami binarnymi.
  • Kopiowanie dużych assembly lub backupy.

2. Strategie optymalizacji przy pracy z dużymi plikami

Zanim rzucisz się optymalizować — ustalmy, co dokładnie chcemy przyspieszyć lub poprawić. Oto najczęstsze zadania:

  • „Przeczytać/zapisać plik jak najszybciej i nie zabić systemu”.
  • „Przetwarzać plik kawałkami, żeby nie obciążać pamięci”.
  • „Nie tworzyć zbędnych kopii danych w pamięci”.
  • „Równolegle przetwarzać duże pliki (jeśli to możliwe)”.

Ogólne podejścia:

  • Strumieniowe czytanie i zapis: używamy streamów i buforów, czytamy/zapisujemy partiami (FileStream, BufferedStream).
  • Operacje z plikami bezpośrednio na dysku — bez pośrednich kopii w pamięci.
  • Ostrożne zarządzanie buforami i pamięcią: nie przechowujemy w RAM całego pliku.
  • Operacje asynchroniczne — jeśli trzeba nie blokować wątku głównego (więcej w następnym wykładzie).

3. Strumieniowe czytanie i zapis: podstawowy wzorzec

Główny zasada: Pracuj z plikami kawałkami! We współczesnym C# łatwo to robić za pomocą klas FileStream, BufferedStream, i ogólnie pracy przez streamy.


// Otwieramy strumień do czytania:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1 MB
int bytesRead;

// Czytamy, dopóki nie osiągniemy końca pliku
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
    // Tutaj przetwarzamy odczytane dane!
    // Na przykład policzymy sumę wszystkich bajtów (czysto jako ćwiczenie)
    long sum = 0;
    for (int i = 0; i < bytesRead; i++)
        sum += buffer[i];

    Console.WriteLine($"Odczytano {bytesRead} bajtów, suma: {sum}");
}

Wskazówka: Rozmiar bufora (np. 64 KB, 128 KB, 1 MB) dobieraj doświadczalnie. Zbyt mały — będzie dużo operacji dyskowych. Zbyt duży — nie zawsze poprawi wynik, ale zużyje pamięć.

Dlaczego tak? Klasyczne czytanie pliku za pomocą File.ReadAllBytes() lub File.ReadAllText() bez podziału na części próbuje załadować całą zawartość do pamięci. Jeśli plik jest ogromny — efekt jest oczywisty: OutOfMemoryException murowany.

4. BufferedStream: po co i kiedy

Już znaliśmy tę klasę w poprzednim wykładzie, ale warto przypomnieć: BufferedStream — to wrapper nad dowolnym innym streamem, który pozwala czytać/zapisować nie pojedynczo bajt po bajcie, lecz blokami.

Przykład użycia:


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)
{
    // Przetwarzanie danych
}

W niektórych przypadkach użycie BufferedStream przyspiesza, szczególnie jeśli czytasz plik po kilka bajtów naraz, a system plików preferuje dostęp blokowy.

Ciekawostka: W klasie FileStream w nowych wersjach .NET jest już wbudowana buforyzacja, więc ręczne użycie BufferedStream daje największy zysk przy pracy z "surowymi" streamami, np. sieciowymi lub niestandardowymi urządzeniami.

5. Czytanie i zapis dużych plików tekstowych

Z plikami binarnymi jest jasne: czytamy i zapisujemy partiami. Ale co z tekstowymi plikami, zwłaszcza dużymi CSV, logami, JSON-ami?

Z pomocą przychodzi klasa StreamReader do czytania i StreamWriter do zapisu.

Czytanie liniami:


using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    // Przetwarzanie linii
    if (line.Contains("ERROR"))
        Console.WriteLine("Wykryto ERROR: " + line);
}

Dlaczego to dobre?

  • Nie trzymamy całego pliku w pamięci.
  • Buforowanie wewnątrz StreamReader jest już ustawione optymalnie.

Zapis po liniach:


using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
    writer.WriteLine($"To jest linia numer {i}");

Architektura strumieniowego czytania


   [Plik na dysku]
          |
     [FileStream]
          |
  [BufferedStream (opcjonalnie)]
          |
   [StreamReader/StreamWriter (dla tekstu)]
          |
   [Twój kod: przetwarzanie danych]

6. Zastosowanie w praktyce

Kontynuujemy rozwój naszej aplikacji do pracy z logami. Załóżmy, że archiwizacja starych logów (starszych niż 7 dni) wykonywana jest nie przez proste przenoszenie plików, lecz przez spakowanie ich do jednego archiwum. Jeśli plik jest ogromny — będziemy czytać i zapisywać go partiami, żeby nie "zalać" pamięci.

Dla uproszczenia użyjemy standardowego kopiowania przez streamy:


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);
        // Możemy tu dodać pasek postępu!
    }
}

Gdzie to się przydaje?

  • Kopiowanie kopii zapasowych
  • Scalanie logów według dni lub miesięcy
  • Wstępne przetwarzanie plików (np. filtrowanie linii)

7. Jak ocenić efektywność bufora?

Czasem chcesz wiedzieć: „O ile szybciej to działa?” To łatwo zmierzyć przez czas wykonania operacji:


var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"Czas kopiowania: {watch.Elapsed.TotalSeconds} sekund");

Typowe wartości bufora:

  • 4 KB — minimalny blok systemu plików.
  • 64 KB/128 KB — w praktyce dobrze działa prawie zawsze.
  • 1 MB i więcej — uzasadnione tylko na bardzo szybkich SSD i przy bardzo dużych plikach.

Przetestuj sam różne rozmiary bufora!

8. Wyszukiwanie i przetwarzanie danych w dużych plikach

Co jeśli trzeba nie tylko kopiować, lecz wyszukać konkretną linię, liczbę, frazę? W dużych plikach najefektywniej robi się to partiami.

Przykład: znaleźć wszystkie linie z błędami w ogromnym logu


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); // Tylko potrzebne linie wysyłamy do wyniku
}

To podejście pozwala przetwarzać logi po gigabajty i więcej, nie zużywając mnóstwa pamięci.

9. Szczegóły pracy z ogromnymi plikami (>2 GB)

.NET i Windows dobrze radzą sobie z plikami dowolnego rozmiaru (nawet dziesiątki terabajtów), jeśli używasz strumieniowego czytania. Ale są niuanse!

  • Aplikacje 32-bitowe są ograniczone do 2 GB adresowania pamięci — używaj x64!
  • Zawsze używaj 64-bitowej platformy dla dużych plików (AnyCPU lub x64).
  • Dla plików powyżej 4 GB system plików FAT32 nie będzie odpowiedni — potrzebny NTFS/exFAT.

Iteracyjne przetwarzanie dużych plików


+------------------+
|    Start         |
+------------------+
        |
        v
+------------------------------+
| Otworzyć strumień do czytania|
+------------------------------+
        |
        v
+------------------------------+
| Dopóki nie koniec pliku      |
+------------------------------+
        |
        v
+---------------------------+
| Odczytać blok danych      |
+---------------------------+
        |
        v
+---------------------------+
| Przetworzyć blok          |
+---------------------------+
        |
        v
+--------------------------+
| Następny blok            |
+--------------------------+
        |
        v
+--------------------+
| Zamknąć strumień   |
+--------------------+

10. Przydatne niuanse

Plik jako strumień: kluczowe metody klasy FileStream

Metoda Opis
Read
Odczytuje część pliku do bufora
Write
Zapisuje część bufora do pliku
Seek
Pozwala przeskoczyć do żądanej pozycji w pliku
Length
Rozmiar pliku w bajtach
Position
Aktualna pozycja w pliku

Przykład użycia Seek():


using var stream = new FileStream("bigfile.bin", FileMode.Open);
// Przeskoczmy o 1 GB do przodu!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);

byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// Teraz czytamy dane ze środka pliku!

Gdzie się przyda?

  • Indeksowanie plików
  • Szybki dostęp do potrzebnych bloków (np. w dużych bazach danych)

Praca z plikami w wielu wątkach (multithreading)

Jeśli zadanie na to pozwala, duże pliki można przetwarzać równolegle: np. podzielić je na bloki i czytać/zapiswać różne części jednocześnie. Ale trzeba rozumieć, że talerze w HDD działają wolno przy losowym dostępie, a SSD — dużo szybciej.

W prostych, domowych zadaniach, jeśli nie jesteś pewien, pracuj sekwencyjnie. Równoległość przyda się do przetwarzania różnych plików jednocześnie, a niekoniecznie jednego pliku (w przeciwnym razie zysk jest wątpliwy).

11. Typowe błędy przy pracy z dużymi plikami

Błąd nr 1: czytają cały plik do pamięci.
Początkujący często używają File.ReadAllBytes() lub File.ReadAllText() nawet dla ogromnych plików. Jeśli plik waży gigabajty, aplikacja po prostu się zakończy z błędem — zabraknie pamięci. Używaj strumieniowego czytania.

Błąd nr 2: używają zbyt małego bufora.
Czytanie z mikroskopijnym buforem — to jak pić zupę łyżeczką do herbaty. Program będzie tracił masę czasu na operacje dyskowe, zamiast robić robotę. Dobierz rozsądny rozmiar bufora.

Błąd nr 3: zapominają zamknąć strumień.
Jeśli nie zamknąć strumienia po pracy, deskryptor pliku pozostanie zajęty. To przeszkadza innym programom i może wywołać błędy na poziomie OS. Zawsze stosuj using — bezpieczniej i czyściej.

Błąd nr 4: jednoczesny dostęp do jednego pliku.
Próba czytania i pisania tego samego pliku z różnych części programu — prosta droga do IOException. Nawet jeśli czasem "działa", nie uzyskasz stabilności.

2
Zadanie
C# SELF, poziom 41, lekcja 4
Niedostępne
Kopiowanie dużego pliku
Kopiowanie dużego pliku
1
Ankieta/quiz
Zasada buforowania danych, poziom 41, lekcja 4
Niedostępny
Zasada buforowania danych
Optymalizacja wejścia-wyjścia
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION