C #: File.ReadLines () vs File.ReadAllLines () - und warum sollte es mich interessieren?

Vor ein paar Wochen hatten ich und zwei der Teams, mit denen ich zusammenarbeite, eine Diskussion über effiziente Methoden zur Verarbeitung großer Textdateien.

Dies löste einige frühere Diskussionen zu diesem Thema aus, insbesondere über die Verwendung von Yield Return in C # (über die ich wahrscheinlich in einem zukünftigen Blog-Beitrag sprechen werde). Daher hielt ich es für eine gute Herausforderung, zu demonstrieren, wie sich C # bei der Verarbeitung großer Datenmengen effektiv skalieren lässt.

Die Herausforderung

Das zur Diskussion stehende Problem ist also:

  • Angenommen, es gibt eine große CSV-Datei, beispielsweise ~ 500 MB für den Anfang
  • Das Programm muss jede Zeile der Datei durchgehen, sie analysieren und auf der Karte / Verkleinerung basierende Berechnungen durchführen

Und die Frage an dieser Stelle in der Diskussion ist:

Was ist die effizienteste Methode, um den Code zu schreiben, mit dem dieses Ziel erreicht werden kann? Dabei beachten Sie auch:
i) den belegten Speicherplatz minimieren und
ii) Minimierung der Codezeilen des Programms (natürlich in angemessenem Umfang)

Aus Gründen des Arguments könnten wir StreamReader verwenden, aber das würde dazu führen, dass mehr Code geschrieben wird, als benötigt wird, und tatsächlich verfügt C # bereits über die Convenience-Methoden File.ReadAllLines () und File.ReadLines (). Also sollten wir diese verwenden!

Zeig mir den Code

Betrachten wir für das Beispiel ein Programm, das:

  1. Nimmt eine Textdatei als Eingabe, wobei jede Zeile eine Ganzzahl ist
  2. Berechnet die Summe aller Zahlen in der Datei

In diesem Beispiel überspringen wir einige Überprüfungsmeldungen :-)

In C # kann dies durch den folgenden Code erreicht werden:

var sumOfLines = File.ReadAllLines (filePath)
    .Select (line => int.Parse (line))
    .Summe()

Ziemlich einfach, oder?

Was passiert, wenn wir dieses Programm mit einer großen Datei füttern?

Wenn wir dieses Programm ausführen, um eine 100-MB-Datei zu verarbeiten, erhalten wir Folgendes:

  • 2 GB RAM verbrauchten Speicher, um diese Berechnung abzuschließen
  • Viel GC (jeder gelbe Gegenstand ist ein GC-Lauf)
  • 18 Sekunden, um die Ausführung abzuschließen
Übrigens hat das Füttern einer 500-MB-Datei zu diesem Code dazu geführt, dass das Programm mit einem OutOfMemoryException-Spaß abstürzt, oder?

Versuchen wir jetzt stattdessen File.ReadLines ()

Ändern wir den Code so, dass File.ReadLines () anstelle von File.ReadAllLines () verwendet wird, und sehen wir, wie es geht:

var sumOfLines = File.ReadLines (filePath)
    .Select (line => int.Parse (line))
    .Summe()

Wenn wir es ausführen, erhalten wir jetzt:

  • 12 MB RAM verbraucht, anstatt 2 GB (!!)
  • Nur 1 GC-Lauf
  • 10 Sekunden anstelle von 18 Sekunden

Warum passiert das?

TL; DR Der Hauptunterschied besteht darin, dass File.ReadAllLines () eine Zeichenfolge [] erstellt, die jede Zeile der Datei enthält und ausreichend Speicherplatz zum Laden der gesamten Datei benötigt. im Gegensatz zu File.ReadLines (), mit dem das Programm zeilenweise gespeist wird und nur der Speicher zum Laden einer Zeile benötigt wird.

Im Detail:

File.ReadAllLines () liest die gesamte Datei auf einmal und gibt einen String [] zurück, in dem jedes Element des Arrays einer Zeile der Datei entspricht. Dies bedeutet, dass das Programm so viel Speicher benötigt wie die Größe der Datei, um den Inhalt aus der Datei zu laden. Plus den notwendigen Speicher, um ALLE Zeichenkettenelemente auf int zu analysieren und dann die Summe () zu berechnen

Auf der anderen Seite erstellt File.ReadLines () einen Enumerator für die Datei und liest ihn Zeile für Zeile (tatsächlich mit StreamReader.ReadLine ()). Dies bedeutet, dass jede Zeile in einem zeilenweisen Modus gelesen, konvertiert und zur Teilsumme addiert wird.

Fazit

Dieses Thema mag wie ein Implementierungsdetail auf niedriger Ebene erscheinen, aber es ist tatsächlich sehr wichtig, da es bestimmt, wie ein Programm skaliert, wenn es mit einem großen Datensatz gespeist wird.

Für Softwareentwickler ist es wichtig, in der Lage zu sein, solche Situationen vorherzusagen, da man nie weiß, ob jemand einen großen Beitrag leisten wird, der in der Entwicklungsphase nicht vorgesehen war.

LINQ ist auch flexibel genug, um diese beiden Szenarien nahtlos zu handhaben und eine hervorragende Effizienz zu erzielen, wenn Code verwendet wird, der ein „Streaming“ von Werten ermöglicht.

Dies bedeutet, dass nicht alles eine Liste oder ein T [] sein muss, was bedeutet, dass der gesamte Datensatz in den Speicher geladen wird. Durch die Verwendung von IEnumerable wird der Code generisch für Methoden, die den gesamten Datensatz im Speicher bereitstellen oder Werte im Streaming-Modus bereitstellen.