CodeGym /Kursy /C# SELF /Problem współdzielonych zasobów

Problem współdzielonych zasobów

C# SELF
Poziom 56 , Lekcja 0
Dostępny

1. Wprowadzenie

W aplikacji wielowątkowej współdzielony zasób — to wszystko, do czego jednocześnie mogą mieć dostęp dwa lub więcej wątków. Może to być:

  • Zmienna (na przykład globalny licznik lub lista).
  • Obiekt (na przykład kolekcja użytkowników).
  • Plik lub socket sieciowy.
  • Każda struktura danych modyfikowana przez różne wątki.

W naszej aplikacji konsolowej najczęściej natkniemy się na zmienne i obiekty, które są "shared" między wątkami.

Analoogia

Wyobraź sobie dwóch ludzi, którzy jednocześnie próbują zapisać coś w tej samej zeszycie, nie ustalając kolejności. W najlepszym wypadku powstanie krzywy zapis, w najgorszym — ktoś nadpisze cudze dane. W programowaniu sytuacja jest dokładnie taka sama, tylko ci „ludzie” to wątki.

Krótk o typowych zasobach podatnych na race

W tabeli poniżej — najczęstsze zasoby, niebezpieczne przy wspólnym dostępie z różnych wątków:

Zasób Grupy problemów Przykład
Zmienna typu int Nieprawidłowe zwiększanie/zmniejszanie Liczniki, indeksy
Wspólne kolekcje Utrata/uszkodzenie elementów, wyjątki Wspólna lista zamówień
Obiekty Niespójne zmiany stanu Flagi, właściwości
Pliki Uszkodzenie danych, niepoprawne odczyty/zapisy Log-faily, konfiguracja

2. Race condition: jak się objawia?

Przykład: licznik odwiedzin

Powiemy, że chcemy policzyć, ile razy użytkownik kliknął przycisk (albo, w naszym przykładzie, ile razy różne wątki inkrementowały zmienną). Prosta wersja kodu:


int counter = 0;

void Increment() {
    counter++;
}

Teraz utwórzmy dwa wątki, w każdym z nich wywoływane jest po 100 000 razy Increment():


using System;
using System.Threading;

class Program
{
    static int counter = 0;

    static void Increment()
    {
        for (int i = 0; i < 100_000; 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($"Oczekiwano: 200000, otrzymano: {counter}");
    }
}

Ile razy logicznie powinien zostać zwiększony counter? 200000! Ale jeśli uruchomicie ten kod kilka razy, prawie na pewno zobaczycie różne liczby: 185000, 192500, 198765… Dlaczego?

3. Dlaczego counter++ — to nie atomowa operacja?

Jak to działa naprawdę: counter++

W C# i innych językach wysokiego poziomu program jest tłumaczony na zestaw instrukcji maszynowych. Operator counter++, niestety, nie zamienia się w jedną magiczną komendę "dodaj 1 do zmiennej". Oto co się dzieje naprawdę:

  1. Wątek CZYTA wartość z pamięci (counter).
  2. Zwiększa tę wartość o 1 (w rejestrze procesora).
  3. Zapisuje nowe wartość z powrotem do pamięci (counter).

Jeśli dwa wątki robią to niemal jednocześnie, mogą oba odczytać tę samą starą wartość, zwiększyć ją i oba zapisać wynik z powrotem, tracąc jeden inkrement.

Scenariusz race

Załóżmy, że counter wynosił 1000. Oba wątki odczytały tę wartość (krok 1), oba zwiększyły ją do 1001 (krok 2), a potem oba zapisały z powrotem 1001 (krok 3). Co za koszmar: jeden inkrement po prostu zaginął!

Wizualizacja race

Moment czasu Wątek 1 Wątek 2 Wartość counter
1 Odczyt 1000 1000
2 Odczyt 1000 1000
3 Inkrement do 1001 Inkrement do 1001 1000 (jeszcze bez zapisu)
4 Zapis 1001 1001
5 Zapis 1001 1001

W rezultacie, za dwa inkrementy wartość wzrosła tylko o 1!

4. Jeszcze kilka przykładów: "niewidoczne bugi"

A co jeśli race condition nie dotyczy liczb?

Wyobraźmy sobie teraz, że kilka wątków dodaje elementy do tej samej listy:


using System;
using System.Collections.Generic;
using System.Threading;

class Program
{
    static List<int> numbers = new List<int>();

    static void AddNumbers()
    {
        for (int i = 0; i < 10000; i++)
        {
            numbers.Add(i);
        }
    }

    static void Main()
    {
        Thread t1 = new Thread(AddNumbers);
        Thread t2 = new Thread(AddNumbers);

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        Console.WriteLine($"Oczekiwano: 20000, otrzymano: {numbers.Count}");
    }
}

Ten kod też może dawać różne wyniki przy każdym uruchomieniu: czasem program zakończy się awarią (wyjątek), czasem zobaczycie mniej elementów niż oczekiwano.

Dlaczego? Bo kolekcja List<T> z pudełka nie jest thread-safe. Czyli kiedy dwa wątki jednocześnie wywołują Add, może dojść do uszkodzenia wewnętrznej struktury listy.

5. Atomowość operacji

Czym jest atomowa operacja?

Atomową nazywamy operację, która wykonuje się w całości, bez możliwości przerwania jej w połowie przez inny wątek. To taka "transakcja": albo wszystko, albo nic.

  • Operacje przypisania typu int myVar = 42; na większości platform są atomowe (o ile to nie ogromny obiekt).
  • Ale counter++ nie jest atomowy — to trzy kolejne działania.

Specjalne operacje atomowe

W .NET są specjalne klasy do operacji atomowych: na przykład Interlocked. Ten sposób omówimy w następnych wykładach.

Przykład atomowego inkrementu z użyciem Interlocked.Increment:


using System.Threading;

int counter = 0;
Interlocked.Increment(ref counter); // operacja atomowa!

6. Dlaczego trudno złapać race condition?

Race condition jest niebezpieczny, bo:

  • Może ujawniać się tylko przy dużym obciążeniu.
  • Zdarza się nie w 100%, a w 5% lub nawet 0.01% przypadków.
  • Upada "losowo" i pojawia się tam, gdzie nikt się tego nie spodziewa.

Jak rozpoznać problem?

Jeśli przy każdym uruchomieniu programu dostajesz różne (i błędne) wyniki, warto podejrzewać race condition.

Programistyczne żarty

"Jeśli błąd pojawia się rzadko i znika po dodaniu Thread.Sleep(50) — masz poważniejsze problemy, niż się wydaje."

7. Przydatne niuanse

Synchronizacja

Aby chronić sekcje krytyczne (części kodu, gdzie operujemy na współdzielonych zasobach), trzeba je synchronizować. Ale to temat następnych wykładów. Teraz najważniejsze — nauczyć się zauważać i opisywać problem.

Typowe błędy początkujących

Wielu początkujących myśli: „Mam counter++ — co może pójść nie tak?” Niestety, gdy tylko pojawia się więcej niż jeden wątek, wszystko może pójść nie tak! Nawet pozornie proste rzeczy: odczyt i zapis zmiennych, dodawanie elementów do listy, zmiana stanu obiektu i wiele innych.

Miejsce race condition w realnym projekcie

W nowoczesnych aplikacjach wielowątkowych (na przykład w serwerowych API, obsłudze zapytań webowych, grach i aplikacjach mobilnych) prawie zawsze są współdzielone zasoby. Bez synchronizacji race condition prowadzi do błędnej obsługi zamówień, awarii, wycieków pamięci i ogromnych trudności przy debugowaniu.

Na interview na stanowiska middle/senior na pewno zapytają: "Co to jest race condition? Jak go uniknąć?" Jeśli będziesz potrafił przytoczyć powyższe przykłady — i wytłumaczyć mechanikę — rekruterzy będą zadowoleni!

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION