IEnumerable i yield return w C# – jak przetworzyć milion rekordów bez crash’u serwera

IEnumerable i yield return w C#

Myślisz, że List<T> to domyślny wybór do przechowywania i zwracania danych w C#? Jeśli Twoja aplikacja operuje na małych zbiorach, pewnie masz rację. Ale co jeśli musisz przetworzyć plik CSV o rozmiarze 5 GB albo pobrać z bazy danych milion rekordów? Jeden naiwny błąd wystarczy, żeby serwer produkcyjny dostał OutOfMemoryException i padł w środku nocy.

W tym artykule zobaczysz, jak IEnumerable<T> i słowo kluczowe yield return pozwalają przetworzyć gigabajty danych, trzymając w pamięci RAM zaledwie jeden rekord na raz. Zrozumiesz, jak kompilator C# buduje pod spodem maszynę stanów, poznasz pułapkę Multiple Enumeration i dowiesz się, kiedy leniwe przetwarzanie może zrujnować Twoją wydajność I/O. Gotowy? Zaczynamy.

Dlaczego List<T> może zabić Twój serwer

Antywzorzec – ładowanie wszystkiego do pamięci

Klasyczny błąd, który regularnie pojawia się na code review w projektach komercyjnych:

// ANTYWZORZEC – katastrofa pamięciowa dla dużych zbiorów danych
// File.ReadAllLines() ładuje WSZYSTKIE linie z dysku do tablicy stringów naraz
public List<Transaction> GetAllTransactionsNaive(string filePath)
{
    var transactions = new List<Transaction>();

    foreach (var line in File.ReadAllLines(filePath)) // cały plik w RAM
    {
        transactions.Add(TransactionParser.Parse(line));
    }

    return transactions; // serwer właśnie dostał BOM
}

Co tu się dzieje za kulisami?

Metoda File.ReadAllLines() wczytuje cały plik do pamięci jako tablicę stringów. Następnie każdy string jest parsowany do obiektu Transaction i dodawany do List<Transaction>. Dla pliku 5 GB RAM-u zajętego będzie więcej niż 5 GB, bo do każdego stringa dochodzi jeszcze narzut obiektu .NET.

Large Object Heap – cichy zabójca wydajności

W .NET obowiązuje twarda reguła: obiekty o rozmiarze ≥ 85 000 bajtów trafiają nie na normalną stertę, lecz na Large Object Heap (LOH). To ma dwie poważne konsekwencje:

  • LOH nie jest kompaktowany podczas standardowych GC – fragmentacja pamięci rośnie z każdą alokacją.
  • Full GC (Gen 2) wymagany do zebrania LOH potrafi zawiesić aplikację na sekundy (tzw. GC pauses).

Wystarczy kilka żądań do serwisu, które każde ładuje duży zbiór danych, i aplikacja zaczyna odpowiadać z opóźnieniami – zanim w ogóle rzuci OutOfMemoryException.

Złożoność pamięciowa tego podejścia: O(N) – im więcej danych, tym więcej RAM-u.


H2: IEnumerable i yield return – leniwe przetwarzanie strumieniowe

H3: Rozwiązanie – jeden rekord na raz

Zamiast pakować wszystko do listy, zmieniamy sygnaturę metody i używamy yield return:

// BEST PRACTICE – odroczone wykonanie (Deferred Execution), złożoność pamięciowa O(1)
// StreamReader czyta plik partiami (buforuje), a nie w całości
public IEnumerable<Transaction> GetTransactionsLazily(string filePath)
{
    using var stream = new StreamReader(filePath);
    string? line;

    while ((line = stream.ReadLine()) != null)
    {
        // yield return "zamraża" metodę i zwraca jeden element do pętli wywołującej.
        // Metoda wznawia działanie dopiero przy kolejnym MoveNext().
        yield return TransactionParser.Parse(line);
    }
    // using gwarantuje zamknięcie pliku po zakończeniu lub przerwaniu iteracji
}

Metafora:
To jak czytanie książki, nie musisz przeczytać całości naraz, żeby znać jej treść. Czytasz linijkę po linijce, a reszta książki spokojnie czeka.

Złożoność pamięciowa: O(1) – niezależnie od rozmiaru pliku, w RAM-ie masz zawsze jeden obiekt.

Jak kompilator C# buduje maszynę stanów

Możesz się zastanawiać: Zwracam Transaction, a sygnatura mówi IEnumerable<Transaction>. Jak to się kompiluje bez tworzenia kolekcji?

To właśnie jest magia yield return. Kiedy kompilator C# napotka to słowo kluczowe, nie tworzy żadnej kolekcji w pamięci. Zamiast tego generuje w tle prywatną klasę – maszynę stanów (State Machine), która implementuje interfejs IEnumerator<Transaction>.

Cykl życia maszyny stanów krok po kroku

Klient (foreach)          Maszyna stanów (wygenerowana przez kompilator)
      │                               │
      │──── GetEnumerator() ────────▶│
      │                               │
      │──── MoveNext() ─────────────▶│  ← wchodzi do GetTransactionsLazily,
      │                               │    otwiera plik, czyta pierwszą linię,
      │                               │    parsuje → zapisuje stan
      │◀─── Current = Transaction ── │  ← "zamraża" metodę przy yield return
      │                               │
      │  [przetwarza obiekt]          │
      │                               │
      │──── MoveNext() ─────────────▶│  ← "odmraża" metodę DOKŁADNIE
      │                               │    po yield return, czyta kolejną linię
      │◀─── Current = Transaction ───│
      │          ...                  │
      │──── MoveNext() ─────────────▶│  ← EOF, pętla while kończy się
      │◀─── false ───────────────────│  ← iterator skończony

Kluczowa obserwacja:
Jako programista piszesz prosty, sekwencyjny kod z pętlą while. Kompilator bierze na siebie całą złożoność zarządzania stanem. Nie musisz ręcznie implementować IEnumerator<T>, pilnować liczników ani zarządzać zasobami.

Co z otwartym plikiem – czy grozi wyciek zasobów?

Nie. Blok using współpracuje z yield return w pełni bezpiecznie. Gdy iteracja zakończy się naturalnie (koniec danych) lub zostanie przerwana (break w foreach), kompilator generuje kod, który wywoła Dispose() na StreamReader. Plik zostanie zamknięty w każdym scenariuszu.

H2: Klasy pomocnicze – separacja odpowiedzialności

Przed testem wydajnościowym warto zobaczyć strukturę kodu. W realnych projektach zawsze separujemy logikę odczytu danych od logiki transformacji:

// Rekord reprezentujący jeden wiersz CSV
public record Transaction(string Id, DateTime Date, decimal Amount, string Status);
// Separacja logiki parsowania – łatwiejsze testowanie jednostkowe
namespace EnumerableDemo;

public static class TransactionParser
{
    public static Transaction Parse(string csvLine)
    {
        var parts = csvLine.Split(',');
        return new Transaction(
            Id: parts[0],
            Date: DateTime.Parse(parts[1]),
            Amount: decimal.Parse(parts[2]),
            Status: parts[3]
        );
    }
}
// Helper do generowania danych na potrzeby dema
// (1 milion to ok. 80 MB czystego tekstu) 
// (1o milion to ok. 800 MB czystego tekstu) 
private static void GenerateDummyCsv(string path, int rows)
{
   if (File.Exists(path)) return;

   using var writer = new StreamWriter(path);
   var random = new Random(42);
   var statuses = new[] { "Success", "Failed", "Pending" };

   for (int i = 0; i < rows; i++)
   {
      writer.WriteLine($"{Guid.NewGuid()},{DateTime.UtcNow.AddMinutes(-i):O},{random.Next(10, 10000) + random.NextDouble():F2},{statuses[random.Next(statuses.Length)]}");
   }
}

Dlaczego tak?

Klasa TransactionParser z jedną odpowiedzialnością (Single Responsibility Principle) jest łatwa do testowania i wymiany. Generator danych pozwala odtworzyć środowisko produkcyjne bez zewnętrznych plików.

Starcie dwóch podejść – pomiar pamięci w praktyce

Kod testowy w Program.cs

using System.Diagnostics;

public class Program
{
    private const string FilePath = "transactions_large.csv";

    public static void Main()
    {
        Console.WriteLine("Generowanie pliku testowego (ok. 1 mln wierszy)...");
        GenerateDummyCsv(FilePath, 10_000_000);

        var repo = new TransactionRepository();

        #region SCENARIUSZ 1: Katastrofa pamięciowa (Antywzorzec) 
        Console.WriteLine("\n--- SCENARIUSZ 1: Naiwne ładowanie do Listy ---");

        var memoryBeforeNaive = GC.GetTotalMemory(true);
        var naiveList = repo.GetAllTransactionsNaive(FilePath);
        var memoryAfterNaive = GC.GetTotalMemory(true);

        Console.WriteLine($"Zużycie pamięci RAM: {(memoryAfterNaive - memoryBeforeNaive) / 1024 / 1024} MB");
        #endregion

        #region  SCENARIUSZ 2: Leniwe przetwarzanie (Best Practice)
        Console.WriteLine("\n--- SCENARIUSZ 2: IEnumerable z yield return ---");
        
        var memoryBeforeLazy = GC.GetTotalMemory(true);
        var lazyTransactions = repo.GetTransactionsLazily(FilePath); // UWAGA: Tu nic się jeszcze nie wykonuje!
        decimal totalAmount = 0;
        int failedCount = 0;

        foreach (var tx in lazyTransactions)
        {
            totalAmount += tx.Amount;
            if (tx.Status == "Failed")
                failedCount++;
        }
        var memoryAfterLazy = GC.GetTotalMemory(true);

        Console.WriteLine($"Suma transakcji: {totalAmount}, Błędnych: {failedCount}");
        Console.WriteLine($"Zużycie pamięci RAM (Netto): {(memoryAfterLazy - memoryBeforeLazy) / 1024 / 1024} MB");
        #endregion

        #region SCENARIUSZ 3: Pułapka Multiple Enumeration
        Console.WriteLine("\n--- SCENARIUSZ 3: Pułapka Multiple Enumeration ---");
        var stopwatch = Stopwatch.StartNew();

        // Pierwszy odczyt z dysku (cały plik)
        var count = lazyTransactions.Count();
        Console.WriteLine($"Czas Count(): {stopwatch.ElapsedMilliseconds} ms");

        stopwatch.Restart();
        // DRUGI odczyt z dysku (plik otwierany ponownie!)
        var anyFailed = lazyTransactions.Any(t => t.Status == "Failed");
        Console.WriteLine($"Czas Any(): {stopwatch.ElapsedMilliseconds} ms");
        #endregion
    }

    #region Helper do generowania danych na potrzeby dema
    // (1 milion to ok. 80 MB czystego tekstu) 
    // (1o milion to ok. 800 MB czystego tekstu) 
    private static void GenerateDummyCsv(string path, int rows)
    {
        if (File.Exists(path)) return;

        using var writer = new StreamWriter(path);
        var random = new Random(42);
        var statuses = new[] { "Success", "Failed", "Pending" };

        for (int i = 0; i < rows; i++)
        {
            writer.WriteLine($"{Guid.NewGuid()},{DateTime.UtcNow.AddMinutes(-i):O},{random.Next(10, 10000) + random.NextDouble():F2},{statuses[random.Next(statuses.Length)]}");
        }
    }
    #endregion
}

Wynik w profilerze Visual Studio:

  • List<T>: gwałtowny skok pamięci, dane trafiają na LOH, GC zaczyna pracować w pocie czoła.
  • IEnumerable + yield return: niemal płaska linia. Każdy Transaction żyje przez ułamek sekundy w Gen 0 – najtańsza możliwa kategoria dla GC.

Pułapka Multiple Enumeration – obosieczny miecz

Leniwe przetwarzanie ma jeden poważny efekt uboczny, o którym wielu juniorów (i nie tylko) zapomina. IEnumerable to obietnica, nie pudełko z danymi.

IEnumerable<Transaction> lazyTransactions = GetTransactionsLazily("transactions.csv");

// PUŁAPKA: każde wywołanie LINQ ponownie czyta plik od początku!
var count = lazyTransactions.Count();   // 1. pełny odczyt pliku z dysku
var hasAny = lazyTransactions.Any();    // 2. PONOWNY odczyt pliku od początku
var sum = lazyTransactions.Sum(t => t.Amount); // 3. TRZECI odczyt pliku

Analogia:
To jak gdybyś za każdym razem, gdy chcesz sprawdzić coś w książce, zaczynał czytać ją od pierwszej strony. Dla pliku 5 GB oznacza to 3 × 5 GB operacji I/O.

Jak unikać Multiple Enumeration

Opcja 1 – Jedna pętla foreach, wszystkie statystyki naraz:

// Jedno przejście przez plik, wszystkie obliczenia w jednej pętli
decimal sum = 0;
int count = 0;

foreach (var tx in GetTransactionsLazily(filePath))
{
    sum += tx.Amount;
    count++;
}
// Wynik: jeden odczyt pliku, pełne dane

Opcja 2 – Materializacja przez .ToList() (tylko gdy dane są małe!):

// TYLKO gdy wiesz, że zbiór jest bezpieczny dla pamięci
var transactions = GetTransactionsLazily(filePath).ToList(); // materializacja

var count = transactions.Count;           // O(1) – już w pamięci
var sum = transactions.Sum(t => t.Amount); // jedno przejście po liście

⚠️ Ostrzeżenie:
.ToList() na strumieniu 5 GB danych sprawi, że wrócisz do punktu wyjścia – pełna lista w RAM-ie, LOH i GC pauses.

Checklista – kiedy używać IEnumerable z yield return, a kiedy List\<T\>

SytuacjaRekomendacja
Przetwarzanie dużych plików CSV/JSONIEnumerable + yield return
Streaming z bazy danych (duże zbiory)IEnumerable + yield return
Potrzebujesz wielokrotnie iterować po danych⚠️ List<T> (jeśli dane małe) lub jedna pętla
Znany, mały zbiór danych (< kilka MB) List<T> – prostsze i wystarczające
Przekazujesz dane do wielu konsumentów⚠️ Zmaterializuj do List<T> lub Array
API zwracające dane do klienta (pagination) IEnumerable + yield return

Porównanie złożoności pamięciowej – O(N) vs O(1)

Złożoność pamięciowa:

List<T>:

RAM ↑  │          ████████████████████████
       │     ████████████████████████████████
       │████████████████████████████████████████
       └────────────────────────────────────────▶ N (liczba rekordów)
       Więcej danych = więcej RAM

IEnumerable + yield return:

RAM ↑  │ ──────────────────────────────────────
       │ (jedna jednostka, zawsze)
       └────────────────────────────────────────▶ N (liczba rekordów)
       Więcej danych ≠ więcej RAM

List<T> O(N):
każdy nowy rekord = więcej RAM. Przy N = 1 000 000 rekordów możesz potrzebować setek megabajtów.

IEnumerable + yield return O(1):
niezależnie od N, w pamięci masz zawsze jeden rekord. Obiekty o krótkim czasie życia (jedna iteracja foreach) trafiają do Generacji 0 GC – najszybsza i najtańsza możliwa kolekcja pamięci.

Podsumowanie – IEnumerable to obietnica, nie pudełko

  • IEnumerable<T> to nie kolekcja – to instrukcja „jak” pobierać dane w przyszłości. Samo przypisanie do zmiennej nie wykonuje żadnej pracy.
  • yield return generuje maszynę stanów – kompilator C# tworzy prywatną klasę implementującą IEnumerator<T>, która pamięta stan między wywołaniami MoveNext().
  • Złożoność pamięciowa spada z O(N) do O(1) – dla dużych zbiorów danych (pliki, bazy danych) to różnica między stabilnym serwisem a OutOfMemoryException o 3:00 w nocy.
  • using + yield return = bezpieczeństwo zasobów – pliki i połączenia są zamykane poprawnie zarówno przy normalnym zakończeniu, jak i przy break w pętli.
  • ⚠️ Pułapka Multiple Enumeration – każde wywołanie LINQ na leniwym IEnumerable może ponownie wykonać całą operację I/O. Rób obliczenia w jednej pętli lub świadomie materializuj przez .ToList().

Zobacz także — powiązane artykuły

👉 Tworzenie klas i obiektów w C# — kompletny przewodnik

👉LINQ w C# — przetwarzanie kolekcji bez pętli – zobacz w kursie LINQ w C# -czytelny kod, wydajne zapytania

👉 Typy wartościowe vs referencyjne w C# — jak działa pamięć – zobacz w kursie C# Podstawy Programowania: Twój Pierwszy Krok w Świat Kodowania

Masz pytania o IEnumerable, yield return lub optymalizację pamięci w .NET?
Zostaw komentarz poniżej – chętnie odpowiem i rozwinę temat, który Cię interesuje.

📧 Chcesz więcej takich materiałów?
Zapisz się do Lista VIP i otrzymuj co tydzień praktyczne porady o .NET, C# i architekturze aplikacji – bez spamu, tylko wartościowy content.

Jeśli artykuł był pomocny – udostępnij go komuś, kto właśnie uczy się C# lub walczy z problemami pamięciowymi w .NET. Dzięki!

📺 Wolisz video? Cały tutorial z live codem i pomiarem RAM w profilerze Visual Studio znajdziesz na moim kanale YouTube.

Dodaj komentarz