1. Wprowadzenie
Wyobraź sobie, że jesteś dyrygentem orkiestry (twojej aplikacji). Jeśli za każdym razem, gdy skrzypek stroi swoją skrzypce (długi I/O), będziesz na niego czekać, zanim przejdziesz do innych instrumentów, cała orkiestra stanie. Ale jeśli skrzypek powie: "Zaraz się nastroję, a wy w międzyczasie grajcie, dam znać, kiedy będę gotowy", — to jest asynchroniczność!
W świecie C# i .NET 9 do takiej "wielozadaniowości bez zastygania" są specjalne narzędzia. Główni bohaterowie dzisiejszego dnia to asynchroniczne wersje metod Read i Write, które nazywają się ReadAsync i WriteAsync.
Pozwalają one zainicjować operację czytania lub zapisu i od razu "puścić" bieżący wątek wykonania, żeby mógł robić coś innego. Kiedy operacja I/O się skończy (np. dane zostaną odczytane z dysku lub zapisane na nim), twój kod "obudzi się" i będzie kontynuować pracę od miejsca, gdzie został zatrzymany.
Do użycia tych metod potrzebujemy dwóch magicznych słów, które pojawiły się w C# już w 2012 z wersją 5.0 (a teraz, w C# 14, są już jak u siebie!):
- async: To modyfikator, który dodajesz do metody, żeby powiedzieć kompilatorowi: "W środku tej metody mogą być operacje asynchroniczne i będę używać await".
- await: To operator, którego używasz przed wywołaniem operacji asynchronicznej (jak ReadAsync lub WriteAsync). Oznacza: "Uruchom tę operację, ale tu jej nie czekaj. Oddaj kontrolę wywołującemu i wróć, gdy operacja będzie gotowa".
Nie martw się, jeśli te pojęcia wydają się na razie trochę mgliste. Będziemy mieć osobny poziom poświęcony async i await (Poziom 58), gdzie rozbijemy je na części pierwsze. Teraz ważne jest, żeby zrozumieć, że pomagają nam "nie blokować" głównego wątku.
2. ReadAsync: czytamy bez pośpiechu
Metoda ReadAsync pozwala asynchronicznie czytać dane ze strumienia. Zamiast czekać, aż bajty zostaną odczytane z dysku, zaczynasz czytanie i od razu przełączasz się na inne zadania.
Oto jak wygląda jej podstawowa sygnatura dla odczytu do bufora:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Albo, co częściej używane w nowoczesnym C# (i .NET 9), z użyciem Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Rozbierzmy parametry:
- buffer: To tablica byte (lub Memory<byte>), do której zostaną odczytane dane. Pamiętaj, mówiliśmy o buforach dla optymalizacji? Tutaj są używane dokładnie tak samo, ale już dla operacji asynchronicznych.
- offset: Przesunięcie w buffer, od którego zacząć zapis odczytanych bajtów.
- count: Maksymalna liczba bajtów do odczytania.
- CancellationToken cancellationToken: To bardzo przydatny parametr, pozwalający anulować operację, jeśli nie jest już potrzebna (np. użytkownik zamknął aplikację lub kliknął przycisk "Anuluj").
- ValueTask<int>: To "obietnica", że kiedy operacja się zakończy, zwróci wartość całkowitą (int), która będzie równa liczbie odczytanych bajtów. ValueTask — zoptymalizowana wersja Task dla scenariuszy, gdzie wynik może być dostępny synchronicznie lub asynchronicznie.
Przykład 1: Asynchroniczne czytanie pliku
Załóżmy, że mamy duży plik tekstowy i chcemy go czytać nie blokując głównego wątku. Oto podstawowy przykład:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Asynchroniczna funkcja do czytania pliku i zliczania linii
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Asynchroniczne otwarcie pliku
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"W pliku {filename} linii: {count}");
}
}
Komentarze do kodu:
- Zwróć uwagę na parametr useAsync: true w FileStream. To ważne dla prawdziwej asynchroniczności.
- Używamy await z ReadLineAsync, żeby nie blokować wątku, dopóki linia jest czytana.
- Metoda Main jest teraz asynchroniczna (C# 7+ na to pozwala).
Gdyby to była prawdziwa aplikacja graficzna, to podczas czytania pliku (gdy ReadAsync czeka na dane z dysku) użytkownik mógłby klikać przyciski, przewijać i wykonywać inne akcje, bo główny wątek UI nie byłby zablokowany. W aplikacji konsolowej jest to mniej widoczne, ale zasada jest ta sama.
3. WriteAsync: zapisujemy bez zwłoki
Analogicznie do ReadAsync, metoda WriteAsync pozwala asynchronicznie zapisywać dane do strumienia. To bardzo przydatne, gdy trzeba zapisać dużo danych, nie zmuszając aplikacji do czekania na zapis na dysk.
Główne sygnatury:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
I z użyciem ReadOnlyMemory<byte> (do zapisu nie modyfikujemy bufora):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Parametry podobne do ReadAsync:
- buffer: Tablica byte (lub ReadOnlyMemory<byte>), zawierająca dane do zapisu.
- offset: Przesunięcie w buffer, skąd zacząć czytać dane do zapisu.
- count: Liczba bajtów do zapisania.
- CancellationToken cancellationToken: Do anulowania operacji.
- ValueTask: Nie zwraca wartości, bo liczba zapisywanych bajtów jest podana przez parametr count.
Przykład 2: Asynchroniczny zapis do pliku
A teraz zapiszmy coś do pliku — też asynchronicznie.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Linia numer {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Zapis {filename} zakończony.");
}
}
Tutaj pętla zapisuje 10000 linii, a główny wątek nie jest zablokowany: gdyby to była aplikacja GUI, interfejs by się nie "zawiesił".
Dzięki async i await, nasza aplikacja konsolowa może teraz wykonywać operację kopiowania pliku, pozostając jednocześnie "responsywną" na wejście użytkownika (w naszym przypadku — na naciśnięcie Enter by anulować). To podstawowa zasada pisania nowoczesnych, wydajnych i responsywnych aplikacji w C#.
4. Przydatne niuanse
Wizualizacja: jak działa asynchroniczne czytanie/zapisywanie
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Twój kod (UI/logika)│ ─────────────────────→ │ OS/E/S: operacja asynchroniczna │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(robi coś innego) │(czyta plik, czeka na dysk)
│<────────────────────────────────────────→│
└─ Czeka na Task, dostaje wynik ←──────────┘
Przybliżony schemat: dopóki dysk wolno pracuje, kod może robić coś innego. Dopiero gdy dane będą potrzebne — wykonanie czeka na Task.
Praktyczne zastosowania: gdzie to naprawdę potrzebne?
- Aplikacje desktopowe: jeśli twoja aplikacja czyta lub zapisuje coś dużego (logi, bazy danych, wideo), asynchroniczność to must-have. Nawet jeśli twój komputer jest "mocarny", użytkownik może otwierać pliki przez sieć, a tam prędkość może być jak u żółwia na przerwie obiadowej.
- Backend lub aplikacje webowe: jednocześnie do serwera może pukąć kilkadziesiąt (a nawet tysiące) użytkowników. Jeśli każdy wątek będzie blokowany na czytaniu plików — powoduje to opóźnienia i 502 Bad Gateway.
- Aplikacje mobilne: jeśli otwarcie pliku lub zapis zajmuje czas — użytkownik zobaczy laga. Używamy asynchroniczności!
- Każde masowe przetwarzanie plików: aplikacje pracujące na kolekcjach plików (archiwizatory, parsery, analizatory) zyskują na asynchronicznym I/O.
Synchroniczne vs Asynchroniczne czytanie/zapisywanie
| Metoda | Blokuje wątek? | Łatwe w implementacji? | Lepsza wydajność? | Wygoda dla UI/serwerów |
|---|---|---|---|---|
| Synchroniczne (Read/Write) | Tak | Tak | Nie | Nie |
| Asynchroniczne (ReadAsync) | Nie | Prawie | Tak | Tak |
5. Niuanse i najlepsze praktyki
Buforowanie nadal ważne: Nawet z ReadAsync i WriteAsync, czytanie lub zapisywanie po jednym bajcie nadal jest skrajnie nieefektywne. Asynchroniczność usuwa blokowanie, ale nie czyni cudów i nie przyspiesza każdego pojedynczego bajta. Dobre początkowe rozmiary bufora to 4096-8192 bajtów, a dla dużych plików warto spróbować 65536 lub 131072.
"Asynchroniczność powinna być wszędzie" (Async All The Way Down): Jeśli zacząłeś używać async/await w jednym miejscu, zazwyczaj warto pociągnąć ten model przez cały łańcuch wywołań: C wykonuje asynchroniczną operację — więc C jest async Task, B też async Task, a A — async Task. W przeciwnym razie mogą pojawić się blokady i nawet deadlocki w aplikacjach UI.
Obsługa wyjątków: W asynchronicznym kodzie używaj zwykłych try-catch. Często spotykane są OperationCanceledException i IOException — obsługuj je jawnie.
Uwalnianie zasobów (await using): Dla strumieni i innych IDisposable-obiektów zwalniaj zasoby poprawnie. Jeśli typ implementuje IAsyncDisposable, to await using wywoła DisposeAsync(); jeśli tylko IDisposable — zostanie wywołany Dispose().
Co się dzieje pod maską (krótko): Przy await kompilator zamienia metodę w automat stanów: operacja jest uruchamiana, metoda "zawieszana", kontrola zwracana jest wywołującemu. Po gotowości wyniku SynchronizationContext (w UI) lub ThreadPool (w konsoli/na serwerze) wznowi wykonanie od miejsca zatrzymania. To pozwala jednemu wątkowi obsługiwać wiele "zawieszonych" zadań bez blokowania.
Podsumowując, programowanie asynchroniczne z async i await to potężne narzędzie do tworzenia responsywnych i skalowalnych aplikacji. Pozwala twojemu kodowi efektywnie używać zasobów systemowych, nie blokując interfejsu użytkownika ani wątków na serwerze. Może na początku wydawać się to nieco nietypowe, ale naprawdę warto się tego nauczyć! W kolejnych wykładach będziemy dalej zgłębiać świat asynchroniczności i równoległości. Do zobaczenia!
GO TO FULL VERSION