CodeGym /Kurse /C# SELF /Synchrone und asynchrone Generatoren in C# (

Synchrone und asynchrone Generatoren in C# ( yield)

C# SELF
Level 62, Lektion 1
Verfügbar

1. Einführung

Stell dir vor, du musst eine riesige Logdatei mit Millionen Zeilen verarbeiten oder eine unglaublich lange Zahlenfolge generieren. Wie würdest du das ohne Generatoren machen?

Der traditionelle Ansatz sähe so aus:


// Problem: erzeugt die ganze Collection sofort im Speicher
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // Rückgabe, wenn alles fertig ist
}

// Verwendung:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 1 Million Zahlen sofort im Speicher!
foreach (var num in myNumbers) { /* Verarbeitung */ }

Welche Probleme gibt es hier?

  1. Speicherverbrauch: Wenn count sehr groß ist, wird die gesamte Collection im Speicher erstellt, was zu einer OutOfMemoryException führen kann.
  2. Latenz: Der Nutzer oder der nächste Programmschritt muss warten, bis alle Daten komplett generiert und in den Speicher geladen sind.
  3. Unendliche Sequenzen: Dieser Ansatz funktioniert nicht, wenn die Sequenz potentiell unendlich ist.

Hier kommen Generatoren ins Spiel! Sie implementieren das Konzept der faulen Berechnung (Lazy Evaluation) und des Streamings. Statt alle Daten auf einmal zu erzeugen, liefert ein Generator die Elemente eines nach dem anderen, nur wenn sie wirklich benötigt werden.

2. Grundlagen der Generatoren

In C# werden Generatoren mit dem speziellen Schlüsselwort yield erstellt.

Was ist ein Generator?

Das ist eine Methode, ein get-Block einer Property oder ein Operator, der ein oder mehrere Ausdrücke mit yield return enthält.

yield return

Das ist das Herzstück der Generatoren. Wenn der Compiler auf yield return trifft:

  1. Das Element, das nach yield return steht, wird an den aufrufenden Code übergeben.
  2. Die Ausführung der Generator-Methode wird angehalten, und ihr aktueller Zustand (wo sie sich in der Schleife befindet, Werte lokaler Variablen) wird gespeichert.
  3. Bei der nächsten Anforderung eines Elements (z. B. in der nächsten Iteration der foreach) wird die Ausführung der Methode an der Stelle fortgesetzt, an der sie angehalten wurde.

Rückgabetyp: Die Generator-Methode muss IEnumerable<T> oder IEnumerator<T> zurückgeben. Der Compiler erzeugt die benötigte „Magie“ automatisch.


// Beispiel 2.1: Einfacher Zahlengenerator
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("Beginn der Generierung...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"Erzeuge: {i}");
        yield return i; // Anhalten und Element zurückgeben
    }
    Console.WriteLine("Generierung abgeschlossen.");
}

// Verwendung:
// Beachte, dass "Beginn der Generierung..." erst bei der ersten Iteration erscheint!
// Und "Erzeuge: X" bei jeder neuen Iteration.
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"Im foreach erhalten: {num}");
}

yield break

Wird verwendet, um die Iteration vorzeitig zu beenden. Nach yield break werden keine weiteren Elemente geliefert. Wenn das Ende der Methode erreicht wird, ist ein separates yield break nicht nötig.


// Beispiel 2.2: Generator mit Abbruchbedingung
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // Verlassen des Generators
        }
        yield return item;
        count++;
    }
}

// Verwendung:
// var fruits = new List<string> { "Apple", "Banana", "Orange", "Grape" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // Gibt "Apple", "Banana" aus
// }

3. Zustandsmaschine

Wie funktioniert das „unter der Haube“? Es gibt keine Magie — nur intelligente Arbeit des Compilers.

Wenn du eine Methode mit yield schreibst, wandelt der C#-Compiler sie in eine Klasse um, die IEnumerator<T> und IEnumerable<T> implementiert. Diese vom Compiler erzeugte Klasse ist die Zustandsmaschine.

  • Zustandsspeicherung: Die Maschine speichert die Zustandsnummer (wo gestoppt wurde) und die Werte aller lokalen Variablen zum Zeitpunkt des Anhaltens.
  • Iteration: Beim Durchlaufen mit foreach werden Methoden MoveNext() aufgerufen und die Eigenschaft Current gelesen. MoveNext() setzt die Ausführung bis zum nächsten yield return/yield break fort, und Current gibt das aktuelle Element zurück.

Tatsächlich implementiert der Compiler für dich das Iterator Pattern.

4. Einsatz von Generatoren

Beispiel: Verarbeitung großer Datenmengen

Zeilenweises Lesen einer Datei, ohne die ganze Datei in den Speicher zu laden.


// Simulation des Lesens einer großen Datei
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"Öffne Datei: {filePath}");
    // In einer realen Anwendung wäre hier ein StreamReader
    yield return "Datenzeile 1";
    yield return "Datenzeile 2";
    yield return "Datenzeile 3";
    Console.WriteLine("Beende die Simulation des Dateilesens.");
}

// Verwendung:
Console.WriteLine("Beginne Verarbeitung.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"Zeile verarbeitet: {line}");
    if (line.Contains("2")) break; // Wir können abbrechen, wann wir wollen
}
Console.WriteLine("Verarbeitung abgeschlossen.");

Beachte, dass die abschließende Meldung erst nach Ende der Iteration erscheint.

Beispiel: Unendliche Sequenzen


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // Potentiell unendliche Sequenz
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// Verwendung:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // Wir müssen begrenzen, um nicht zu hängen
}

Beispiel: Verarbeitungspipelines

Erstellen von Ketten von Methoden, bei denen jeder Schritt Daten „on the fly“ verarbeitet.


IEnumerable<int> GetNumbers()
{
    yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}

IEnumerable<int> FilterEven(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        if (num % 2 == 0) yield return num;
    }
}

IEnumerable<int> Square(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        yield return num * num;
    }
}

// Verwendung:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

Das ist sehr ähnlich zu vielen LINQ-Operatoren (zum Beispiel Where, Select, Take, Skip).

5. Asynchrone Generatoren

Synchrone Generatoren sind großartig, aber was, wenn jedes Element der Sequenz eine asynchrone Operation erfordert (z. B. ein Netzwerkaufruf)? Vor C# 8.0 war das schwierig umzusetzen.

Problem: Asynchrone Datenströme

Wir können await nicht innerhalb einer synchronen Generator-Methode verwenden.


// Das wird NICHT KOMPILIEREN!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // Fehler: await ist nur in async-Methoden erlaubt
    yield return "Hello";
}

Lösung: IAsyncEnumerable<T> und await foreach

  • IAsyncEnumerable<T> — das asynchrone Pendant zu IEnumerable<T>.
  • await foreach — bequeme Syntax zum Durchlaufen einer asynchronen Sequenz (ruft intern MoveNextAsync() auf und behandelt den asynchronen Zustand).

async yield return

Jetzt kann man yield return in einer async-Methode verwenden, die IAsyncEnumerable<T> zurückgibt. Der Compiler baut eine asynchrone Zustandsmaschine.


// Beispiel 5.1: Asynchroner Zahlengenerator
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("Beginn der asynchronen Generierung...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // Simulation asynchroner Arbeit (z.B. Netzwerkrequest)
        Console.WriteLine($"Asynchron erzeuge: {i}");
        yield return i; // Element zurückgeben
    }
    Console.WriteLine("Asynchrone Generierung abgeschlossen.");
}

// Verwendung:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("Beginn der asynchronen Verarbeitung...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"Asynchron erhalten: {number}");
    }
    Console.WriteLine("Asynchrone Verarbeitung abgeschlossen.");
}

// Start:
await ConsumeAsyncNumbers(); // Rufe das aus async Main oder einem ähnlichen Kontext auf

IAsyncDisposable und await using (im Kontext von Generatoren)

Wenn ein Generator eine Ressource öffnet, die asynchron freigegeben wird (DisposeAsync()), nutze await using. Beim Beenden ruft await foreach automatisch DisposeAsync() am internen Iterator auf, falls dieser IAsyncDisposable implementiert.


// Beispiel 5.2: Asynchrones Dateilesen mit await using
// Ein echter StreamReader implementiert IAsyncDisposable
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[Generator] Öffne Datei asynchron: {filePath}");
    // await using stellt sicher, dass DisposeAsync() nach dem Block aufgerufen wird
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // Asynchrones Lesen einer Zeile
    {
        yield return line;
    }
    Console.WriteLine($"[Generator] Beendetes Lesen der Datei: {filePath}");
}

// Verwendung:
async Task ProcessFileAsync()
{
    Console.WriteLine("[Handler] Beginn der Dateiverarbeitung.");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // ersetze durch echten Pfad
    {
        Console.WriteLine($"[Handler] Zeile erhalten: {line}");
        // Hier kann man asynchrones Processing jeder Zeile machen
        await Task.Delay(50); 
    }
    Console.WriteLine("[Handler] Dateiverarbeitung abgeschlossen.");
}

// Start:
await ProcessFileAsync(); // Rufe das aus async Main auf

Abbrechen asynchroner Generatoren: CancellationToken

Füge CancellationToken in asynchrone Generatoren ein, damit aufrufender Code die Generierung abbrechen kann.


// Beispiel 5.3: Asynchroner Generator mit Abbruch
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // Prüfen des CancellationToken
        await Task.Delay(100, token); // Task.Delay unterstützt ebenfalls Abbruch
        yield return start + i;
    }
}

Verwendung


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // Dem Generator etwas Zeit geben
    cts.Cancel(); // Abbrechen!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"Erhalten: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Generierung abgebrochen.");
}

6. Einschränkungen und vorsichtiger Einsatz

Einschränkungen von yield:

  • Du kannst yield return nicht in try-Blöcken verwenden, in denen catch oder finally ebenfalls yield enthalten.
  • Methoden mit yield können nicht unsafe sein.
  • Du kannst yield nicht in async void-Methoden verwenden (nutze async Task oder IAsyncEnumerable<T>).

Performance: Für sehr kleine oder feste Collections können die Overheads der Zustandsmaschine minimal höher sein als das direkte Zurückgeben einer List<T>. Bei großen Datenmengen ist jedoch der Gewinn durch Faulheit und Streaming in der Regel deutlich wichtiger.

Fehlerbehandlung: Ausnahmen, die innerhalb eines Generators geworfen werden, erreichen korrekt den aufrufenden Code und können dort genauso behandelt werden wie in normalen Methoden.

2
Aufgabe
C# SELF, Level 62, Lektion 1
Gesperrt
Asynchroner Zahlengenerator im Bereich
Asynchroner Zahlengenerator im Bereich
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION