O strumieniach w .NET

Strumienie w .NET dostarczają warstwę abstrakcji nad ciągami bajtów. Spotykamy się z nimi między innymi przy operacjach I/O, choć nie tylko. Krótki research stackoverflow pod kątem najwyżej ocenianych pytań pod tagami stream i .net prowadzi do raczej przewidywalnych wniosków. Większość naszej pracy ze strumieniami to konwersja z i do stringów, tablicy bajtów, plików itd. Co więcej powinniśmy wiedzieć i kiedy możemy poprawić wydajność swojego kodu dzięki strumieniom ? O tym poniżej.

Podstawy

Klasą bazową definiującą wspólny interfejs dla wszystkich strumieni jest abstrakcyjna klasa Stream. Dostarcza ona wspólne metody i propercje dla klas takich jak: FileStream, NetworkStream, czy PrintQueueStream, reprezentujących operacje na konkretnych źródłach danych.

Klasy takie potrafią komunikować się ze swoimi źródłami danych i co ważne, utworzenie instancji nie pobiera od razu danych do pamięci. Klasy z rodziny Stream pozwalają na ustawienie pozycji, od której chcemy czytać / zapisywać dane. Zarówno odczyt jak i zapis może odbywać się porcjami, bajt po bajcie.

Od tego, w jaki sposób tworzymy strumień zależy, czy możemy wykonywać na nim wszystkie wspomniane operacje.

using (var fs = new FileStream("new-file.txt", FileMode.Append))
    Console.WriteLine(fs.CanRead); //false

using (var fs = new FileStream("sample.txt", FileMode.Open, FileAccess.Read))
    Console.WriteLine(fs.CanWrite); //false

WebRequest request = WebRequest.Create("https://mickl.net");
WebResponse response = request.GetResponse();
using (var rs = response.GetResponseStream())
    Console.WriteLine(rs.CanSeek); //false

Pozycję względem początku strumienia można ustawiać poprzez property Position lub wykonując metodę Seek, która dodatkowo pozwala na przesunięcia względem obecnej pozycji i względem końca strumienia. Watro pamiętać, że czytanie przesuwa pozycję, a zatem jeśli chcemy przeczytać strumień dwa razy, to konieczne jest wyzerowanie Position przed drugim odczytem.

Dotnet wcale nie skazuje nas na pracę ze strumieniami. Przy wielu operacjach I/O mamy dostępne API na znacznie wyższym poziomie abstrakcji. Pliki możemy czytać w jednej linii kodu za pomocą klasy System.IO.File, żądania HTTP wykonywać przy użyciu System.Net.Http.HttpClient i w wielu sytuacjach to całkowicie wystarcza. Mając jednak świadomość tego, jak działają strumienie możemy w niektórych sytuacjach pisać kod znacznie wydajniejszy niż przy użyciu wspomnianych klas.

Zwalnianie zasobów

Klasa Stream implementuje IDisposable, co sugeruje, że możemy mieć do czynienia z niezarządzanymi zasobami. Przykładowo podczas pracy z systemem plików operujemy na uchwytach do plików blokując zapis innym wątkom i procesom. Zasoby takie są zwalniane w metodzie Dispose, dlatego ZAWSZE należy korzystać z usinga, a kod wewnątrz niego powinien ograniczać się do niezbędnych operacji. Przykład bardzo źle napisanej metody:

private static long ReadFileLength()
{
    var fs = new FileStream("sample.txt", FileMode.Open);
    return fs.Length;
}

Plik taki będzie niedostępny do zapisu aż do przejścia Garbage Collectora.

var length = ReadFileLength();
TryWrite(); //IOException
GC.Collect();
TryWrite(); //ok

 Buforowanie

Operacje I/O bywają powolne. Zapis do pliku lub socketu bajt po bajcie nie ma większego sensu. Aby uzyskać jak najlepszą wydajność, strumienie w .NET są buforowane w pamięci i operacja I/O wykonuje się dopiero po przepełnieniu takiego bufora. Dla pracy z systemem plików domyślna długość bufora to 4096 bajtów, choć możemy ją zmienić wykorzystując jeden z konstruktorów FileStream.

var bufferLength = 4096;
var bytes = new byte[bufferLength * 3 + 1];
var ticks = new long[bufferLength * 3 + 1];
var rng = new RNGCryptoServiceProvider();
rng.GetBytes(bytes);
var sw = new Stopwatch();

using (var fs = new FileStream("newfile.dat", FileMode.Create))
{                
    for(var i = 0; i < bytes.Length; i++)
    {
        sw.Restart();
        fs.WriteByte(bytes[i]);
        sw.Stop();
        ticks[i] = sw.ElapsedTicks;
    }
}

var maxTicks = ticks.Select((t, i) => new { t, i })
    .OrderByDescending(p => p.t).ToList();

Dla kodu powyżej z dużym prawdopodobieństwem w top 5 najdłużej trwających operacji będą te, które znajdują się pod indeksami będącymi wielokrotnościami 4096.

ab

Analogiczny test można wykonać dla operacji odczytu. MSDN zaleca, by w celu uzyskania najwyższej wydajności utrzymywać wielkość bufora pomiędzy 4 i 8 KB. Bufor możemy również “zrzucać” jawnie używając metody Flush.

Stream Decorators

Specjalny zestaw strumieni to dekoratory. Są to klasy, które transformują jeden strumień w inny. Dekoratory widnieją w kodzie jako serie zagnieżdżonych usingów. Przykładem operacji dekorujących na strumieniu jest kompresja.

using (FileStream f2 = new FileStream("output.gzip", FileMode.Create))
using (GZipStream gz = new GZipStream(f2, CompressionMode.Compress, false))
{
    gz.Write(bytes, 0, bytes.Length);
}

Wydajna praca z XML

Jak już wspomniano, dane ze strumieni czytamy porcjami, co można dobrze wykorzystać przy pracy z dużymi plikami XML. Czytanie danych za pomocą klasy XmlReader jest znacznie wydajniejsze, niż np. XDocument i LINQ to XML lub ewaluacja XPatha. Zysk numer jeden to mniejsze zużycie pamięci. Załadowanie przez XDocument przykładowego, znalezionego w sieci pliku XML, który na dysku zajmuje 2MB powoduje wzrost zużytej pamięci o około 4MB.

var memorySnapshot = GC.GetTotalMemory(false);
var xDoc = XDocument.Load("large.xml");
var xDocMemory = GC.GetTotalMemory(false) - memorySnapshot;

Dodatkowy zysk otrzymamy, jeżeli nie interesuje nas cały dokument, a chcemy tylko przeczytać jakąś jego część lub dopisać coś na koniec.

Chcąc w 2 megabajtowym dokumencie znaleźć node znajdujący się mniej więcej w połowie możemy porównać dwa sposoby:

// 1 sposób
var xDoc = XDocument.Load("large.xml");
var xPathResult = xDoc.XPathEvaluate("(//T//PS_PARTKEY[text()='1000'])[1]");

// 2 sposób
XElement xElementResult = null;
using(var fs = new FileStream("large.xml", FileMode.Open))
{
    using(var reader = System.Xml.XmlReader.Create(fs))
    {
        while (reader.Read())
        {
            if (reader.NodeType == XmlNodeType.Element)
            {
                if (reader.Name == "PS_PARTKEY")
                {
                    XElement el = XNode.ReadFrom(reader) as XElement;
                    if(el.Value == "1000")
                    {
                        xElementResult = el;
                        break;
                    }
                }
            }
        }
    }
}

Użycie strumieni i XmlReadera jest dla tego przykładu średnio około 5 razy szybsze i wprowadza znacznie mniejsze zużycie pamięci pamięci (~200KB vs wspomniane 4MB).

Wniosek jest zatem prosty. Kod wykonujący operacje na dużych źródłach danych (XML, JSON, system plików itd) można mocno zoptymalizować przez umiejętne wykorzystanie strumieni. W połączeniu z możliwością wykonywania tych operacji w sposób asynchroniczny (metody ReadAsync, WriteAsync) mamy do dyspozycji całkiem potężne narzędzie.

3 thoughts on “O strumieniach w .NET

    • Propercja to chyba jakiś slang podsłuchany u któregoś z kolegów programistów. Dobrze wiedzieć, że czytelnicy walidują teksty na sjp 🙂

      Co do wpisu to fakt, że może wyszło z tego dużo luźno powiązanych ze sobą wątków, ale jeśli ktoś czytając dowie się czegoś nowego, to zakładam, że warto było spróbować.

      Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s