1. Wprowadzenie
Zanim zaczniemy używać nowej klasy, warto zrozumieć, po co ona w ogóle jest. Rozbijmy, co się dzieje, gdy pracujemy z plikiem „bezpośrednio” za pomocą FileStream.
Gdy wywołujesz Read lub Write na streamie utworzonym przez FileStream, w rzeczywistości następuje odwołanie do podsystemu dyskowego komputera. Sam proces (szczególnie na starych talerzowych dyskach, ale też na nowych SSD) jest znacznie wolniejszy niż operacje w pamięci RAM. Wyobraź sobie, że gdy zamawiasz frytki w McDonalds, kasjer za każdym razem biegnie na magazyn po nowy pakiet. Wyobraź sobie, jak długa zrobi się kolejka!
Jeśli pracujesz z małymi kawałkami danych, częste odwołania do dysku lub sieci prowadzą do utraty wydajności. Im większy wolumen danych — tym bardziej zauważalny efekt.
Krótkie porównanie
Wychodzi na to, że streamy bez bufora są jak jeżdżenie do sklepu 10 razy po jeden jogurt. Buforowany stream to wzięcie od razu całego koszyka jogurtów, zmniejszając liczbę podróży do minimum.
2. Klasa BufferedStream: pierwszy rzut oka
Do czego służy
BufferedStream to nakładka na dowolny stream (Stream), która przechowuje pośredni bufor w pamięci. Kiedy zapisujesz dane, trafiają one najpierw do bufora, i dopiero gdy bufor się zapełni — są zrzucane na dysk jedną większą operacją. Podobnie przy odczycie: przy pierwszym odczycie ładuje spory kawałek danych do pamięci, a potem zwraca po kawałku z pamięci, aż bufor się wyczerpie.
Przykład kodu: tworzenie BufferedStream
Zróbmy prosty przykład. Niech zadaniem będzie zapisanie 100 000 linii do pliku:
string filePath = "big_output.txt";
using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write);
using var bufferedStream = new BufferedStream(fileStream);
using var writer = new StreamWriter(bufferedStream);
for (int i = 0; i < 100_000; i++)
{
writer.WriteLine($"Struka numer {i}");
}
Console.WriteLine("Zapis zakończony!");
Komentarz:
- Otwieramy plik do zapisu przez FileStream.
- Następnie owijamy go w BufferedStream, a potem w StreamWriter (on pisze tekstowe linie do streamu).
- Gdy bufor się zapełni — dane są zrzucane na dysk jedną porcją.
3. Jak działa buforowanie „od środka”
Rozłóżmy to na schemacie:
[Twój kod] → [StreamWriter] → [BufferedStream] → [FileStream] → [Plik na dysku]
Gdy wywołujesz metodę WriteLine() u StreamWriter, tekst najpierw trafia do wewnętrznego bufora, potem przez BufferedStream — jeszcze do jednego bufora, a dopiero gdy bufor się zapełni lub stream zostanie zamknięty, dane zostają zrzucane na dysk.
Ile bajtów w jednym „wiadrze”?
Domyślny rozmiar bufora to 4096 bajtów (4 KB), ale można go podać ręcznie:
int myBufferSize = 16 * 1024; // 16 KB
using var fileStream = new FileStream(filePath, FileMode.Create);
using var bufferedStream = new BufferedStream(fileStream, myBufferSize);
// ...
Praktyczny tip: Na współczesnych systemach sensowne jest stosowanie buforów 8–64 KB. Dla bardzo dużych operacji plikowych — i większe. Ale nie przesadzaj: jeśli działasz na mikrokontrolerze z 128 KB RAM, to 64 KB bufora to zły pomysł :)
4. Eksperyment: porównajmy szybkość z buforem i bez
Aby zrozumieć, jak to ważne, napiszmy test, który porównuje zapis danych przez FileStream z buforem i bez:
using System.Diagnostics;
using System.Text;
string data = new string('X', 1000); // 1 000 znaków
void WriteWithoutBuffer()
{
using var fs = new FileStream("no_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: false);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
fs.Write(bytes, 0, bytes.Length); // Bezpośrednio do pliku – za każdym razem odwołanie do dysku
}
}
void WriteWithBuffer()
{
using var fs = new FileStream("with_buffer.txt", FileMode.Create, FileAccess.Write, FileShare.None);
using var bs = new BufferedStream(fs, 16 * 1024);
for (int i = 0; i < 10_000; i++)
{
byte[] bytes = Encoding.UTF8.GetBytes(data);
bs.Write(bytes, 0, bytes.Length);
}
}
// Odmierzamy czas
Stopwatch sw = Stopwatch.StartNew();
WriteWithoutBuffer();
sw.Stop();
Console.WriteLine("Bez bufora: " + sw.ElapsedMilliseconds + " ms");
sw.Restart();
WriteWithBuffer();
sw.Stop();
Console.WriteLine("Z buforem: " + sw.ElapsedMilliseconds + " ms");
Oczekiwany wynik:
W większości przypadków z buforem zobaczysz zauważalny wzrost prędkości! Szczególnie na HDD. Na SSD efekt też będzie widoczny, ale nie aż tak dramatyczny.
5. Jaki bufor wybrać? Porównanie i praktyka
W .NET jest wiele klas do buforowania. Ustalmy jasność:
| Klasa | Do czego używana | Bufor wbudowany? | Trzeba używać BufferedStream? |
|---|---|---|---|
|
Praca z plikami | Tak (4 KB) | Prawie nie trzeba (ale można) |
|
Praca z siecią | Nie | Bardzo wskazane |
|
Odczyt/zapis tekstu | Tak (od 1 KB) | Zazwyczaj nie trzeba |
|
Kompresja/dekompresja | Nie | Można/trzeba dla przyspieszenia |
Ważne:
FileStream z parametrem konstruktora bufferSize — w praktyce już jest buforowanym streamem. Jeśli sam ustawiłeś wystarczająco duży bufor, to dodatkowy BufferedStream nie da dużego zysku. Ale jeśli korzystasz z innego streamu (np. sieciowego), to BufferedStream będzie twoim przyjacielem.
6. Przykład: Kopiowanie pliku za pomocą BufferedStream
string source = "big_input.dat";
string dest = "big_output.dat";
int bufferSize = 64 * 1024; // 64 KB
using var inputStream = new FileStream(source, FileMode.Open, FileAccess.Read);
using var outputStream = new FileStream(dest, FileMode.Create, FileAccess.Write);
using var bufferedInput = new BufferedStream(inputStream, bufferSize);
using var bufferedOutput = new BufferedStream(outputStream, bufferSize);
byte[] buffer = new byte[bufferSize];
int bytesRead;
while ((bytesRead = bufferedInput.Read(buffer, 0, buffer.Length)) > 0)
{
bufferedOutput.Write(buffer, 0, bytesRead);
}
// Nie zapomnij o flush – inaczej ostatnie bajty nie trafią na dysk!
bufferedOutput.Flush();
Console.WriteLine("Kopiowanie zakończone!");
Komentarz:
- Czytamy dane dużymi blokami (64 KB) z jednego pliku przez BufferedStream.
- Zapisujemy je do drugiego pliku, też używając bufora.
- Po zakończeniu pętli koniecznie wywołujemy Flush(), żeby zapisać ostatnie dane.
7. Przydatne niuanse
Wskazówka: kiedy naprawdę potrzebny jest BufferedStream
- Gdy pracujesz ze streamami, które nie mają buforowania (np. network streamy lub customowe implementacje Stream);
- Gdy operujesz na dużych ilościach danych binarnych (np. kopiowanie plików, konwersje formatów, backupy);
- Gdy optymalizujesz istniejący kod i widzisz, że wąskim gardłem jest duża liczba małych operacji Write/Read.
Trochę o asynchroniczności i buforowaniu
Wraz z pojawieniem się operacji asynchronicznych (ReadAsync/WriteAsync) buforowanie nadal jest użyteczne, ale pamiętaj: jeśli używasz metod asynchronicznych na wierzchu bufora, obsługa dalej odbywa się w pamięci, a fizyczne I/O jest jeszcze bardziej zminimalizowane.
W .NET 8+ i .NET 9 buforowanie jest coraz bardziej wbudowane i większość klas domyślnie ma swoje bufory. Jednak dla kompatybilności z streamami sieciowymi lub własnymi implementacjami nadal warto ręcznie użyć BufferedStream.
Więcej o asynchroniczności poznasz na poziomie 58 :P
Wizualny schemat działania buforowanych streamów
flowchart LR
A[Twój kod] --> B[StreamReader/Writer]
B --> C[BufferedStream]
C --> D[FileStream]
D --> E[Plik/Urządzenie]
- A — Twój kod, który wywołuje Write/Read.
- B — Stream wysokiego poziomu (pracuje z tekstem lub danymi).
- C — Buforowanie (grupuje dane, by zwiększyć prędkość).
- D — Konkretna implementacja streamu (plik, sieć).
- E — Urządzenie fizyczne (HDD, SSD, sieć itp.).
Wskazówki i triki z praktyki
- Jeśli piszesz do pliku po jednej linii (np. logowanie), lepiej jawnie ustawić rozmiar bufora większy niż rozmiar jednej linii. To pozwoli szybciej zrzucać duże partie danych.
- Jeśli każda operacja musi być natychmiast zapisana (np. krytyczne logi), wywołuj Flush() po każdym zapisie. Ale to zmniejsza korzyści z buforowania!
- Jeśli tworzysz pliki tymczasowe, które zaraz po stworzeniu są usuwane, może nie być istotne, czy coś zostało w buforze — ale bądź ostrożny, jeśli ważne jest, żeby plik faktycznie został zapisany.
- Przy pracy z bardzo dużymi plikami (np. dziesiątki GB) śmiało zwiększaj rozmiar bufora do 1_048_576 bajtów (1 MB) i więcej — ważne, żeby było wystarczająco RAMu.
8. Typowe błędy i niuanse użycia
Jeśli poczułeś teraz zapał, żeby „wstawić bufor wszędzie” — nie śpiesz się. Wszystko z umiarem!
Jednym z częstych błędów jest zapominanie wywołać Flush() lub zamknąć stream, gdy to konieczne. Jeśli stream nie został zamknięty, a program zakończy się awaryjnie, ostatnie bajty mogą pozostać w buforze RAM i nie trafić na dysk. Na przykład, pisząc logi do pliku i program padnie, ostatni wpis może zniknąć.
BufferedStream sam w sobie nie „rozumie” końca twoich logicznych wiadomości — on po prostu czeka, aż uzbiera porcję danych odpowiedniego rozmiaru. Dlatego dla rzeczy krytycznych (logi, backupy itd.) lepiej okresowo wymuszać Flush():
bufferedStream.Flush(); // Wymusza zrzut bufora na dysk
Jeśli korzystasz ze StreamWriter, to on ma własny bufor! Czyli przy zagnieżdżonym użyciu buforowanie zachodzi dwukrotnie (i to nie zawsze jest dobre). Często wystarcza jeden poziom bufora, i jeśli używasz StreamWriter, dodatkowy BufferedStream może być zbędny.
GO TO FULL VERSION