1. Wprowadzenie
Nadszedł czas, żeby zrozumieć — czym zasadniczo różnią się Task i Thread? Dlaczego C# od wielu lat rekomenduje używanie Task zamiast bezpośredniego zarządzania wątkami? W jakich sytuacjach można dalej używać wątków ręcznie, a kiedy — wystarczą (i trzeba) zadania?
Jeśli czujesz, że słowa "wątki" i "zadania" zaczynają się trochę mieszać gdzieś w ciemnym kącie twojej świadomości i serce bije szybciej — nie martw się, nie jesteś sam. Nawet doświadczeni programiści czasem się mylą, gdy mowa o paralelizmie i asynchroniczności.
Ułóżmy to wszystko na półkach. Zaczynamy!
Krótkie tło powstania Task
W dawnych dobrych czasach (przed .span class="code text-user">.NET 4.0) jedynym oczywistym sposobem wykonywania kodu równolegle lub "w tle" było tworzenie nowego wątku. Na przykład, new Thread(() => { ... }).Start(); Wątki są fajne ze względu na prostotę. Ale są też straszne, bo wszystko jest na twoich barkach. Alokacja zasobów, lifecycle, obsługa wyjątków, synchronizacja, monitoring, skalowalność – to wszystko leży po stronie developera. A w programowaniu lubimy odrobinę lenistwa!
Wszystko zmieniło się z nadejściem zadań — Task — z przestrzeni nazw System.Threading.Tasks.Task. Zadanie to nie wątek. To bardziej abstrakcyjne i elastyczne pojęcie. Opisuje pracę, którą trzeba wykonać kiedyś w przyszłości, być może równolegle.
2. Thread — "Goły wątek"
Wątek — to niskopoziomowa jednostka wykonania, reprezentująca przydzielony fragment zasobów systemu operacyjnego (własny stos, kontekst wykonania itp.). Jeśli tworzysz wątek ręcznie, odpowiadasz za jego uruchomienie, zakończenie i wszystkie szczegóły jego życia.
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(() => {
Console.WriteLine("Witaj z wątku!");
});
thread.Start();
thread.Join(); // Czekamy na zakończenie wątku
}
}
- Tu stworzyliśmy wątek, który wykonuje lambdę na swoim stosie.
- Po uruchomieniu wątku wywołujemy Join(), żeby poczekać na zakończenie jego pracy.
W czym haczyk?
- Każdy wątek zajmuje pamięć (stos, około 1 MB).
- W .NET nie zaleca się tworzyć tysięcy wątków ręcznie — system będzie cierpieć.
- Jeśli zapomnisz wywołać Join(), główny wątek może zakończyć się wcześniej niż podrzędny i program "urwie się".
- Wyjątki wewnątrz wątku nie wyjdą na zewnątrz — trzeba je łapać specjalnie!
- Jeśli uruchomisz wątek — nie da się go "ładnie" anulować (nie ma metody Stop()!).
3. Task — "Zadania nowej generacji"
Task — to inteligentniejsza abstrakcja, która reprezentuje "pracę, która kiedyś zostanie wykonana". Pod maską zadania wykonują się na pulach wątków ThreadPool, co jest dużo bardziej efektywne niż nadmierne tworzenie wątków. Nie zarządzasz ich tworzeniem ręcznie, pool robi to za ciebie, skalując liczbę wątków zgodnie z obciążeniem.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task task = Task.Run(() =>
{
Console.WriteLine("Witaj z Task!");
});
await task; // Czekamy na zakończenie zadania
}
}
- Tu zadanie nie gwarantuje uruchomienia w osobnym wątku, ale zwykle będzie działać na wątku z puli.
- Możesz oczekiwać zakończenia zadania w znany sposób (await w metodzie async lub task.Wait() w synchronicznym kodzie).
4. Czym różnią się Task i Thread?
Rozłóżmy na czynniki pierwsze, czym się różnią, do czego ich używać i jakie są (nieoczywiste) pułapki.
| Thread | Task | |
|---|---|---|
| Abstrakcja | Wątek OS | Praca/Zadanie (abstrakcja, która może użyć wątku) |
| Uruchomienie | Przez new Thread(...).Start() | Przez Task.Run(...), Task.Factory.StartNew(...), async-metody |
| Bezpośrednie sterowanie | Tak (start, Join, priorytet itp.) | Nie, zarządzanie przejmuje .NET |
| ThreadPool | Nie, wątek jest zawsze nowy | Tak, zwykle używa ThreadPool |
| Zarządzanie zasobami | Przydzielany własny stos | Zasoby są ponownie używane przez pool |
| Skalowalność | Słabo: nieefektywne dla 1000+ wątków | Świetnie: tysiące zadań = ok |
| Interakcja | Osobny wątek z perspektywy OS | Może być kontynuacją bieżącego wątku, może działać na ThreadPool |
| Wyjątki | Wymaga jawnego przechwycenia, inaczej mogą "zniknąć" | Wyjątki są przechowywane w Task; można je złapać przy await lub .Wait() |
| Anulowanie | Brak standardowego sposobu | Tak, wsparcie przez CancellationToken |
| Wyniki pracy | Czekać przez Join() | await, .Wait(), .Result |
| Używać do | Specjalne przypadki — UI threads, long-lived wątki | Prawie wszystkie zadania w tle/paralelne |
5. Kiedy czego używać?
Kiedy używać Thread?
Szczerze mówiąc, w nowoczesnym kodzie .NET ręczne tworzenie wątków jest rzadko potrzebne. Oto przykłady, kiedy to uzasadnione:
- Trzeba stworzyć wątek, który będzie działał bardzo długo (np. serializacja sygnału w eterze, albo obsługa danych z hardware) i jest "specjalny": niski priorytet, oddzielna kultura wykonania, osobna nazwa.
- Czasami dla integracji z niskopoziomowymi API, które wymagają ręcznego sterowania wątkami.
- Bardzo specyficzne przypadki, jak custom task schedulers.
We wszystkich innych przypadkach — Task będzie bardziej poprawnym i nowoczesnym wyborem.
Kiedy używać Task?
Prawie zawsze, gdy trzeba wykonać pracę "w tle" lub "równolegle":
- Dowolne obliczenia w tle, które można uruchomić na puli wątków (np. obsługa żądania na serwerze, parsowanie pliku, wysyłka maili).
- Uruchamianie operacji asynchronicznych (async/await) — mechanizm zwraca Task lub Task<T>.
- Łączenie zadań, obsługa continuation, praca z łańcuchami.
- Łatwe anulowanie, oczekiwanie i zbieranie wyników: Task wspiera CancellationToken, łatwo integruje się z nowoczesnymi API.
- Asynchroniczne operacje I/O: żądania sieciowe, praca z plikami, bazy danych.
Porównanie
| Scenariusz | Thread | Task |
|---|---|---|
| Long-lived wątek (np. własny serwis) | Tak | Nie |
| Masowe wykonywanie krótkich zadań | Nie | Tak |
| Asynchroniczne I/O (await) | Nie | Tak |
| Łączenie, anulowanie, łańcuchy zadań | Nie | Tak |
| Dopasowanie priorytetu i kultury | Tak (ale rzadko) | Nie, tylko dla domyślnych zadań |
| Proste dzielenie pracy między rdzeniami (CPU) | Czasem | Tak |
6. Przydatne niuanse
Task — to nie zawsze wątek!
Najpotężniejsza magia: jeśli używasz Task do asynchronicznych operacji I/O, nowy wątek wcale nie jest tworzony! Wszystko "magicznie" korzysta z IO Completion Ports lub innych natywnych mechanizmów. Wątek jest zwalniany, gdy twoje zadanie czeka na coś zewnętrznego: plik, sieć, bazę danych. W praktyce, podczas oczekiwania żaden wątek nie jest zajęty!
Task i asynchroniczność (I/O-bound) — magia await
using System;
using System.Net.Http;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// Asynchronicznie pobieramy zawartość strony (I/O-bound)
HttpClient client = new HttpClient();
string data = await client.GetStringAsync("https://www.dotnetfoundation.org");
Console.WriteLine($"Otrzymano znaków: {data.Length}");
}
}
- Tu zadanie (Task<string>) enkapsuluje asynchroniczną operację I/O.
- Wątek nie jest blokowany — kontynuuje pracę, a gdy pobieranie się skończy — wykonanie metody jest wznawiane.
- Ręczne tworzenie wątku dla takiego zadania jest absolutnie zbędne i nieefektywne.
Task i ThreadPool
Kiedy piszesz Task.Run(...) lub używasz asynchronicznego API (await czegokolwiek), .NET zazwyczaj używa specjalnego puli wątków — ThreadPool. To zestaw wcześniej utworzonych wątków, które "siedzą na ławce rezerwowych" i są gotowe szybko podjąć dowolne zadanie. Jeśli pracy mało — wątki są bezczynne, jeśli dużo — nowe wątki są tworzone automatycznie, ale rozsądnie! Dzięki temu twoje aplikacje skalują się pod kątem liczby zadań, nie tworząc nadmiernego obciążenia systemu.
Wątek stworzony przez new Thread jest prawie zawsze osobnym "mieszkańcem" systemu — nie wraca do puli po zakończeniu, tylko umiera. Dlatego Task jest znacznie efektywniejszy do masowego paralelizmu.
7. Typowe błędy i pułapki
Jeśli nagle zapragniesz być retro-programistą i wszystko pisać przez wątki, czekają na ciebie przygody: wycieki pamięci, skomplikowana synchronizacja, brak możliwości anulowania pracy, "zawieszone" wątki-duchy (zombie), łapanie i obsługa błędów przez specjalne API.
Najważniejsze do zapamiętania: "Task" — to wygodnie, bezpiecznie i nowocześnie. W przytłaczającej większości przypadków przy pisaniu w C# dzisiaj nie ma powodów, by wracać do ręcznego zarządzania wątkami.
GO TO FULL VERSION