Wird geladen...

Atomisches Schreiben von Dateien in .NET: Unvollständige Dateien verhindern

Schreiben Sie Inhalte zuerst in eine temporäre Datei und ersetzen oder verschieben Sie diese anschließend atomar, um unvollständige Dateien zu vermeiden.

Das direkte Schreiben in eine vorhandene Datei ist einfach, kann jedoch dazu führen, dass die Datei leer, unvollständig oder beschädigt bleibt, wenn die Anwendung abstürzt, der Prozess beendet wird oder während des Schreibens ein Ein-/Ausgabefehler auftritt.

Ein sichererer Ansatz ist das atomare Schreiben von Dateien: Schreiben Sie zunächst den vollständigen Inhalt in eine temporäre Datei, leeren Sie anschließend die Puffer und ersetzen Sie erst danach die Zieldatei. Leser sollten entweder die vorherige vollständige Datei oder die neue vollständige Datei sehen – niemals eine nur teilweise geschriebene Version.


Ziel


Warum direktes Überschreiben riskant sein kann

Direktes Schreiben ist bequem:


await File.WriteAllTextAsync(
    "settings.json",
    json,
    cancellationToken);

Beim Überschreiben einer vorhandenen Datei wird diese jedoch normalerweise gekürzt, bevor der neue Inhalt vollständig geschrieben wurde. Wird der Prozess währenddessen beendet, kann die Zieldatei nur einen Teil des neuen Inhalts oder überhaupt keinen brauchbaren Inhalt enthalten.

Besonders problematisch ist das bei Konfigurationsdateien, exportierten Berichten, lokalen Datenbanken, Anwendungszuständen, zwischengespeicherten API-Antworten und JSON-Dokumenten, die jederzeit syntaktisch gültig bleiben müssen.


Ablauf eines atomaren Schreibvorgangs

  1. Eine temporäre Datei mit eindeutigem Namen neben der Zieldatei erstellen.
  2. Den vollständigen Inhalt in die temporäre Datei schreiben.
  3. Die Puffer des Writers und des Dateisystems leeren.
  4. Wenn die Zieldatei existiert, diese mit File.Replace ersetzen.
  5. Wenn die Zieldatei nicht existiert, die temporäre Datei an ihre endgültige Position verschieben.
  6. Die temporäre Datei entfernen, wenn vor der Veröffentlichung ein Fehler auftritt.

Wichtig: Die temporäre Datei wird bewusst im Zielverzeichnis erstellt. Wenn sich beide Dateien auf demselben Dateisystem-Volume befinden, kann die abschließende Replace- oder Move-Operation das Rename-/Replace-Verhalten des Dateisystems verwenden, statt auf eine Copy-and-Delete-Operation zurückzufallen.


AtomicFileWriter-Helper

Der folgende Helper schreibt Text standardmäßig als UTF-8 ohne Byte Order Mark. Er unterstützt Abbruchsignale, optionale Backups, das Flushen auf den Datenträger und die Bereinigung temporärer Dateien.


using System.Text;

public static class AtomicFileWriter
{
    public static async Task WriteAllTextAsync(
        string destinationPath,
        string content,
        Encoding? encoding = null,
        string? backupPath = null,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
        ArgumentNullException.ThrowIfNull(content);

        var fullDestinationPath = Path.GetFullPath(destinationPath);

        var directory = Path.GetDirectoryName(fullDestinationPath)
            ?? throw new InvalidOperationException(
                "Das Zielverzeichnis konnte nicht ermittelt werden.");

        Directory.CreateDirectory(directory);

        var destinationFileName = Path.GetFileName(fullDestinationPath);

        var temporaryPath = Path.Combine(
            directory,
            $".{destinationFileName}.{Guid.NewGuid():N}.tmp");

        var fullBackupPath = string.IsNullOrWhiteSpace(backupPath)
            ? null
            : Path.GetFullPath(backupPath);

        encoding ??= new UTF8Encoding(
            encoderShouldEmitUTF8Identifier: false);

        try
        {
            await using (var stream = new FileStream(
                temporaryPath,
                new FileStreamOptions
                {
                    Mode = FileMode.CreateNew,
                    Access = FileAccess.Write,
                    Share = FileShare.None,
                    BufferSize = 64 * 1024,
                    Options = FileOptions.Asynchronous
                }))
            {
                await using (var writer = new StreamWriter(
                    stream,
                    encoding,
                    bufferSize: 64 * 1024,
                    leaveOpen: true))
                {
                    await writer.WriteAsync(
                        content.AsMemory(),
                        cancellationToken);

                    await writer.FlushAsync();
                }

                cancellationToken.ThrowIfCancellationRequested();

                // Zwischengeschaltete Dateisystempuffer auf den Datenträger schreiben.
                stream.Flush(flushToDisk: true);
            }

            cancellationToken.ThrowIfCancellationRequested();

            if (File.Exists(fullDestinationPath))
            {
                File.Replace(
                    sourceFileName: temporaryPath,
                    destinationFileName: fullDestinationPath,
                    destinationBackupFileName: fullBackupPath,
                    ignoreMetadataErrors: false);
            }
            else
            {
                File.Move(
                    sourceFileName: temporaryPath,
                    destFileName: fullDestinationPath);
            }
        }
        finally
        {
            // Nach erfolgreichem Replace oder Move existiert die temporäre Datei nicht mehr.
            // Falls das Schreiben fehlschlägt oder abgebrochen wird, wird sie hier entfernt.
            TryDelete(temporaryPath);
        }
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
                File.Delete(path);
        }
        catch
        {
            // Ein Fehler beim Aufräumen darf die ursprüngliche Exception nicht verdecken.
        }
    }
}

Warum sowohl File.Replace als auch File.Move benötigt werden

File.Replace setzt voraus, dass die Zieldatei bereits existiert. Die Methode ersetzt die Zieldatei durch die temporäre Datei und kann die vorherige Version optional als Backup aufbewahren.

Wird die Zieldatei zum ersten Mal erstellt, gibt es noch keine Datei, die ersetzt werden könnte. In diesem Fall veröffentlicht File.Move die vollständig geschriebene temporäre Datei unter ihrem endgültigen Namen.


if (File.Exists(fullDestinationPath))
{
    File.Replace(
        temporaryPath,
        fullDestinationPath,
        fullBackupPath);
}
else
{
    File.Move(
        temporaryPath,
        fullDestinationPath);
}

Beispiel: JSON-Einstellungen sicher speichern

Mit dem Helper können Sie eine JSON-Konfigurationsdatei speichern, ohne dass Leser ein nur teilweise geschriebenes Dokument sehen.


using System.Text.Json;

public sealed record ApplicationSettings(
    string Theme,
    string Language,
    int PageSize);

var settings = new ApplicationSettings(
    Theme: "Dark",
    Language: "de",
    PageSize: 25);

var json = JsonSerializer.Serialize(
    settings,
    new JsonSerializerOptions
    {
        WriteIndented = true
    });

await AtomicFileWriter.WriteAllTextAsync(
    destinationPath: "data/settings.json",
    content: json,
    backupPath: "data/settings.json.bak",
    cancellationToken: cancellationToken);

Wenn settings.json bereits existiert, wird die vorherige Version unter settings.json.bak gespeichert, bevor die neue Datei sie ersetzt.


Beispiel: Speichern ohne Backup

Geben Sie keinen Backup-Pfad an, wenn die vorherige Version nicht aufbewahrt werden muss:


await AtomicFileWriter.WriteAllTextAsync(
    destinationPath: "exports/report.json",
    content: reportJson,
    cancellationToken: cancellationToken);

Atomarität und Dauerhaftigkeit sind nicht dasselbe

Ein atomarer Austausch konzentriert sich darauf, was Leser beobachten: entweder die alte vollständige Datei oder die neue vollständige Datei. Er verhindert, dass die Zieldatei schrittweise aktualisiert wird.

Dauerhaftigkeit beschreibt dagegen, ob die Daten einen Absturz des Betriebssystems oder einen plötzlichen Stromausfall überstehen. Mit Flush(flushToDisk: true) wird das Betriebssystem aufgefordert, zwischengespeicherte Daten auf den Datenträger zu schreiben. Die endgültigen Garantien hängen jedoch weiterhin vom Betriebssystem, Dateisystem, Speichermedium und der Hardwarekonfiguration ab.


Parallelität ist ein separates Problem

Ein atomarer Austausch koordiniert nicht automatisch mehrere Writer. Wenn zwei Prozesse gleichzeitig dieselbe Zieldatei schreiben, können beide temporäre Dateien erstellen und anschließend darum konkurrieren, ihr Ergebnis zu veröffentlichen.

Anwendungen mit mehreren Writern sollten deshalb einen zusätzlichen Koordinationsmechanismus verwenden:


Häufige Erweiterungen


TL;DR

  • Wichtige Dateien nicht direkt überschreiben.
  • Den vollständigen Inhalt zuerst in eine temporäre Datei im Zielverzeichnis schreiben.
  • Die temporäre Datei vor der Veröffentlichung auf den Datenträger flushen.
  • File.Replace für ein vorhandenes Ziel und File.Move für eine neue Datei verwenden.
  • Beachten Sie, dass atomarer Austausch allein keine Koordination mehrerer Writer löst.