1. Wprowadzenie
W świecie programowania asynchronicznego żyjemy zgodnie z zasadą "delegowania z informacją zwrotną". Wyobraź sobie: musisz pobrać ogromny plik, przeanalizować gigabajty logów lub wysłać żądanie do odległego serwera. Zamiast stać w oczekiwaniu jak posąg, mówimy systemowi: "Zajmij się tym, a ja tymczasem popracuję nad czymś innym. Jak skończysz — koniecznie daj znać!"
I właśnie tutaj na scenę wchodzi Task — eleganckie ucieleśnienie takiego podejścia. To nie tylko techniczna abstrakcja, to coś w rodzaju "sprytnego pośrednika", który bierze na siebie odpowiedzialność za wykonanie pracy i gwarantuje, że wynik nie zaginie w cyfrowej próżni.
Task działa jak osobisty asystent, któremu zlecasz ważne zadanie. On kiwa, zapisuje zadanie w swoim notesie i mówi: "Idź spokojnie robić swoje, ja dam znać, kiedy wszystko będzie gotowe". I faktycznie daje znać — z wynikiem w ręku albo z uczciwym wyjaśnieniem, dlaczego coś poszło nie tak.
Jeśli szukać bardziej codziennej analogii, to Task przypomina nowoczesny system rejestracji: rezerwujesz wizytę online, dostajesz potwierdzenie i nie musisz przesiedzieć godzin w kolejce. System sam przypomni o zbliżającej się wizycie, a ty w międzyczasie możesz normalnie żyć.
Klasa Task
Task — to podstawowy budulec programowania asynchronicznego w .NET. Reprezentuje uruchomioną lub przyszłą operację, której wynik będzie dostępny w przyszłości. Jeśli metoda nic nie ma zwracać, używamy po prostu Task.
public async Task BackupToCloudAsync()
{
// Robi magię backupu, nic nie zwraca
}
Klasa Task<TResult>
Jeśli trzeba zwrócić wynik (np. string, liczbę, obiekt…), używamy Task<TResult>:
public async Task<string> DownloadHtmlAsync(string url)
{
// Pobiera stronę i zwraca HTML
return "<html>...</html>";
}
Dlaczego Task, a nie Thread?
Thread zarządza samym wątkiem (to ciężkie i niebezpieczne), a Task — to wyższy poziom abstrakcji: może wykonywać się w puli wątków, może działać asynchronicznie bez tworzenia nowego wątku (np. przy operacjach I/O) i nie wymaga od ciebie martwienia się o niskopoziomowe szczegóły.
Klasa Task pozwala opisać: "Chcę uruchomić to działanie", a jak dokładnie zostanie wykonane — niech zdecyduje .NET!
2. Budowa obiektu Task
Właściwości i metody Task, które warto znać
| Właściwość / Metoda | Opis |
|---|---|
|
Bieżący stan zadania |
|
Wynik dla Task<TResult> (blokuje wątek) |
|
Czy zadanie zostało zakończone |
|
Czy w zadaniu wystąpił wyjątek |
|
Czy zadanie zostało anulowane |
|
Blokuje bieżący wątek do zakończenia (niebezpieczne) |
|
Uruchamia kolejne zadanie po zakończeniu |
|
Dostęp do wyjątku, jeśli Task zakończył się błędem |
|
Unikalny identyfikator zadania |
Jak działa metoda asynchroniczna zwracająca Task
sequenceDiagram
participant Main as Main Wątek
participant Task as Task (Zadanie w tle)
Main->>Task: Uruchomienie Task.Run(() => ...)
Note right of Task: Wykonywanie w tle
(CPU lub I/O)
alt Zadanie zakończone
Task->>Main: await zakończył się, kontynuujemy dalej
else Błąd
Task->>Main: await rzuca wyjątek
end
3. Tworzenie i uruchamianie zadań: jak działa Task
Metody asynchroniczne z async
Najczęstszy przypadek — po prostu deklarujesz metodę jako async i zwracasz albo Task, albo Task<TResult> (jak pokazaliśmy wcześniej).
Task.Run: wykonanie w puli wątków
Jeśli potrzebujesz wykonać jakąś ciężką pracę w tle (np. skomplikowane obliczenia albo kodowanie wideo), możesz użyć Task.Run:
Task work = Task.Run(() =>
{
// Ciężkie obliczenia — nie blokujemy głównego wątku!
Console.WriteLine("Obliczenia w tle rozpoczęte...");
Thread.Sleep(2000); // Emulacja długiej pracy
Console.WriteLine("Obliczenia w tle zakończone!");
});
Jeśli przydatne jest otrzymanie wyniku:
Task<int> calculateTask = Task.Run(() =>
{
// Na przykład sumowanie pierwszych 100 liczb
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
});
Task.Factory.StartNew
To bardziej niskopoziomowy i elastyczny sposób, pozwala precyzyjnie konfigurować uruchomienie zadania (np. wskazać scheduler, przekazać parametry itp.). W nowoczesnym kodzie prawie zawsze zaleca się używać Task.Run, bo jest prostszy i chroni przed błędami.
4. Aplikacja dnia: nasz katalog książek
Załóżmy, że mamy aplikację katalogu książek i musimy dodać funkcję ładowania książek ze "źródła w chmurze" — to będzie operacja I/O-bound (wolne żądanie HTTP lub czytanie z pliku).
Dodajmy metodę, która asynchronicznie "ładuje" książki (emulujemy opóźnienie):
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
}
public class BookCatalog
{
public List<Book> Books { get; set; } = new();
public async Task LoadBooksAsync()
{
Console.WriteLine("Ładowanie książek...");
await Task.Delay(2000); // Emulacja długiego ładowania (np. HTTP lub plik)
Books = new List<Book>
{
new Book { Title = "CLR via C#", Author = "Jeffrey Richter" },
new Book { Title = "C# in Depth", Author = "Jon Skeet" }
};
Console.WriteLine("Książki pomyślnie załadowane.");
}
}
W Main wywołamy asynchroniczne ładowanie (używając await):
var catalog = new BookCatalog();
await catalog.LoadBooksAsync();
Console.WriteLine($"W katalogu {catalog.Books.Count} książek.");
Tabela: Główne sposoby tworzenia i uruchamiania Task
| Metoda tworzenia | Jak stosować | Wynik | Zastosowanie |
|---|---|---|---|
| async-metoda | |
Operacja asynchroniczna | Zazwyczaj I/O, wygoda |
|
|
Zadanie w tle | CPU-bound (obliczenia) |
|
Ręcznie tworzymy i kończymy Task | Pełna kontrola programisty | Rzadko, do niskopoziomowych rzeczy |
5. Cykl życia Task
Task może być w różnych stanach:
- Created — zadanie utworzone, ale nie uruchomione (dla Task z wyraźnym uruchomieniem).
- WaitingToRun — czeka w kolejce w puli.
- Running — wykonywane.
- WaitingForActivation — czeka na uruchomienie lub zewnętrzną aktywację.
- RanToCompletion — pomyślnie zakończone.
- Faulted — zakończone z błędem (wyjątkiem).
- Canceled — anulowane (jeśli obsługiwana jest anulacja).
Diagram
flowchart LR
Start -->|Uruchomienie zadania| Running
Running -->|Powodzenie| Completed
Running -->|Błąd| Faulted
Running -->|Anulowanie| Canceled
Sprawdźmy to w praktyce
Task task = Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine(task.Status); // Zwykle: Running albo WaitingToRun
await task;
Console.WriteLine(task.Status); // RanToCompletion po zakończeniu
6. Jak uzyskać wynik z Task<TResult>?
Task<TResult> — to opakowanie nad wynikiem, który pojawi się w przyszłości. Gdy trzeba poczekać na wynik, używamy await:
Task<int> sumTask = Task.Run(() =>
{
int sum = 0;
for (int i = 1; i <= 5; i++) sum += i;
return sum;
});
int result = await sumTask;
Console.WriteLine(result); // 15
Jeśli zapomnisz napisać await, otrzymasz Task (obietnicę), a nie wynik. To typowa "asynchroniczna pułapka".
Alternatywa: Synchroniczne pobranie wyniku (NIE RÓB TEGO W UI!)
Czasami (np. w testach) trzeba dostać wynik bez await. Można użyć właściwości .Result:
int result = sumTask.Result;
Ale jeśli Task jeszcze się nie zakończył, ten kod blokuje wątek, i jeśli to wątek UI, aplikacja zamarznie! Dlatego: lepiej zawsze preferować await.
Typowe błędy z Task i Task<TResult>
Zapomniano zwrócić Task, metoda stała się void. Jeżeli metoda nie ma zwracanego wyniku — zwracaj Task, nie void, bo inaczej nie będzie można obsłużyć błędu.
Ignorowanie await. Po prostu wywołano metodę, nie czekając na wynik, i zadanie zaczyna żyć własnym życiem ("fire and forget"). Nie dowiesz się, kiedy się skończy ani czy upadło z błędem.
Blokujące oczekiwanie przez .Result lub .Wait(). Łatwo otrzymać deadlock, szczególnie w UI i ASP.NET. Używaj tylko await.
7. Zaawansowane możliwości Task
Łańcuch zadań: ContinueWith
Można "podczepiać" akcje, które trzeba wykonać po zakończeniu zadania, przez ContinueWith:
Task.Run(() => 10)
.ContinueWith(t =>
{
Console.WriteLine($"Wykonano! Wynik: {t.Result}");
});
Ale w nowoczesnym C# zwykle robi się to za pomocą async/await — tak łatwiej czytać.
Przykład: Równoległe i sekwencyjne ładowanie danych
Załóżmy, że musisz pobrać dwie książki z różnych źródeł. Możesz uruchomić oba Task równolegle i poczekać na oba:
public async Task LoadBooksFromMultipleSourcesAsync()
{
Task<List<Book>> t1 = LoadFromCloudAsync();
Task<List<Book>> t2 = LoadFromLocalAsync();
// Czekamy na oba zadania równolegle
await Task.WhenAll(t1, t2);
// Łączymy wyniki
Books = t1.Result.Concat(t2.Result).ToList();
}
private async Task<List<Book>> LoadFromCloudAsync()
{
await Task.Delay(2000); // "Chmura"
return new List<Book> { new Book { Title = "Cloud Book", Author = "Cloud Author" } };
}
private async Task<List<Book>> LoadFromLocalAsync()
{
await Task.Delay(1000); // "Dysk lokalny"
return new List<Book> { new Book { Title = "Local Book", Author = "Local Author" } };
}
Zwróć uwagę: dzięki await Task.WhenAll(...) oba zapytania startują jednocześnie i wykonują się równolegle (jeśli to możliwe), a aplikacja czeka, aż oba się zakończą.
8. Przydatne niuanse
Task i Fire-and-forget
Czasem chcesz uruchomić zadanie i nie czekać, aż się skończy (np. wysłać logi do chmury albo "podpiec tosta", podczas gdy użytkownik pracuje):
async void LogToCloudAsync(string message)
{
await Task.Run(() =>
{
// Długie wysyłanie logu
Thread.Sleep(1000);
Console.WriteLine($"Log wysłany: {message}");
});
}
Ale pamiętaj: jeśli w takim zadaniu wystąpi błąd — trudno będzie się o tym dowiedzieć. Dlatego jeśli to możliwe, zwracaj Task i przynajmniej loguj wyjątki wewnątrz!
Task i Task<TResult> w realnym świecie
- W aplikacjach klienckich UWP/WPF/WinForms nie blokuj UI — używaj Task do długich operacji (pliki, sieć).
- W WebAPI/ASP.NET Task pomaga nie marnować wątków na czekanie na sieć/DB, poprawiając wydajność.
- Organizuj wykonanie "równoległe": jednocześnie pobierz, przetwórz i zapisz.
- Prawie wszystkie długie metody mają wariant Async: File.ReadAllTextAsync, HttpClient.GetStringAsync i inne.
FAQ i niespodzianki
Pytanie: Dlaczego Task czasem wykonuje się synchronicznie?
Odpowiedź: Jeśli operacja jest już zakończona (np. wynik jest zcache'owany), kompilator i/lub scheduler może zakończyć metodę na tym samym wątku synchronicznie. To normalne i przyspiesza powtórne wywołania.
Pytanie: Dlaczego nie używać async void?
Odpowiedź: Taka metoda nie może być "czekana", nie da się złapać jej błędów ani śledzić zakończenia. Używaj Task, a async void — tylko dla EventHandler (np. Button_Click).
Pytanie: Czy można uruchomić kilka zadań i czekać tylko na jedno?
Odpowiedź: Tak — użyj Task.WhenAny.
GO TO FULL VERSION