Praxisleitfaden für FileSystemWatcher - Umgang mit verpassten und doppelten Ereignissen

· · FileSystemWatcher, C#, .NET, Windows-Entwicklung, Dateiintegration, Design

FileSystemWatcher ist unter .NET auf Windows die erste API, die für die Überwachung von Dateiänderungen infrage kommt. Sie liefert praktischerweise Erstellungen, Änderungen, Löschungen und Umbenennungen von Dateien und Verzeichnissen als Ereignisse – wer Created oder Changed jedoch unbesehen als Abschlussbenachrichtigung nimmt, erlebt ziemlich regelmäßig Unfälle durch verpasste Ereignisse, doppelte Benachrichtigungen und das Lesen halbfertiger Dateien.

In diesem Artikel ordnen wir die Verwendung von FileSystemWatcher und ihre Fallstricke, ausgehend hauptsächlich von der Dateiintegration mit .NET auf Windows. Ergänzend lässt sich die zugrunde liegende Idee des wechselseitigen Ausschlusses in „Grundlagen des wechselseitigen Ausschlusses bei der Dateiintegration - Best Practices für Dateisperren und atomare Claims“ nachlesen.

Tatsächlich kann Created während einer Dateikopie zuerst feuern, und auch Changed kommt keineswegs immer nur einmal. Ballen sich Änderungen in kurzer Zeit, kann der interne Puffer überlaufen, wodurch einzelne Änderungen verloren gehen.

Der Kern des Designs lautet daher:

  • Benachrichtigungen sind nur der Auslöser
  • Die Wahrheit steckt im erneuten Scan des Verzeichnisses
  • Eigentümerschaft kommt aus einem atomaren claim
  • Am Ende fängt idempotency alles auf

Im Hauptteil gehen wir mit dieser Denkweise die Stolperfallen durch, die beim Einbau von FileSystemWatcher in die Dateiintegration auftreten.

Der in diesem Artikel gezeigte Code ist als vollständiges, bau- und lauffähiges Beispielset (eine Bibliothek, eine Konsolen-Demo, die auf einem temporären Verzeichnis läuft, sowie Unit-Tests, die tatsächlich Dateien erstellen und ändern, um die Ereignisse zu prüfen) auf GitHub veröffentlicht.

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

Zielgruppe und Voraussetzungen

Dieser Artikel richtet sich an Entwickler, die unter .NET auf Windows Code schreiben, der ein Empfangsverzeichnis überwacht und Dateien einliest. Die Codebeispiele setzen C# / .NET 8 oder neuer voraus, die Grundidee selbst ist jedoch sprachunabhängig.

Dieser Artikel verwendet dieselben Begriffe wie der oben verlinkte vorherige Artikel (wechselseitiger Ausschluss bei der Dateiintegration). claim, idempotency, manifest und bundle tauchen ab Kapitel 4 ohne erneute Erklärung auf; damit der Text auch ohne Kenntnis des vorherigen Artikels nachvollziehbar bleibt, fassen wir sie vorab kurz zusammen.

Vorab zu klärende Begriffe

Begriff Bedeutung
claim Das Sichern der Eigentümerschaft nach dem Prinzip „diese Datei verarbeite ich“, ohne dass andere Worker dazwischenfunken können. Implementiert wird das über ein rename von incoming/ nach processing/<worker>/; nur der eine Prozess, dessen rename gelingt, wird Eigentümer (4.3)
idempotency (Idempotenz) Die Eigenschaft, dass das Ergebnis auch bei zwei- oder mehrfacher Verarbeitung desselben Ziels dasselbe bleibt wie bei einmaliger Verarbeitung. Da doppelte Benachrichtigungen und wiederholte Scans ohnehin vorausgesetzt werden, fängt am Ende genau das alles auf (4.5)
manifest Eine kleine, zusammen mit den eigentlichen Daten abgelegte Datei, die den Inhalt beschreibt. Enthält sie Angaben wie Anzahl, Hash und IdempotencyKey, kann der Empfänger beurteilen, „ob das bereits verarbeitet wurde“
bundle Eine Einheit, die eine einzelne Übergabe zusammenfasst. Legt man Hauptdatei + manifest + Hilfsdateien in ein gemeinsames Verzeichnis, lässt sich das gesamte Verzeichnis mit einem einzigen rename claimen (4.3)
full rescan Das komplette Neu-Auflisten des überwachten Verzeichnisses, ohne sich auf Ereignisse zu verlassen, um erneut zu klären, was verarbeitet werden darf (4.4)
overflow Das Überlaufen des internen Puffers von FileSystemWatcher, wodurch einzelne Benachrichtigungen verloren gehen. Wird über das Error-Ereignis gemeldet (2.3)
ready Der Zustand, in dem feststeht, dass „jetzt gelesen werden darf“. Die Entscheidung erfolgt nicht durch Raten, sondern anhand des final-Namens oder der Existenz von done / manifest (4.2)

Inhaltsverzeichnis

  1. Zuerst das Fazit (in einem Satz)
    • 1.1. Der kleinste lauffähige Code zuerst
  2. Häufige Missverständnisse bei FileSystemWatcher (Diagramme)
    • 2.1. Created für eine Abschlussbenachrichtigung halten
    • 2.2. Der Anzahl und Reihenfolge von Changed vertrauen
    • 2.3. Änderungen durch Überlauf des internen Puffers verlieren
  3. Antipatterns
    • 3.1. Direkt im Event-Handler verarbeiten
    • 3.2. Versuchen, den wahren Zustand aus der Ereignisreihe zu rekonstruieren
    • 3.3. „Kein Changed mehr“ als Abschluss werten
    • 3.4. Glauben, ein größeres InternalBufferSize löse das Problem
    • 3.5. Error nur protokollieren und ignorieren
  4. Best Practices
    • 4.1. Benachrichtigungen zu einer „Rescan-Anforderung“ zusammenfassen
    • 4.2. Die Abschlussbedingung auf Senderseite explizit machen
    • 4.3. Der Empfänger nimmt den claim atomar
    • 4.4. Bei startup / overflow / Neuverbindung einen full rescan durchführen
    • 4.5. idempotency voraussetzen
  5. Pseudocode (Auszug)
    • 5.1. Das typische Fehlermuster
    • 5.2. Ein Beispiel in die richtige Richtung (grob skizziert)
  6. Grobe Entscheidungshilfe
  7. Fazit
  8. Referenzen

1. Zuerst das Fazit (in einem Satz)

  • Die Ereignisse von FileSystemWatcher sind keine Abschlussbenachrichtigungen, sondern nur ein Anzeichen für eine Veränderung
  • Created / Changed / Renamed können sich verdoppeln, in einer unerwarteten Reihenfolge eintreffen oder bei einem overflow verloren gehen
  • Event-Handler sind stabiler, wenn sie keine schwere Arbeit erledigen und nur eine Rescan-Anforderung anhäufen
  • Die Abschlusserkennung sollte grundsätzlich über temp -> close -> rename / replace oder done / manifest explizit gemacht werden
  • Bei mehreren Workern muss der claim vor dem Lesen atomar genommen werden
  • Das Anpassen von InternalBufferSize ist nur ein Hilfsmittel. Am Ende wirken full rescan und idempotency

Kurz gesagt: FileSystemWatcher nicht als „wahrheitsgetreuen Verlaufsstream“ behandeln. Es bricht deutlich seltener, wenn Benachrichtigungen nur als Signal „geh mal nachsehen“ verstanden werden.

1.1. Der kleinste lauffähige Code zuerst

Für alle, die FileSystemWatcher noch nicht angefasst haben, hier die kleinstmögliche Form, die nur den Normalfall zeigt. Die folgenden Kapitel drehen sich um die Fallstricke, die genau dort beginnen, wo diese zehn Zeilen „einfach funktionieren“.

// C# / .NET 8-Konsolenanwendung. Minimalform, die nur prüft, dass Benachrichtigungen ankommen
using System.IO;

using var watcher = new FileSystemWatcher(@"C:\incoming")
{
    Filter = "*.csv",
    NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
};

watcher.Created += (_, e) => Console.WriteLine($"Created: {e.FullPath}");
watcher.Changed += (_, e) => Console.WriteLine($"Changed: {e.FullPath}");
watcher.Renamed += (_, e) => Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
watcher.Error += (_, e) => Console.WriteLine($"Error: {e.GetException().Message}");

watcher.EnableRaisingEvents = true; // Hier beginnt die Überwachung
Console.WriteLine("Zum Beenden die Eingabetaste drücken");
Console.ReadLine();

Auch in dieser Minimalform lohnt es sich, diese drei Punkte vorab zu klären.

  • Bis EnableRaisingEvents = true gesetzt wird, kommt kein einziges Ereignis an. Das bloße Registrieren der Handler genügt nicht
  • Die Lebensdauer von watcher ist die Lebensdauer der Anwendung. Verliert eine lokale Variable ihren Gültigkeitsbereich und wird verworfen, stoppen dort die Benachrichtigungen. Soll die Überwachung dauerhaft laufen, muss watcher an einem langlebigen Ort wie einem Feld gehalten werden
  • Der Standardwert von NotifyFilter ist die Kombination LastWrite | FileName | DirectoryName (siehe FileSystemWatcher.NotifyFilter Property in Kapitel 8, Referenzen). Es ist ratsam, explizit anzugeben, was erfasst werden soll, damit man beim späteren Lesen nicht ins Grübeln kommt

Wichtig ist außerdem: Dieser Code prüft nur, „dass Ereignisse ankommen“. Ob zum Zeitpunkt von Created bereits gelesen werden darf oder ob Benachrichtigungen verloren gegangen sind, lässt sich damit nicht feststellen. Ab hier beginnt das eigentliche Thema.

2. Häufige Missverständnisse bei der Verwendung von FileSystemWatcher (Diagramme)

2.1. Created für eine Abschlussbenachrichtigung halten

Das ist die am leichtesten verständliche Mine. Bei Kopien und Übertragungen feuert Created in dem Moment, in dem die Datei erzeugt wird, und danach können ein oder mehrere Changed-Ereignisse folgen.

EmpfängerFileSystemWatcherwatched dirSenderEmpfängerFileSystemWatcherwatched dirSenderKopie noch nicht abgeschlossenFehlende Zeilen / defektes JSON / defektes ZIPErstellt orders.csvCreatedOnCreatedÖffnet orders.csv und liestSchreibt den RestChangedChanged

Created mag „der Name ist sichtbar geworden“ bedeuten, garantiert aber nicht „jetzt darf gelesen werden“. Wer beides gleichsetzt, tappt über einen anderen Weg in dieselbe Falle wie in Abschnitt 2.1 des vorherigen Artikels.

2.2. Der Anzahl und Reihenfolge von Changed vertrauen

Changed kommt nicht garantiert nur einmal. Selbst gewöhnliche Vorgänge wie Verschieben oder Speichern können als mehrere Ereignisse erscheinen. Zusätzlich werden auch Zugriffe von Virenschutzsoftware oder Indexern erfasst.

FileSystemWatcherAV / indexerwatched dirSpeichernde AppFileSystemWatcherAV / indexerwatched dirSpeichernde AppNicht garantiert einmalig oder in dieser ReihenfolgeBeginnt report.xlsx zu speichernCreatedChangedrename von der temporären DateiRenamedChangedScan / AttributzugriffChanged

Erwartungen wie „ein Changed bedeutet fertig“ oder „nach Renamed wird nichts mehr angefasst“ sind ziemlich riskant.

Ergänzend:

  • Ein rename der Datei kann ein Changed auslösen
  • RenamedEventArgs.Name kann null sein, wenn das Betriebssystem old/new nicht zuordnen kann
  • Auch hidden files sind nicht ausgenommen. „Ein versteckter temp-Name wird schon nicht bemerkt“ stimmt nicht
  • Wird das überwachte Verzeichnis selbst umbenannt, wird diese Änderung nicht gemeldet

2.3. Änderungen durch Überlauf des internen Puffers verlieren

FileSystemWatcher besitzt einen internen Puffer. Ballen sich Änderungen in kurzer Zeit, läuft dieser über, und einzelne Benachrichtigungen gehen verloren.

JaNeinViele Änderungen in kurzer ZeitBenachrichtigungen stauen sich im internen PufferKommt die Verarbeitung mit?Einzelne Ereignisse der Reihe nach verarbeitenoverflowError-EreignisDer Vollständigkeit der Einzelhistorie nicht vertrauenVerzeichnis per full rescan neu überprüfen

Wichtig ist hier, dass ein overflow nicht garantiert „nur ein einzelnes Ereignis verloren geht“. Die Vollständigkeit der gesamten Einzelereignis-Reihe wird dadurch grundsätzlich fraglich, deshalb ist es besser, das Gesamtbild direkt neu zu überprüfen.

3. Antipatterns

3.1. Direkt im Event-Handler verarbeiten

Hier wird den Ereignissen zu viel aufgebürdet – Abschlusserkennung und Eigentumsübernahme zugleich.

watcher.Created += (_, e) =>
{
    using var stream = File.OpenRead(e.FullPath);
    Import(stream); // Könnte noch mitten im Kopiervorgang sein
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException()); // Wird nur ausgegeben
};

Es gibt zwei Probleme.

  • Zum Zeitpunkt von Created kann der Inhalt noch unvollständig sein
  • Es gibt keine Erholung von Fehlern oder overflow

Am besten ist ein Event-Handler, der nur eine Rescan-Anforderung stellt und sofort zurückkehrt. Beginnt man hier bereits mit schwerer I/O oder DB-Aktualisierungen, schnürt man sich bei Bursts selbst die Luft ab.

3.2. Versuchen, den wahren Zustand aus der Ereignisreihe zu rekonstruieren

Ein Design wie „bei Created ins Dictionary aufnehmen, bei Changed aktualisieren, bei Deleted entfernen, bei Renamed den Schlüssel austauschen“ wirkt auf den ersten Blick elegant. Kommen jedoch Duplikate, Aufteilungen, overflow oder äußere Störungen hinzu, gerät die Buchführung nach und nach ins Wanken.

switch (e.ChangeType)
{
    case WatcherChangeTypes.Created:
        state[e.FullPath] = Pending;
        break;
    case WatcherChangeTypes.Changed:
        state[e.FullPath] = Modified;
        break;
    case WatcherChangeTypes.Deleted:
        state.Remove(e.FullPath);
        break;
}

Stärker als dieser Ansatz ist es, jedes Mal den tatsächlichen Zustand auf der Festplatte neu zu prüfen. Bei der Dateiintegration kommt es darauf an, in genau diesem Moment korrekt zu erkennen, was verarbeitet werden darf – nicht darauf, die Ereignishistorie originalgetreu zu rekonstruieren.

3.3. „Kein Changed mehr“ als Abschluss werten

Dieses Design riecht genauso wie das „abgeschlossen, sobald sich die Dateigröße nicht mehr ändert“ aus dem vorherigen Artikel. Es wirkt praktisch, bestimmt den Abschluss aber durch Raten.

if (lastChangedAt + TimeSpan.FromSeconds(10) < DateTime.UtcNow)
{
    return Ready;
}

Probleme bereitet das zum Beispiel in folgenden Fällen.

  • Die Kopie einer großen Datei pausiert mittendrin
  • Die sendende Anwendung speichert in mehreren Schritten
  • Benachrichtigungen erscheinen über eine Netzwerkfreigabe verzögert
  • Ein externer Prozess schreibt nachträglich Attribute oder Zeitstempel um

Stabiler ist es, den Abschluss nicht zu raten, sondern explizit zu machen.

3.4. Glauben, ein größeres InternalBufferSize löse das Problem

Das Anpassen von InternalBufferSize ist wichtig, ist aber nicht der Kern des Designs.

  • Der Standardwert ist 8192 Byte
  • Er kann nicht unter 4096 Byte gesetzt werden und 64 KB nicht überschreiten
  • Da der Puffer non-paged memory verwendet, ist eine Vergrößerung nicht beliebig unbedenklich

Das heißt: Selbst bei 64 KB ist Schluss, sobald ein Benachrichtigungs-Burst darüber hinausgeht. Und das Problem, ob es sich um eine Abschlussbenachrichtigung handelt, wird dadurch nicht im Geringsten gelöst.

Bevor der Puffer vergrößert wird, gibt es Dinge, die zuerst angegangen werden sollten.

  • Den Überwachungsbereich über Filter / Filters eingrenzen
  • NotifyFilter auf das notwendige Minimum beschränken
  • IncludeSubdirectories nicht unbedacht auf true setzen
  • Die Event-Handler entlasten
  • full rescan und idempotency einführen

3.5. Error nur protokollieren und ignorieren

Error gehört nicht zu der Art von Benachrichtigung, die „gelegentlich auftaucht, aber egal ist“. Genau hier zeigen sich buffer overflows und Situationen, in denen die Überwachung nicht fortgesetzt werden konnte.

watcher.Error += (_, e) =>
{
    _logger.LogError(e.GetException(), "watcher error");
    // Endet man hier, wurde der Verlust zwar bemerkt, aber nicht behoben
};

Mindestens so weit sollte man gehen.

  • Einen full rescan anfordern
  • Ist die fortgesetzte Überwachung fraglich, auch das Neuerstellen des watchers erwägen
  • Die Wiederverarbeitung so gestalten, dass sie unter der Annahme verpasster Ereignisse idempotent bleibt

4. Best Practices

4.1. Benachrichtigungen zu einer „Rescan-Anforderung“ zusammenfassen

Werden Created / Changed / Deleted / Renamed / Error jeweils direkt mit eigener Fachlogik verknüpft, verschlechtert sich die Übersicht. Zuerst werden sie alle zu einem einzigen Signal – „geh nachsehen“ – zusammengefasst.

Created / Changed / Deleted / Renamedscan requestError / overflowstartupVerzeichnis erneut scannenready-Kandidaten auflistenclaim versuchen

Punkte zur Umsetzung:

  • Im Event-Handler nur dirty = true setzen und ein signal auslösen
  • Die Abtastung auf einen einzigen worker konzentrieren
  • Bei Bursts etwa 100–300 ms sammeln und dann einmal scannen
  • Kommen während des Scans weitere Benachrichtigungen an, danach noch einmal scannen

Der dritte Wert, 100–300 ms, ist keine in einer Norm oder offiziellen Dokumentation begründete Zahl, sondern ein Ausgangswert aus der Betriebserfahrung des Autors. In der Praxis lässt er sich zuverlässiger festlegen, wenn man vorher die folgenden zwei Größen misst.

Zu betrachten Vorgehen
Dauer eines einzelnen Scans Ist die Wartezeit kürzer als das, sammeln sich vor Abschluss des Scans nur weitere Scan-Anforderungen an. Als Untergrenze mindestens die Scan-Dauer ansetzen
Tolerierbare Erkennungsverzögerung Die Wartezeit wird direkt zur Erkennungsverzögerung. Gibt es eine Anforderung wie „innerhalb von n Sekunden nach dem Ablegen verarbeiten“, die Obergrenze so setzen, dass sie in einem Teil davon Platz findet

Dauert ein einzelner Scan zum Beispiel nur 50 ms und genügt eine Erkennung innerhalb einer Sekunde, passen diese 100–300 ms genau. Braucht ein einzelner Scan dagegen wegen vieler Dateien mehrere Sekunden, wirkt es mehr, zuerst den Scan selbst zu überarbeiten (Eingrenzung des Ziels, nur auf done schauen, Unterverzeichnisse aufteilen), statt die Wartezeit zu verlängern.

So lässt sich das Endergebnis vereinheitlichen, egal ob 5 oder 50 Ereignisse eintreffen: „die tatsächlichen Dateien ansehen und ready-Kandidaten finden“.

4.2. Die Abschlussbedingung auf Senderseite explizit machen

Kontrolliert man auch die Senderseite selbst, wirkt es mehr, das Veröffentlichungsprotokoll zu korrigieren, als sich mit der Abschlusserkennung auf Seiten von FileSystemWatcher abzumühen.

Der Königsweg bleibt auch hier derselbe.

  • Den gesamten Inhalt unter einem temp-Namen schreiben
  • close aufrufen
  • Auf demselben Dateisystem rename / replace durchführen
  • Bei Bedarf zum Schluss done / manifest ablegen
Gesamten Inhalt in data.tmp schreibenflush / closerename / replace nach data.csvdata.done / manifest.json ablegenEmpfänger achtet nur auf final-Namen oder done

Das ist dasselbe wie im vorherigen Artikel, aber genau hier zeigt sich der eigentliche Nutzen. Es passt besser, FileSystemWatcher nicht als Werkzeug zu verstehen, das Abschluss erfindet, sondern als Werkzeug, das einen explizit gemachten Abschluss frühzeitig bemerkt.

4.3. Der Empfänger nimmt den claim atomar

Auch wenn ein Rescan einen ready-Kandidaten findet, können bei direktem Lesezugriff mehrere Worker ihn gleichzeitig ergreifen. Deshalb wird vor der Verarbeitung der claim atomar genommen.

processing/worker2processing/worker1incomingscannerprocessing/worker2processing/worker1incomingscannerNur wer zuerst erfolgreich ist, besitzt die EigentümerschaftEntdeckt order-123rename order-123rename order-123

Wie schon im vorherigen Artikel erwähnt, ist das rename incoming -> processing/<worker>/ die klarste Lösung. Fasst man insbesondere Hauptdatei + manifest + Hilfsdateien in einem einzigen Verzeichnis zusammen, lässt sich der claim bequem pro bundle nehmen.

incoming/
  order-123/
    payload.csv
    manifest.json

Damit genügt ein einziges rename des bundle directory, um die Eigentümerschaft zu übernehmen.

4.4. Bei startup / overflow / Neuverbindung einen full rescan durchführen

Das ist ziemlich wichtig.

  • Dateien, die schon vor dem Anwendungsstart abgelegt wurden, werden über Ereignisse nicht erfasst
  • Nach einem overflow lässt sich der einzelnen Ereignisreihe kaum noch vertrauen
  • Sind Netzwerkfreigaben oder vorübergehende Verbindungsabbrüche im Spiel, ist es sicherer, davon auszugehen, dass „irgendetwas dazwischen“ fehlt

Daher sollte mindestens zu folgenden Zeitpunkten ein full rescan durchgeführt werden.

  • Beim Start
  • Beim Empfang von Error
  • Direkt nach dem Neuerstellen des watchers
  • Als regelmäßige Absicherung in festen Abständen

Die Denkweise dahinter: „Der watcher liefert einen Hinweis auf die Differenz, der Rescan stellt die Konsistenz wieder her.“

4.5. idempotency voraussetzen

Bei der Verwendung von FileSystemWatcher kommt es dazu, dasselbe Ziel mehrfach anzusehen. Das ist kein Bug – stabiler ist es, das als Teil des Designs zu akzeptieren.

Konkret sieht das etwa so aus.

  • Ein IdempotencyKey wird ins manifest aufgenommen
  • Ist etwas bereits verarbeitet, werden die Nebenwirkungen nicht erneut ausgeführt
  • archiviert / in der DB vermerkt / versendet lässt sich abgleichen
  • Auch ein full rescan bewirkt nur, „dasselbe noch einmal sicher anzusehen“

Exactly-once allein aus Ereignissen zu bauen, wird ziemlich mühsam. At-least-once zu akzeptieren und am Ende mit idempotency abzuschließen, ist in der Praxis die stärkere Position.

5. Pseudocode (Auszug)

5.1. Das typische Fehlermuster

using var watcher = new FileSystemWatcher(incomingDir)
{
    Filter = "*.csv",
    IncludeSubdirectories = false,
    EnableRaisingEvents = true,
    InternalBufferSize = 64 * 1024
};

watcher.Created += (_, e) =>
{
    // Nimmt an, Created = Abschlussbenachrichtigung
    ProcessFile(e.FullPath);
};

watcher.Changed += (_, e) =>
{
    // Kommt mehrfach, also einfach noch einmal verarbeiten
    ProcessFile(e.FullPath);
};

watcher.Error += (_, e) =>
{
    Console.WriteLine(e.GetException());
    // Keine Erholung
};

Es gibt vier Probleme.

  • Created / Changed werden direkt mit der Fachverarbeitung verknüpft
  • Es gibt keine Abschlusserkennung
  • Bei overflow wird kein full rescan durchgeführt
  • Es gibt keinen Mechanismus, der verhindert, dieselbe Datei mehrfach zu verarbeiten

5.2. Ein Beispiel in die richtige Richtung (grob skizziert)

private readonly SemaphoreSlim _scanSignal = new(0, int.MaxValue);
private int _scanRequested = 0;
private int _fullRescanRequested = 0;

void OnAnyChange(object? sender, FileSystemEventArgs e)
{
    RequestScan(full: false);
}

void OnRenamed(object? sender, RenamedEventArgs e)
{
    RequestScan(full: false);
}

void OnError(object? sender, ErrorEventArgs e)
{
    Log(e.GetException());
    RequestScan(full: true);
}

void RequestScan(bool full)
{
    if (full)
    {
        Interlocked.Exchange(ref _fullRescanRequested, 1);
    }

    if (Interlocked.Exchange(ref _scanRequested, 1) == 0)
    {
        _scanSignal.Release();
    }
}

async Task ScannerLoopAsync(CancellationToken cancellationToken)
{
    RequestScan(full: true); // startup scan

    while (!cancellationToken.IsCancellationRequested)
    {
        await _scanSignal.WaitAsync(cancellationToken);

        // Benachrichtigungs-Bursts etwas sammeln
        await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);

        Interlocked.Exchange(ref _scanRequested, 0);
        bool full = Interlocked.Exchange(ref _fullRescanRequested, 0) == 1;

        foreach (var bundle in EnumerateReadyBundles(incomingDir, full))
        {
            var claimedPath = Path.Combine(processingDir, bundle.Name);

            if (!TryClaimByRename(bundle.Path, claimedPath))
            {
                continue; // Ein anderer Worker hat es zuerst übernommen
            }

            var manifest = ReadManifest(Path.Combine(claimedPath, "manifest.json"));

            if (AlreadyProcessed(manifest.IdempotencyKey))
            {
                MoveToArchive(claimedPath, archiveDir);
                continue;
            }

            ProcessBundle(claimedPath);
            RecordProcessed(manifest.IdempotencyKey);
            MoveToArchive(claimedPath, archiveDir);
        }

        if (Volatile.Read(ref _scanRequested) == 1)
        {
            _scanSignal.Release(); // Benachrichtigungen, die während des Scans eintrafen, nicht verlieren
        }
    }
}

Wichtig an diesem Beispiel ist nicht die genaue API, sondern der Ablauf.

  • Benachrichtigungen zu einer scan request zusammenfassen
  • Durch Abtasten ready-Kandidaten finden
  • Den claim nehmen
  • idempotency prüfen
  • Verarbeiten, vermerken und ins archive verschieben

Die Ereignisse von FileSystemWatcher sind hier nichts weiter als ein trigger.

EnumerateReadyBundles / TryClaimByRename / ReadManifest / AlreadyProcessed und Ähnliches sind Funktionen, denen dieser Artikel Namen gegeben hat, um den Ablauf zu zeigen; sie sind keine Standard-APIs von .NET. Die tatsächlich bau- und lauffähige Form (Bibliothek, eine auf einem temporären Verzeichnis laufende Konsolen-Demo, Unit-Tests, die die Ereignisse prüfen) befindet sich im eingangs genannten Beispielset.

filesystemwatcher-safe-basics - komurasoft-blog-samples (GitHub)

6. Grobe Entscheidungshilfe

  • Einzelner Empfangs-Worker / man kann auch die Senderseite selbst korrigieren Zunächst temp -> close -> rename und ein startup scan. Das allein ist schon recht stabil.

  • Es gibt mehrere Empfangs-Worker Zusätzlich das claim-rename incoming -> processing einführen.

  • Hochfrequent mit vielen Benachrichtigungen Filter / NotifyFilter / IncludeSubdirectories eingrenzen und die Event-Handler auf ein Minimum reduzieren. Das Anpassen von InternalBufferSize kommt danach.

  • overflow bereitet Probleme / verpasste Ereignisse sind nicht akzeptabel Auf full rescan setzen, und wenn das immer noch nicht reicht, sich nicht allein auf FileSystemWatcher verlassen. Bei reiner Windows-Nutzung kommt auch das USN change journal als Option infrage.

  • Die Schreibweise des Gegensystems lässt sich nicht kontrollieren Sicherer ist es, zuerst zu prüfen, ob sich das Veröffentlichungsprotokoll verhandeln lässt, statt die Abschlussbedingung durch Raten zu ersetzen. Geht das nicht, das Garantieniveau senken und stärker auf ein idempotentes Empfangsdesign setzen.

Die letzten beiden Punkte sind recht wichtige Rückzugsentscheidungen. FileSystemWatcher ist praktisch, aber kein allmächtiger Wahrheitsdetektor.

Worin unterscheidet sich das USN change journal?

Das USN change journal ist eine Aufzeichnung von Änderungen, die NTFS pro Volume führt. Verzeichnisbenachrichtigungen wie bei FileSystemWatcher lassen sich nur empfangen, wenn die Anwendung genau im Moment der Änderung läuft, doch beim change journal bleibt der Datensatz auf Seiten des Volumes erhalten, sodass sich auch Änderungen, die während des Stillstands der Anwendung stattfanden, später ab der zuletzt gelesenen Position (USN) nachlesen lassen. Auch die Microsoft-Dokumentation nennt als Schwäche der Verzeichnisbenachrichtigung, dass „die Anwendung ständig laufen muss“, und beschreibt das change journal als Ausweg dagegen.

Andererseits steigt auch der Aufwand.

  FileSystemWatcher USN change journal
Überwachungseinheit Das angegebene Verzeichnis (+ Unterverzeichnisse) Das gesamte Volume. Den benötigten Bereich muss man selbst eingrenzen
Während die Anwendung stand Unbekannt. Wird durch full rescan aufgefüllt Lässt sich aus dem Datensatz nachlesen
Verpasste Ereignisse Entstehen durch overflow des internen Puffers Werden gelöscht, sobald die Journal-Obergrenze überschritten wird, beginnend mit den ältesten Einträgen
Benötigt wird Nur die .NET-API Ein Volume-Handle und Aufrufe von FSCTL_*. Für Verwaltungsvorgänge wie das Erstellen/Löschen des Journals sind Administratorrechte nötig

Kurz gesagt ist das eine Option, sobald Anforderungen wie „kann nicht dauerhaft laufen“ oder „Änderungen während des Stillstands sollen ebenfalls erfasst werden“ ins Spiel kommen. Braucht man das nicht, ist FileSystemWatcher + full rescan die geradlinigere Umsetzung.

7. Fazit

FileSystemWatcher ist kein Ersatz für eine Abschlussbenachrichtigung. Die Wahrheit steckt nicht in der Ereignisreihe, sondern in dem Zustand, der gerade jetzt auf der Festplatte sichtbar ist. Der Abschluss wird über temp -> close -> rename / replace oder done / manifest explizit gemacht, die Eigentümerschaft wird durch atomares Nehmen des claim entschieden. Genau darin liegt der Kern des Designs.

Sofortiges Verarbeiten bei Created, das Vertrauen auf Anzahl oder Reihenfolge von Changed, „kein Changed mehr“ als Abschluss werten, sich allein auf InternalBufferSize verlassen, Error sehen und trotzdem nicht sich erholen – all das sind Designs, die vermieden werden sollten. Stattdessen werden Benachrichtigungen zu Rescan-Anforderungen zusammengefasst, bei startup / overflow / Neuverbindung ein full rescan durchgeführt, die Eigentümerschaft über claim-rename übernommen und Duplikate sowie erneute Scans mit idempotency aufgefangen.

Der Trick bei FileSystemWatcher besteht also darin, „ein Ereignis empfangen zu haben“ und „verarbeiten zu dürfen“ nicht gleichzusetzen. Allein diese Trennung reduziert die Art von Überwachungscode, die nur gelegentlich versagt, erheblich.

8. Referenzen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Darf ich eine Datei beim Created-Ereignis von FileSystemWatcher lesen?
Nein. Created bedeutet nur, dass „der Name sichtbar geworden ist“, und garantiert nicht, dass „jetzt gelesen werden darf“. Bei Kopien und Übertragungen feuert Created in dem Moment, in dem die Datei erzeugt wird, und danach können ein oder mehrere Changed-Ereignisse folgen. Der Abschluss sollte vom Sender über temp -> close -> rename/replace oder done/manifest explizit gemacht werden, während der Empfänger nur auf den final-Namen oder done achtet.
Kann FileSystemWatcher Benachrichtigungen verpassen?
Ja. Läuft der interne Puffer (Standard 8192 Byte, mindestens 4096 Byte, maximal 64 KB) über, gehen einzelne Benachrichtigungen verloren, und es wird ein Error-Ereignis ausgelöst. Kommt es zu einem overflow, wird die Vollständigkeit der gesamten Einzelereignis-Reihe fraglich, daher ist es sicherer, das Verzeichnis per full rescan komplett neu zu überprüfen. Ein full rescan sollte beim Start, beim Empfang von Error, direkt nach dem Neuerstellen des watchers und auch als regelmäßige Absicherung durchgeführt werden.
Warum kommt das Changed-Ereignis mehrfach?
Weil selbst gewöhnliche Vorgänge wie Verschieben oder Speichern in mehrere Ereignisse aufgeteilt erscheinen können und zusätzlich auch Zugriffe von Virenschutzsoftware oder Indexern erfasst werden. Ein Design, das sich auf Anzahl oder Reihenfolge verlässt, ist riskant. Stabiler ist es, alle Benachrichtigungen zu einem einzigen Signal – einer „Rescan-Anforderung“ – zusammenzufassen, die Abtastung auf einen einzigen worker zu konzentrieren und bei Bursts etwa 100–300 ms zu sammeln, bevor einmal gescannt wird.
Löst eine Erhöhung von InternalBufferSize das Problem verpasster Ereignisse?
Nein. Selbst bei einer Erhöhung auf 64 KB gehen Benachrichtigungen verloren, sobald ein Burst darüber hinausgeht, und das Problem, ob es sich um eine Abschlussbenachrichtigung handelt, wird dadurch nicht im Geringsten gelöst. Da der Puffer non-paged memory verwendet, ist eine Vergrößerung auch nicht ganz beliebig möglich. Die richtige Reihenfolge ist: zuerst den Überwachungsbereich über Filter/NotifyFilter eingrenzen, IncludeSubdirectories überdenken, die Event-Handler entlasten und dann full rescan sowie idempotency einführen.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog