1. Wprowadzenie
Wielowątkowość — to jak równoległa praca kilku pracowników w biurze: jeden drukuje dokumenty, drugi dzwoni do klienta, trzeci robi kawę (i, oczywiście, wszyscy oni — programiści). Gdybyśmy mieli tylko jednego pracownika, robiłby wszystko po kolei, a biuro dusiłoby się od nudy i kolejki po kawę. W programowaniu sytuacja analogiczna: program jednoprocesowy może wykonywać tylko jedno zadanie naraz.
Wyobraźmy sobie, że nasza aplikacja wykonuje długą operację, na przykład pobiera plik z internetu albo liczy ogromną tabelę. Wszystko inne w tym czasie "zamraża się" — przyciski nie działają, animacja stoi, słowa "nie odpowiada" pojawiają się w okienku.
Wielowątkowość pozwala aplikacji robić kilka rzeczy jednocześnie: interfejs pozostaje responsywny, operacje wykonywane są równolegle i nie dostajemy monologu w stylu "Komputer, znowu się zawiesiłeś?!".
Główne pojęcia i terminologia
Zanim wskoczymy głębiej, rozbijmy, czym jest wątek (thread) i czym różni się od procesu.
- Proces (Process): Samodzielny program z własną przestrzenią adresową, zmiennymi, zasobami. Na przykład każda uruchomiona aplikacja w Windows — to osobny proces.
- Wątek (Thread): Jednostka wykonania wewnątrz procesu. Proces może zawierać jeden lub więcej wątków, które pracują na tych samych zasobach (pamięć, zmienne).
Dlaczego wielowątkowość budzi tyle pytań?
Bo wątki to imprezowe typy: mogą zacząć wykonywać się w dowolnym momencie, mieszać dane, przerywać sobie nawzajem i generalnie zrobić bałagan w pamięci, jeśli nie pilnujesz porządku. Jeśli wydaje się, że to wygląda jak przedszkole bez wychowawcy — to dokładnie tak! Dyscyplina i dbałość o wątki to podstawa pisania niezawodnych programów wielowątkowych.
2. Historia i rola wielowątkowości w C# i .NET
Dawno temu C# był jednowątkowy, a programy — proste. Wraz ze wzrostem wymagań wydajnościowych, pojawieniem się wielordzeniowych procesorów i potrzebą tworzenia responsywnych aplikacji bez zawieszania UI, w .NET pojawiły się narzędzia do wielowątkowości. Najpierw był klasyczny System.Threading.Thread, później pojawiły się tasks (Task), metody asynchroniczne (async/await), przetwarzanie równoległe danych (PLINQ) i wysokopoziomowe prymitywy synchronizacji.
C# urosło do potężnej platformy, gdzie wielowątkowość to nie egzotyka, a codzienność.
Wizualnie: proces i wątki
Prosty schemat:
+--------------------------------------------------+
| Proces (twoj program) |
| +-------------+ +-------------+ |
| | Wątek 1 | | Wątek 2 | |
| +-------------+ +-------------+ |
| ... |
| +-------------+ |
| | Wątek N | |
| +-------------+ |
+--------------------------------------------------+
Wszystkie wątki wewnątrz procesu widzą wspólne zmienne i zasoby.
3. Jak stworzyć wątek w C#?
Zaczniemy od najbardziej podstawowego: klasy Thread z przestrzeni nazw System.Threading.
Przykład: Uruchamiamy drugi wątek
Niech mamy jakąś długotrwałą pracę — na przykład obliczanie sumy liczb od 1 do 10_000_000. Gdy trwa liczenie, główny wątek niech wypisze powitanie użytkownikowi.
using System;
using System.Threading;
class Program
{
// Metoda dla drugiego zadania
static void CalculateSum()
{
long sum = 0;
for (int i = 1; i <= 10_000_000; i++)
sum += i;
Console.WriteLine($"[Wątek 2] Suma: {sum}");
}
static void Main()
{
// Tworzymy wątek, podajemy delegat na metodę
Thread thread = new Thread(CalculateSum);
thread.Start(); // Uruchamiamy drugi wątek
// Główny wątek kontynuuje pracę
Console.WriteLine("[Wątek 1] Cześć! Pracujemy równolegle...");
// Czekamy na zakończenie drugiego wątku przed wyjściem
thread.Join();
Console.WriteLine("[Wątek 1] Wszystko zakończone!");
}
}
Co się stanie?
Na ekranie pojawi się linia "[Wątek 1] Cześć! Pracujemy równolegle...", a potem, gdy wątek liczący skończy pracę, wypisze sumę.
Typowy problem: Kto pierwszy, ten wypisał
Uruchom ten kod kilka razy — kolejność wypisywania może być różna! Czasem suma pojawi się pierwsza, czasem powitanie. To jest prawdziwa wielowątkowość — programy stają się mniej przewidywalne, jak humor kota w poniedziałek.
4. Obszar pamięci: co widzą wątki?
Wszystkie wątki w procesie mają dostęp do tych samych zmiennych (o ile nie są lokalne dla metody). Jeśli zmienisz zmienną w jednym wątku — zobaczą ją pozostałe!
Przykład: Wspólna zmienna
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 1000; i++)
counter++;
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"counter = {counter}");
}
}
Ile spodziewamy się zobaczyć w counter? Logika podpowiada 2000, bo każdy wątek zwiększa po 1000.
A jednak nie! Uruchom kilka razy — zobaczysz różne wartości: 1782, 1935, 1999…
Dlaczego? To klasyczny problem Race Condition — wątki "przechwytują" sobie sterowanie między odczytem -> zwiększeniem -> zapisem, i część inkrementów się gubi.
5. Przydatne niuanse
Jak wątki współdziałają z UI?
W nowoczesnych aplikacjach desktopowych (WinForms/WPF/MAUI) główny wątek obsługuje GUI. Wszystkie akcje użytkownika (kliknięcia, wprowadzanie) — na tym wątku. Tła zadania powinny wykonywać się w innych wątkach, ale zgodnie z zasadami nie można bezpośrednio "dotykać" UI z innych wątków. To zapobiega chaosowi.
W konsoli takiego ograniczenia nie ma, można pisać do Console.WriteLine z dowolnego wątku. Jednak w rzeczywistych aplikacjach bez poprawnej synchronizacji UI może "odjechać".
Sekwencja i równoległość
Tabela — żeby utrwalić różnice.
| Kod jednowątkowy | Kod wielowątkowy |
|---|---|
| Wykonuje zadania po kolei | Zadania mogą być wykonywane jednocześnie |
| UI "zawiesza się" przy długich zadaniach | UI pozostaje responsywne |
| Prosto czytać i zapisywać zmienne | Wymaga kontroli dostępu do danych |
| Łatwo debugować | Może być trudno debugować |
Ważne punkty przy pracy z wątkami
- Wspólne zmienne — wspólne ryzyko. Jak pokazano wyżej, jeśli kilka wątków używa wspólnej zmiennej — bez synchronizacji możliwe są błędy! (Szczegóły w kolejnych wykładach.)
- Na wątek można "czekać" przez Join(). Metoda Join() pozwala "zawiesić" główny wątek do zakończenia wątku tła. Użyj jej, jeśli trzeba poczekać na wynik.
- Wątku nie da się uruchomić dwa razy. Po wykonaniu wątku nie można go ponownie Startować — trzeba utworzyć nowy obiekt Thread.
- Zakończenie wątku. Wątek kończy się, gdy kończy się wykonanie jego metody. Przymusowe "zabijanie" wątków — zły pomysł (metoda Abort() od dawna uważana jest za szkodliwą i przestarzałą).
Do czego przydaje się wielowątkowość w praktyce?
- Aplikacje UI: nie blokować interfejsu, gdy trwa tło ładowania lub obliczenia.
- Serwery i usługi: obsługiwać jednocześnie żądania wielu klientów.
- Wydajne obliczenia: dzielić duże zadanie (np. przetwarzanie milionów rekordów) na części i wykonywać je równolegle.
- Gry, symulacje, przetwarzanie danych: modelować złożone systemy bez utraty wydajności.
Problemy wielowątkowości
Wielowątkowość daje moc, ale dodaje komplikacje:
- Race Condition (stan wyścigu): gdy kilka wątków jednocześnie zmienia te same dane, wynik zależy od kolejności instrukcji, co jest nieprzewidywalne.
- Deadlock (wzajemne blokowanie): wątki czekają na siebie i nikt nie może kontynuować pracy.
- Starvation (głodzenie): jeden z wątków jest stale "pozbawiany" dostępu do zasobu.
W tym wykładzie tylko wspomnieliśmy główne problemy wielowątkowości, w następnych nauczymy się je rozpoznawać i unikać. Na razie — zapamiętaj, że jeśli coś "zawiesiło się", winne mogą być nie tylko bugi, ale też wątki, które "zorganizowały imprezę".
GO TO FULL VERSION