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
- Neue Inhalte niemals direkt über die Zieldatei schreiben.
- Die temporäre Datei im selben Verzeichnis wie die Zieldatei erstellen.
- Die vollständig geschriebene temporäre Datei vor der Veröffentlichung auf den Datenträger flushen.
File.Replacefür vorhandene Dateien undFile.Movefür die erstmalige Erstellung verwenden.- Zurückgelassene temporäre Dateien löschen, wenn der Vorgang fehlschlägt oder abgebrochen wird.
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
- Eine temporäre Datei mit eindeutigem Namen neben der Zieldatei erstellen.
- Den vollständigen Inhalt in die temporäre Datei schreiben.
- Die Puffer des Writers und des Dateisystems leeren.
- Wenn die Zieldatei existiert, diese mit
File.Replaceersetzen. - Wenn die Zieldatei nicht existiert, die temporäre Datei an ihre endgültige Position verschieben.
- 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:
- Ein anwendungsweites
SemaphoreSlimfür Schreibvorgänge innerhalb eines Prozesses. - Einen benannten Mutex für mehrere Prozesse auf demselben Computer.
- Eine Datenbank- oder Distributed-Lock-Lösung, wenn mehrere Computer dieselbe Ressource aktualisieren können.
- Eine Versionsprüfung oder ein Optimistic-Concurrency-Token zur Erkennung veralteter Änderungen.
Häufige Erweiterungen
- Ein Ergebnis mit endgültigem Pfad, Backup-Pfad und Anzahl der geschriebenen Bytes zurückgeben.
- Verwaiste
.tmp-Dateien, die durch einen harten Prozessabbruch entstanden sind, begrenzen oder regelmäßig entfernen. - Binäre Inhalte nach demselben Muster schreiben, indem ein Stream oder
ReadOnlyMemory<byte>akzeptiert wird. - Prüfen, ob ein optionaler Backup-Pfad auf einem kompatiblen Dateisystem-Volume liegt.
- Retry-Logik nur für bestimmte vorübergehende I/O-Fehler ergänzen – niemals pauschal jede Exception erneut versuchen.
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.Replacefür ein vorhandenes Ziel undFile.Movefür eine neue Datei verwenden.- Beachten Sie, dass atomarer Austausch allein keine Koordination mehrerer Writer löst.