6 minut(y)

Podstawy są ważne, bez nich nie możemy ruszyć dalej z nauką, szczególnie, gdy chcemy opanować rzeczy o dużo większej złożoności, jak na przykład wielowątkowość i przetwarzanie współbieżne. Zacznijmy więc od fundamentów, pojęć bez, których opanowanie sztuki tworzenia wielowątkowego kodu się nie powiedzie. Żeby pisać kod uruchamiany w więcej niż jednym wątku, wpierw trzeba nauczyć się samego myślenia w sposób wielowątkowy i właśnie od tego zaczniemy.

Współbieżność i wielowątkowość

Rozpoczniemy od rozróżnienia, czym jest współbieżność, a czym wielowątkowość. Zrozumienie obu zagadnień umożliwi Ci zbudowanie solidnych podstaw myślenia, jak wygląda przetwarzanie zadań przez komputer.

Współbieżność to wykonywanie zadań w tym samym czasie przez jedną jednostkę CPU. To znaczy, że nasz procesor otrzymuje kilka zadań i sekwencyjnie wykonuje po trochu każde z nich, przełączając konteksty między nimi. Zadania sumarycznie są wykonywane w tym samym czasie, ale tak naprawdę, to CPU jest w stanie pracować tylko nad jednym zadaniem na raz. Dlatego, by  każde zadanie posuwało się do przodu, CPU przełącza się miedzy nimi, aż do ich zakończenia. Proces ten nazywamy przełączaniem kontekstów. To daje nam wrażenie, że wszystko co się dzieje na ekranie komputera, wykonuje się niejako jednocześnie. To sprytna sztuczka, oszukiwanie ludzkiego oka i percepcji.

Wielowątkowość to również przetwarzanie wielu zadań na raz, ale już bez przełączania kontekstu, ponieważ każdy wątek dostaje swoje zasoby może wykonywać przydzielone zadanie równocześnie z innymi wątkami. W tym przypadku możemy mówić o przynajmniej dwóch CPU (to uproszczenie), które nie przełączają się między zadaniami, tylko dostają po jednym i wykonują je bez przerwy, aż do zakończenia.

By jeszcze lepiej to zrozumieć, posłużmy się prostą analogią. Współbieżność jest jak gotowanie w kuchni, przez chwilę mieszasz gotujący się makaron, potem wrzucasz kotlet na patelnię, by  za chwilę kroić warzywa na sałatkę. Nie kroisz jedną ręką warzyw, a drugą nie mieszasz makaronu, obracając stopą kotleta :) (no może niektórzy tak potrafią). Przeskakujesz między czynnościami, aby  wszystkie zakończyć w podobnym czasie i móc podać gotowy obiad. Wielowątkowość to po prostu większa liczba kucharzy w kuchni, którzy nie wchodzą sobie w drogę. Obiad powstaje szybciej, ale wymaga więcej ludzi.

Oba podejścia można oczywiście łączyć, otrzymując wielozadaniowe wątki i jednostki przetwarzania. Obecnie architektury sprzętowe mogą być bardzo różne i rozbudowane, od procesora jednordzeniowego, poprzez wielordzeniowe CPU, a kończąc na wielu procesorach wielordzeniowych.

Z przetwarzaniem współbieżnym i wielowątkowym wiążą się również spore wyzwania i ograniczenia. Nie jest to remedium na wszystko, ale potrafią (gdy są odpowiednio użyte) mocno przyśpieszyć działanie naszego programu.

Operacje w linijce kodu

Kiedy znamy już różnicę między współbieżnością, a wielowątkowością, przejdźmy do najbardziej podstawowego pojmowanie kodu właśnie w tym kontekście.

Gdy widzisz taką linijkę kodu, to co myślisz?

x += 42;

Niby jedna operacja, nic bardziej mylnego! Tak naprawdę dzieją się tam trzy rzeczy:

  1. Odczytanie zmiennej x.
  2. Modyfikacja tej zmiennej
  3. Zapis zmiennej x w pamięci.

Każda ta operacja przedstawia inny stan zmiennej x. Pomiędzy lub nawet w trakcie tych operacji, inny wątek może też chcieć skorzystać z naszej zmiennej. Pytanie w jakim stanie powinna się ona znajdować? By odpowiedzieć na to pytanie musimy najpierw zrozumieć czym są niezmienniki.

Niezmienniki

Niezmiennik to stan zmiennej/obiektu/modułu/systemu, który musi być prawdą w każdym momencie jego obserwacji.

Kluczowymi słowami w tej definicji są stan, prawdaobserwacja. Rozłóżmy to na czynniki pierwsze. Jeśli chcesz nauczyć się dobrze programować wielowątkowo to wpierw naucz się myśleć, nie w kategoriach zmiennych czy obiektów, ale ich stanu w jakim się obecnie znajdują, w jakim będą się znajdować za chwilę itd.

Mamy metodę klasy Account, która ma taką implementację metody transfer.

auto transfer(Account& account, Money value) -> void
{
    account.withdraw(value);
    this->balance += value;
}

Niezmiennikami mogą być, w takim przypadku, poniższe założenia:

  1. Saldo każdego konta jest zawsze ≥ 0.
  2. Suma środków account.balancethis->balance jest taka sama w każdym obserwowanym momencie.
  3. Metoda transfer jest niepodzielną zmianą systemu.
  4. Jeśli zmniejszone zostało account.balance to zwiększone zostało this->balance.

Powyższe 4 punkty to są założenia, które metoda transfer powinna spełniać. Czy tak jest? No niestety nie. W wyniku wykonywania tej metody, nie ma żadnych ograniczeń w tym, by  inny wątek odwołał się do tych obiektów.

auto transfer(Account& account, Money value) -> void
{
    account.withdraw(value);
    // W tym momencie obserwacji, część niezmienników nie jest prawdziwa
    this->balance += value;
}

Aby temu zaradzić należałoby dodać mutex. Czym są mutexy? O tym trochę później. Skupmy się na fundamentach. By dobrze określić niezmienniki należy zadać sobie odpowiednie pytania. Weźmy przykład prostej klasy Counter.

class Counter
{
public:
    auto increment() -> void
    {
        value++;
    }

    auto getValue() const -> int
    {
        return value;
    }

private:
    int value { 0 };
};
  1. Co jest stanem naszego obiektu klasy Counter?

Stanem jest zmienna value, a raczej to jaką wartość posiada w każdym momencie obserwacji.

  1. Kto może obserwować stan obiektu klasy Colunter?

Każdy inny wątek w dowolnym momencie. Nie tylko przed i po wywołaniu metod.

  1. Jaki stan jest niedozwolony? Sprawia, że obiekt klasy Counter jest niespójny?

value jest ujemne.

Naszym niezmiennikiem będzie więc: “Stan zmiennej value jest nieujemny w każdym momencie obserwacji.”

Zastanówmy się, czy jest w ogóle możliwość, aby  ten niezmiennik był złamany? Dla przykładu metoda increment.

auto increment() -> void
{
    value++;
}

Czy istnieje moment, w którym value może być ujemny? Weź pod uwagę dowolny przeplot wątków. Zakładamy brak synchronizacji i wiele wątków korzystających z tego samego obiektu klasy Counter. Wartość value jest inicjalizowana jako 0.

Odpowiedź brzmi: tak.

Intuicyjnie jest to przecież niemożliwe, prawda? Przecież zaczynamy od 0 i tylko inkrementujemy. Tak, to prawda, jednak przy przetwarzaniu wielowątkowym, w przypadku naszej klasy Counter, mamy do czynienia z data race. Wątki konkurują ze sobą o dostęp do value. Gdy jeden z wątków właśnie pracuje nad zwiększeniem value, inny, w dowolnym momencie może zaobserwować value i odczytać jej stan jako dowolny ciąg bitów. value może być jeszcze w rejestrze CPU, może zawierać starą wartość, może zawierać tylko fragment nowej wartości (w reprezentacji bitowej). Standard C++ określa data race jako Undefined Behavior. Po dokładne szczegóły odsyłam do oficjalnej dokumentacji.

Wyznaczanie niezmienników nie jest rzeczą trywialną i wymaga ćwiczeń, niemniej znając ich definicje i założenia będzie to znacznie prostsze.

Sekcja krytyczna

Wiedząc już czym są niezmienniki, czas przejść do ich spełniania w naszym kodzie. Wspominałem wcześniej o mutexie. To narzędzie do synchronizacji pomiędzy wątkami. Jest ich jeszcze kilka, ale ważniejsze jest teraz to, co ze sobą niosą. Sekcja krytyczna to taki fragment kodu/systemu, który nie może być obserwowalny przez inne wątki, aby  zachować niezmienniki.

Wiedząc już jaki stan jest niedopuszczalny w naszym kodzie, musimy nauczyć się wydzielać te miejsca, które do takiego stanu doprowadzają i zablokować możliwość ich obserwacji przez inne wątki. Do tego, między innymi, służy mutex. Gdy jest zablokowany, nie dopuszcza innych wątków do momentu jego zwolnienia, a ich dostęp jest kolejkowany.

Wróćmy do przykładu z funkcją transfer i dodajmy do niej mutex.

auto transfer(Account& account, Money value) -> void
{
    std::lock_guard<std::mutex> transferLock { transferMutex };
    account->withdraw(value);
    this->balance += value;
}

std::lock_guard to narzędzie typu RAII, samo blokuje mutex w chwili tworzenia i odblokowuje w momencie destrukcji. Teraz funkcja transfer zachowuje niezmiennik:

  1. Jeśli zmniejszone zostało account.balance to zwiększone zostało this->balance.

Obserwowalny moment jest tylko przed zablokowaniem mutexa i po jego odblokowaniu, czyli całe ciało funkcji transfer nie jest dostępne dla innych wątków. Nie zawsze sekcja krytyczna to występujące po sobie linijki kodu. Najważniejsze są niezmienniki. Należy zawsze, przy analizie kodu wielowątkowego, zadawać sobie pytanie o poprawny stan naszej zmiennej czy obiektu.

Dla klasy Counter sekcją krytyczną będą wszystkie operacje na zmiennej value.

class Counter
{
public:
    auto increment() -> void
    {
        std::lock_guard<std::mutex> valueLock{valueMutex};
        value++;
    }

    auto getValue() const -> int
    {
        std::lock_guard<std::mutex> valueLock{valueMutex};
        return value;
    }

private:
    int value { 0 };
    mutable std::mutex valueMutex;
};

To że blokujemy mutex osobno w metodzie increment i osobno w getValue to dalej jest to jedna i ta sama sekcja krytyczna, bo tyczy się stanu tej samej zmiennej czyli tych samych niezmienników.

Podsumowanie

Znasz już najważniejsze koncepty myślenia wielowątkowego. Dzięki nim poznawanie świata programowania wielowątkowego będzie znacznie łatwiejsze. W następnym wpisie z tego cyklu poruszę najważniejsze zagrożenia płynące z wielowątkowości. Jeżeli chcesz poznać więcej szczegółów przetwarzania wielowątkowego, polecam zapoznać się z tytułem “Język C++ i przetwarzanie współbieżne w akcji.” - Anthony Williams. Jest to świetna pozycja, by  dobrze zrozumieć wielowątkowość i zastosować ją w swoich projektach. Książka tak zawiera również bardzo zaawansowaną wiedzę na temat szeregowania dostępu do pamięci oraz tworzenia struktur bez blokad (mutexów), co może być po prostu zbyt trudne do przyswojenia, jeżeli dopiero zaczynasz naukę tej dziedziny. Warto wtedy ostanie kilka rozdziałów zostawić sobie na później :)

Autor: Tadeusz Biela
Programista C++ | Entuzjasta TDD | Fan unit testów

LinkedIn

Zostaw komentarz