1. Wprowadzenie
W aplikacjach wielowątkowych występowanie race condition — to kwestia nie "czy", lecz "kiedy to się zdarzy". Nawet jeśli myślisz, że twój kod jest solidny i masz tylko "dwa małe wątki", gdzie "wszystko jest oczywiste i proste", stan wyścigu może czaić się w najbardziej niegroźnym fragmencie logiki.
Czym w ogóle jest race condition i dlaczego jest takie straszne? Wyobraź sobie, że dwie osoby próbują jednocześnie edytować ten sam kawałek papieru — jedna zapisuje, druga ściera. Czasem wszystko jest ok, a czasem wychodzi coś nieczytelnego. W programowaniu konsekwencje bywają jeszcze "zabawniejsze": błędy nie ujawniają się zawsze, tylko w określonych, prawie losowych warunkach.
Race condition (stan wyścigu) — sytuacja, w której wynik wykonania programu zależy od tego, który wątek pierwszy uzyska dostęp do zasobu lub wykona działanie. Ten problem występuje tylko przy dostępie współbieżnym (wielowątkowym), gdy dwa lub więcej wątków odwołuje się do współdzielonych danych lub zasobów.
Co się dzieje przy wyścigu?
Oto prosta schemat. Wyobraź sobie, że mamy dwa wątki i jeden współdzielony zasób (np. zmienna X):
+---------+ +---------+
| Wątek 1 | | Wątek 2 |
+----+----+ +----+----+
| |
| Odczyt X |
| <-------------------|
| |
| Zwiększenie X |
|-------------------> |
| |
| Zapis X |
| <-------------------|
Jeśli oba wątki jednocześnie odczytają wartość zmiennej X, zwiększą ją i zapiszą z powrotem, ktoś "nadpisze" zmiany drugiego, i łączna liczba inkrementacji nie będzie się zgadzać z oczekiwaną.
2. Klasyczny przykład Race Condition
Przyjrzyjmy się przykładzie. Załóżmy, że chcemy policzyć liczbę naciśnięć przycisku z różnych wątków albo liczbę przetworzonych zadań.
Bierzemy prostą zmienną i kilka wątków, które ją inkrementują:
using System;
using System.Threading;
class Program
{
static int counter = 0; // Współdzielony zasób
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Oczekiwana wartość: 200000");
Console.WriteLine("Rzeczywista wartość: " + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // << Tutaj może wystąpić problem!
}
}
}
Czego oczekujemy?
Ponieważ każdy wątek zwiększa counter po 100000 razy, oczekujemy, że wartość końcowa będzie 200000.
Co dostajemy w rzeczywistości?
Czasem — tak, 200000. Ale częściej wartość będzie mniejsza — czasem znacznie mniejsza. Powtarzaj eksperyment, wynik będzie się wahać!
Dlaczego tak?
Operacja counter++ nie jest atomowa. W rzeczywistości wykonywana jest tak (uproszczone):
- Odczytać bieżącą wartość counter (np. 0)
- Zwiększyć o 1 (otrzymujemy 1)
- ZAPISAĆ z powrotem (counter = 1)
Jeśli dwa wątki odczytają starą wartość jednocześnie, oba mogą zapisać ją z nową wartością, ale w praktyce dodadzą ten sam inkrement.
Wizualizacja na przykładzie dwóch wątków:
Załóżmy, counter = 0.
- Wątek 1: odczytuje 0
- Wątek 2: odczytuje 0
- Wątek 1: liczy 0 + 1 = 1
- Wątek 2: liczy 0 + 1 = 1
- Wątek 1: zapisuje 1
- Wątek 2: zapisuje 1 (traci inkrement Wątku 1)
"Gratulacje", właśnie straciliśmy jedno zwiększenie! W skali tysięcy i milionów operacji — wynik mocno "pływa".
3. Jeszcze przykłady: nie tylko inkrement!
Zamieszanie w kuchni
Dla zabawy wyobraź sobie małą kawiarnię. Dwóch kucharzy smaży omlet na jednej patelni, ale nie koordynują swoich działań:
- Pierwszy kładzie jeden omlet, drugi od razu kładzie swój na wierzchu — mieszają się nawzajem;
- Jeden myśli, że "już położyłem dwa omlety", drugi myśli to samo, a w rzeczywistości na patelni są trzy, a myśleli, że cztery;
- Rozpoczyna się chaos...
W programowaniu race condition prowadzi dokładnie do takiego "chaosu": wynik zależy od ciągu szybkich i niekontrolowanych operacji.
Kiedy wątki przeszkadzają sobie nawzajem: jednoczesny dostęp do danych
Załóżmy, że implementujesz aplikację bankową i klient jednocześnie wpłaca i wypłaca pieniądze z tego samego konta, używając dwóch wątków (np. jeden — przelew online, drugi — kasa):
account.Balance += 500; // Wątek 1: wpłata
account.Balance -= 300; // Wątek 2: wypłata
Jeśli te operacje nie są zabezpieczone, końcowy balans może być niepoprawny: część operacji po prostu "zginie", jeśli wątki działają jednocześnie.
4. Przydatne niuanse
Dlaczego race condition to problem?
Trudne do złapania i odtworzenia. Błąd może ujawnić się tylko na obciążonej maszynie albo w rzadkich warunkach.
Trudne do debugowania. Podczas debugowania wątki mogą "zachować się" inaczej i błąd zniknie.
Naruszanie integralności danych. Otrzymujesz niepoprawne, uszkodzone dane, czasem zupełnie niewidocznie.
Bezpieczeństwo. W krytycznych aplikacjach race condition mogą prowadzić do wycieków, zniszczenia danych, a nawet podatności.
Diagram "timingu wyścigu"
+-----------------------+ +-----------------------+
| Wątek 1 | | Wątek 2 |
+-----------------------+ +-----------------------+
| 1. Odczytać counter | | |
| 2. Zwiększyć counter | | |
| (ale nie zapisać) | | |
| | | 1. Odczytać counter |
| | | 2. Zwiększyć counter |
| | | 3. Zapisać counter |
| | | (counter = 1) |
| 3. Zapisać counter | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
Oba wątki wykonały inkrement, ale wynik — tylko jeden zapisany inkrement!
Gdzie często występuje stan wyścigu
- Dowolne globalne lub statyczne zmienne, do których odwołuje się kilka wątków.
- Listy, kolejki, kolekcje, które są wypełniane z różnych wątków.
- Eventy i delegaty, jeśli subskrypcja/odsubskrypcja dzieje się jednocześnie (np. w UI + zadaniach w tle).
- Cache'owanie, słowniki, zarządzanie połączeniami.
- Każda interakcja z plikami, logami, bazami danych bez transakcji lub blokad.
Jak uniknąć race condition: krótkie wprowadzenie
- Synchronizacja! (więcej — na następnych wykładach).
- Używaj specjalnych konstrukcji języka i bibliotek: lock, Monitor, mutexy, semafory itd.
- Dla prostych operacji — metody atomowe (Interlocked.Increment i inne).
- Używaj kolekcji bezpiecznych dla wątków (ConcurrentBag, ConcurrentDictionary).
- Zawsze myśl: "co się stanie, jeśli dwie moje funkcje zostaną wywołane jednocześnie?"
5. Przydatne wskazówki
Porady dotyczące wyszukiwania i diagnostyki wyścigów
- Nie ufaj nawet najprostszym operacjom (inkrement ++, przypisanie), jeśli używasz wielu wątków.
- Jeśli to możliwe, unikaj współdzielonego dostępu do zmiennych.
- Jeśli widzisz "pływające" bugi, błędy trudne do odtworzenia — pomyśl o wyścigach!
- Używaj narzędzi do analizy wątków (dotTrace, Concurrency Visualizer, Thread Sanitizer).
- Przeprowadzaj testy obciążeniowe — im więcej wątków i operacji, tym większa szansa wykrycia błędu.
Co można, a czego nie można bez synchronizacji
| Operacja | Bezpieczne w środowisku wielowątkowym? | Wyjaśnienie |
|---|---|---|
| Przypisanie int | 🟩 Czasami* | Tylko jeśli jeden wątek pisze, inni tylko czytają, w przeciwnym razie — wyścig |
| Inkrement (++/--) | 🟥 Nie | Nieatomowe! Race Condition |
| Odczyt string | 🟩 Czasami* | Jeśli string nie jest modyfikowany po utworzeniu |
| Przypisanie obiektu | 🟩 Czasami* | Pod warunkiem, że nie ma równoczesnych zapisów |
| Dodanie do List<T> | 🟥 Nie | List<T> nie jest bezpieczny dla wątków |
|
🟩 Tak | Specjalna metoda atomowa |
— "Czasami" oznacza, że jeśli tylko jeden wątek zapisuje, a wszystkie pozostałe tylko czytają, to jest bezpiecznie; jeśli kilka wątków może zapisywać jednocześnie — zawsze wyścig.
6. Typowe błędy i pułapki
W pokazanym wyżej kodzie-demonstracji widzieliśmy counter++ jako problem. Kolejna pułapka: zwiększanie lub sprawdzanie wartości w warunku.
Przykład: Zabawny błąd z "pierwszym uruchomieniem"
if (!alreadyStarted)
{
alreadyStarted = true;
// Robimy inicjalizację...
}
Jeśli taki warunek wykonuje kilka wątków jednocześnie, każdy z nich może zobaczyć alreadyStarted == false i wejść do środka! W efekcie — zainicjują coś dwa razy, co może doprowadzić do awarii.
GO TO FULL VERSION