CodeGym /Kurse /C# SELF /Asynchrones Lesen und Schreiben von Dateien (

Asynchrones Lesen und Schreiben von Dateien ( ReadAsync/ WriteAsync)

C# SELF
Level 42 , Lektion 1
Verfügbar

1. Einführung

Stell dir vor, du bist Dirigent eines Orchesters (deiner Anwendung). Wenn du jedes Mal wartest, bis die Geigerin ihre Geige stimmt (langsame I/O), bevor du zu anderen Instrumenten weitergehst, steht das ganze Orchester still. Aber wenn die Geigerin sagt: "Ich stimme mich, macht ihr weiter, ich gebe ein Zeichen, wenn ich fertig bin", — das ist Asynchronität!

In der Welt von C# und .NET 9 gibt es spezielle Werkzeuge für dieses "Mehr-Aktivitäten-ohne-Anhalten". Die Hauptdarsteller heute sind die asynchronen Versionen von Read und Write, nämlich ReadAsync und WriteAsync.

Sie erlauben dir, eine Lese- oder Schreiboperation zu starten und den aktuellen Ausführungsthread sofort "loszulassen", damit er etwas anderes machen kann. Wenn die I/O-Operation fertig ist (z. B. Daten von der Festplatte gelesen oder auf sie geschrieben wurden), "wacht" dein Code auf und setzt die Arbeit dort fort, wo er aufgehört hat.

Für die Nutzung dieser Methoden brauchen wir zwei magische Wörter, die schon 2012 in C# mit Version 5.0 kamen (und jetzt, in C# 14, quasi alltäglich sind):

  • async: Das ist ein Modifizierer, den du an eine Methode hängst, um dem Compiler zu sagen: "In dieser Methode kann es asynchrone Operationen geben, und ich werde await verwenden."
  • await: Das ist der Operator, den du vor einem Aufruf einer asynchronen Operation (wie ReadAsync oder WriteAsync) setzt. Er bedeutet: "Starte diese Operation, warte hier nicht synchron. Gib die Kontrolle an den Aufrufer zurück und komm zurück, wenn die Operation fertig ist".

Keine Sorge, wenn das erstmal ein bisschen nebulös wirkt. Wir haben eine ganze Einheit, die async und await (Level 58) gewidmet ist, wo wir tiefer einsteigen. Wichtig ist jetzt zu verstehen: sie helfen uns, den Hauptthread nicht zu blockieren.

2. ReadAsync: langsam lesen, ohne zu blockieren

Die Methode ReadAsync erlaubt asynchrones Lesen aus einem Stream. Anstatt darauf zu warten, dass Bytes von der Festplatte kommen, startest du das Lesen und wechselst sofort zu anderen Aufgaben.

So sieht die grundlegende Signatur zum Lesen in einen Buffer aus:

public virtual ValueTask<int> ReadAsync(
    byte[] buffer,
    int offset,
    int count,
    CancellationToken cancellationToken = default
)

Oder, was in modernem C# (und .NET 9) öfter benutzt wird, mit Memory<byte>:

public virtual ValueTask<int> ReadAsync(
    Memory<byte> buffer,
    CancellationToken cancellationToken = default
)

Parameter erklärt:

  • buffer: Das ist ein Array von byte (oder Memory<byte>), in das die Daten gelesen werden. Erinnerst du dich an Puffer zur Optimierung? Hier werden sie genauso genutzt, nur für asynchrone Operationen.
  • offset: Die Verschiebung im buffer, ab der die gelesenen Bytes geschrieben werden.
  • count: Maximale Anzahl Bytes zum Lesen.
  • CancellationToken cancellationToken: Ein sehr nützlicher Parameter, mit dem du die Operation abbrechen kannst, wenn sie nicht mehr gebraucht wird (z. B. Nutzer schließt die App oder drückt die "Abbrechen"-Taste).
  • ValueTask<int>: Das ist das "Versprechen", dass die Operation bei Abschluss eine ganze Zahl (int) zurückgibt — die Anzahl gelesener Bytes. ValueTask ist eine optimierte Version von Task für Fälle, in denen das Ergebnis synchron oder asynchron verfügbar sein kann.

Beispiel 1: Asynchrones Lesen einer Datei

Angenommen, wir haben eine große Textdatei und wollen sie lesen, ohne den Hauptthread zu blockieren. Ein einfaches Beispiel:

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

class Program
{
    // Asynchrone Funktion zum Zählen der Zeilen einer Datei
    public static async Task<int> CountLinesAsync(string filePath)
    {
        int lineCount = 0;
        // Asynchrones Öffnen der Datei
        using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
        using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);

        string? line;
        while ((line = await reader.ReadLineAsync()) != null)
        {
            lineCount++;
        }
        return lineCount;
    }

    static async Task Main()
    {
        string filename = "bigtext.txt";
        int count = await CountLinesAsync(filename);
        Console.WriteLine($"In der Datei {filename} sind {count} Zeilen");
    }
}

Kommentare zum Code:

  • Achte auf das Argument useAsync: true beim FileStream. Das ist wichtig für echte Asynchronität.
  • Wir verwenden await mit ReadLineAsync, damit der Thread nicht blockiert, während eine Zeile gelesen wird.
  • Die Methode Main ist jetzt asynchron (C# 7+ erlaubt das).

Wäre das eine echte GUI-Anwendung, könnte der Benutzer während des Lesens (während ReadAsync auf Daten von der Festplatte wartet) Buttons klicken, scrollen und andere Aktionen ausführen, weil der UI-Thread nicht blockiert ist. In einer Konsolen-App ist das weniger sichtbar, aber das Prinzip bleibt dasselbe.

3. WriteAsync: schreiben ohne Verzögerung

Analog zu ReadAsync erlaubt WriteAsync asynchrones Schreiben in einen Stream. Sehr nützlich, wenn viel zu schreiben ist und die Anwendung nicht auf das Ende der Festplattenoperation warten soll.

Wichtige Signaturen:

public virtual ValueTask WriteAsync(
    byte[] buffer,
    int offset,
    int count,
    CancellationToken cancellationToken = default
)

Und mit ReadOnlyMemory<byte> (wir verändern den Buffer beim Schreiben nicht):

public virtual ValueTask WriteAsync(
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken = default
)

Parameter sind ähnlich wie bei ReadAsync:

  • buffer: Array von byte (oder ReadOnlyMemory<byte>) mit den zu schreibenden Daten.
  • offset: Offset im buffer, ab dem die zu schreibenden Daten gelesen werden.
  • count: Anzahl Bytes, die geschrieben werden sollen.
  • CancellationToken cancellationToken: Zum Abbrechen der Operation.
  • ValueTask: Kein Rückgabewert, da die Anzahl geschriebener Bytes durch count festgelegt ist.

Beispiel 2: Asynchrones Schreiben in eine Datei

Jetzt schreiben wir asynchron etwas in eine Datei.

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

class Program
{
    public static async Task WriteTestAsync(string filePath)
    {
        using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
        using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);

        for (int i = 0; i < 10000; i++)
        {
            await writer.WriteLineAsync($"Zeile Nummer {i}");
        }
    }

    static async Task Main()
    {
        string filename = "testout.txt";
        await WriteTestAsync(filename);
        Console.WriteLine($"Schreiben von {filename} abgeschlossen.");
    }
}

Die Schleife schreibt 10000 Zeilen, während der Hauptthread nicht blockiert wird: wäre das eine GUI-App, würde die Oberfläche nicht "einfrieren".

Dank async und await kann unsere Konsolenanwendung jetzt z. B. eine Datei kopieren und gleichzeitig auf Nutzereingaben reagieren (z. B. Drücken der Enter-Taste zum Abbrechen). Das ist ein grundlegendes Prinzip moderner, performanter und responsiver Anwendungen in C#.

4. Nützliche Feinheiten

Visualisierung: wie asynchrones Lesen/Schreiben funktioniert


┌───────────────────┐     Start Async Read    ┌────────────────────────────────┐
│Dein Code (UI/Logik)│ ─────────────────────→  │  OS/I/O: asynchrone Operation  │ 
└─────┬─────────────┘                         └───────┬────────────────────────┘
      │(macht etwas anderes)                     │(liest Datei, wartet auf Platte)
      │<────────────────────────────────────────→│
      └─ Waits for Task, gets result  ←──────────┘

Kurz gesagt: während die Festplatte langsam arbeitet, kann dein Code anderes tun. Erst wenn die Daten wirklich gebraucht werden, wartet man auf das Task-Ergebnis.

Praktische Anwendungsfälle: wo das wirklich Sinn macht?

  • Desktop-Anwendungen: Wenn deine App große Daten liest oder schreibt (Logdateien, Datenbanken, Videos), ist Asynchronität ein Must-have. Selbst auf schnellen Maschinen kann ein Netzwerk-File-Open den Nutzer ausbremsen.
  • Backend oder Web-Anwendungen: Hunderte oder tausende gleichzeitige Anfragen — wenn jeder Thread beim Datei-Lesen blockiert, kommen schnell schlechte Performance und 502 Bad Gateway.
  • Mobile Apps: Wenn das Öffnen oder Speichern von Dateien Zeit braucht, sieht der Nutzer Lags. Asynchronität vermeiden diese.
  • Jede großflächige Datei-Verarbeitung: Archivierer, Parser, Analyzer — sie profitieren stark von asynchronem I/O.

Synchron vs Asynchrones Lesen/Schreiben

Methode Blockiert Thread? Einfach zu implementieren? Bessere Performance? Bequemer für UI/Server
Synchron (Read/Write) Ja Ja Nein Nein
Asynchron (ReadAsync) Nein Fast Ja Ja

5. Feinheiten und Best Practices

Pufferung bleibt wichtig: Auch mit ReadAsync und WriteAsync ist das Lesen oder Schreiben einzelner Bytes extrem ineffizient. Asynchronität entfernt Blockaden, aber macht nicht jedes einzelne Byte schneller. Gute Startwerte für Puffergrößen sind 4096-8192 Bytes; für große Dateien kann 65536 oder 131072 sinnvoll sein.

"Asynchronität überall" (Async All The Way Down): Wenn du an einer Stelle async/await einsetzt, solltest du das in der Aufrufkette durchziehen: Wenn C asynchron ist, dann ist C async Task, B sollte auch async Task sein, und A ebenfalls. Sonst drohen Blockaden oder sogar Deadlocks in UI-Apps.

Fehlerbehandlung: Im asynchronen Code nutzt du normale try-catch. Häufig sind OperationCanceledException und IOException — diese explizit behandeln.

Ressourcen freigeben (await using): Gib Streams und andere IDisposable-Objekte korrekt frei. Implementiert ein Typ IAsyncDisposable, dann ruft await using DisposeAsync() auf; ansonsten wird Dispose() ausgeführt.

Was passiert unter der Haube (kurz): Beim await wandelt der Compiler die Methode in einen Zustandsautomaten: die Operation startet, die Methode wird "pausiert" und die Kontrolle an den Aufrufer zurückgegeben. Wenn das Ergebnis fertig ist, wird entweder der SynchronizationContext (in UI) oder der ThreadPool (in Konsole/Server) die Ausführung an der Stelle fortsetzen, wo sie unterbrochen wurde. So kann ein Thread viele "angehaltene" Tasks bedienen, ohne zu blockieren.

Zusammengefasst: Asynchrones Programmieren mit async und await ist ein mächtiges Werkzeug, um responsive und skalierbare Anwendungen zu bauen. Es erlaubt deinem Code, Systemressourcen effizient zu nutzen, ohne UI oder Server-Threads zu blockieren. Anfangs fühlt sich das vielleicht ungewohnt an, aber es lohnt sich, es zu lernen. In den nächsten Vorlesungen tauchen wir tiefer in Asynchronität und Parallelismus ein. Bis dann!

Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION