Aplikacje wielowątkowe w języku C++

Czas czytania: 9 minut

Czy pisałeś kiedyś program, który potrzebował dużo mocy CPU? Zastanawiałeś się dlaczego twoja aplikacja używa maksymalnie jednego rdzenia, nawet jeśli masz ich kilka/kilkanaście? Czy tworzyłeś kiedyś program, która odczytywał z dysku duży plik? I w tym momencie, w którym ten plik był odczytywany program nie reagował na polecenia użytkownika? No cóż, jest to całkowicie normalne zjawisko. Aby aplikacja potrafiła korzystać z wielu rdzeni, musisz sprawić, aby wykonywała się w wielu wątkach. Dzisiaj nauczysz się, jak napisać taki program w języku C++. Dowiesz się także, jakie problemy pojawiają się, gdy tworzymy aplikację wielowątkową. No to do dzieła!

Czym jest proces? Czym jest wątek?

Abyś dokładnie zrozumiał temat, musisz najpierw zrozumieć dwa podstawowe pojęcia, które pojawiły się w tytule tego paragrafu. Każdy program w systemie operacyjnym jest tzw. procesem.W systemie operacyjnym uruchomiona jest równocześnie duża ilość programów. Nawet na komputerze jednordzeniowym mamy wrażenie, że programy te pracują jednocześnie. Jak to się dzieje, skoro procesor może wykonywać jedynie jedno zadanie na raz? Otóż, procesor nie pracuje nad jednym programem cały czas. Każdy z procesów istniejących w systemie ma okazję dostać czas CPU. Procesor przydzielany jest na zmianę każdemu z procesów. Przydział ten zmienia się co określony, bardzo krótki czas. Dzięki temu odnosimy wrażenie że wszystkie aplikacje wykonują się w tym samym momencie.

Wróćmy do problemu ze wstępu. Na podstawie wyżej napisanych słów mógłbyś uważać, że można napisać po prostu dwa programy. Otóż, nie jest to takie proste. Każdy proces otrzymuje własną przestrzeń adresową (czyli miejsce w pamięci RAM, w której może przechowywać swoje zmienne). Istnieją sposoby komunikacji między dwoma różnymi procesami. Jednakże, jest to czynność skomplikowana. Istnieje inne rozwiązanie. Są nim: wątki.

Wątek niewiele różni się od procesu. Jeśli chodzi o przydział czasu procesora, traktowany jest zwykle identycznie jak zwykły proces. Pomimo tego, jest jedna rzecz, która odróżnia te pojęcia.Wątek działa w ramach przestrzeni adresowej procesu, który go utworzył.Oznacza to, że wątek przechowuje swoje zmienne w tym samym obszarze pamięci RAM, który został przydzielony procesowi, który go stworzył. Wątek ma zatem dostęp do zmiennych swojego „rodzica”.

Tworzenie aplikacji wielowątkowej w języku C++

Ważne!!!
Abyś mógł skompilować umieszczone poniżej programy na swoim komputerze, musisz
w ustawieniach kompilatora wybrać flagę –std=c++14
Jeśli dostaniesz błąd „undefined reference to pthread_create (…)” musisz w ustawieniach linkera dodać flagę -lpthread
#include <iostream> #include <thread> using namespace std; void foo() { for(int i = 0;i<5;i++) { cout<< "counter value:" <<i<<endl; } } int main() { thread thr(foo); thr.join(); return 0; }

Znajdujący się powyżej kod ilustruje najprostszy możliwy scenariusz. Spójrzmy najpierw na funkcję main. Co w niej się znajduje? W linijce 10 tworzymy obiekt klasy thread. W konstruktorze przekazujemy mu foo. Jej definicja zaczyna się w linijce 4. Co robi funkcja foo? Nic niezwykłego. Ten niezbyt skomplikowany twór po prostu wypisuje liczby od 0 do 5 w ramach pętli for. Na bardziej skomplikowane zadania przyjdzie jeszcze czas 🙂

Zastanawia cię z pewnością linijka 11. Co tam się dzieje?. Otóż, od momentu utworzenia obiektu wątku w linijce 10, utworzony wątek zaczyna wykonywać się równolegle wraz z głównym. Istnieje pewna szansa na to, że wątek główny wykona się szybciej niż potomny. Nie powinieneś do tego dopuścić. Wszelkie wątki potomne utworzone przez twój program powinny się kończyć zawsze przed zakończeniem działania programu. Do tego służy metoda join. Dzięki niej wątek główny może zaczekać, aż swoją pracę wykona wątek potomny.

I tu pojawia się kolejne pytanie. Czy wstrzymując wątek główny, nie rezygnujemy de facto z wszystkich zalet oferowanych przez pracę wielowątkową? Znajdziesz odpowiedź na to pytanie czytając dalszą część artykułu.

Uruchamiamy kilka wątków potomnych na raz – pierwszy praktyczny program

#include <iostream> #include <thread> #include <ctime> const int NUMSIZE = 10; using namespace std; int evenSum = 0; int oddSum = 0; void calculateEvenSum(int numbers[]) { for (int i = 0; i < NUMSIZE; i++) { if (!(numbers[i] & 1)) { evenSum += numbers[i]; } } } void calculateOddSum(int numbers[]) { for (int i = 0; i < NUMSIZE; i++) { if (numbers[i] & 1) { oddSum += numbers[i]; } } } void generateNumbers(int numbers[]) { srand(time(NULL)); for (int i = 0; i < NUMSIZE; i++) { numbers[i] = rand() % 100 + 1; } } void printNumberArray(int numbers[]) { for (int i = 0; i < NUMSIZE; i++) { cout<<numbers[i]<<","; } cout <<endl; } int main() { int numbers[NUMSIZE]; generateNumbers(numbers); cout <<"Wylosowano liczby : "; printNumberArray(numbers); thread thr1(calculateEvenSum, numbers); thread thr2(calculateOddSum, numbers); thr1.join(); thr2.join(); cout <<"Suma liczb parzystych to : "<<evenSum <<endl; cout <<"Suma liczb nieparzystych to : "<<oddSum <<endl; return 0; }

Załóżmy, że masz jakąś ogromną tablicę różnych liczb. Twoim zadaniem jest policzenie sumy wszystkich liczb parzystych znajdujących się w tablicy oraz sumę wszystkich liczb nieparzystych. Oczywiście, można wykonać to zadanie bez pomocy wątków. Lecz jeśli liczb jest ogrom, o wiele szybciej uzyskamy wynik stosując wiele wątków.

Funkcje calculateEvenSum i calculateOddSum

Powyższy przykład ilustruje jak prosto można zachęcić wątki do współpracy ze sobą. Wynik swojej pracy wątki zapisują w zmiennych globalnych evenSum i oddSum, które zdefiniowane są w linijkach 6 i 7. Możesz zadać pytanie, dlaczego po prostu funkcja calculateEvenSum albo calculateOddSum nie zwraca typu int? Ponieważ std::thread nie udostępnia takiej możliwości. W takim celu należy użyć innych typów zdefiniowanych bibliotece standardowej. Zajmiemy się nimi w przyszłości.

Funkcje calculateEvenSum i calculateOddSum wyglądają dokładnie tak samo, jakbyśmy używali ich w zwykłym programie jednowątkowym. Mogą przyjmować dowolne argumenty. Warto abyś spojrzał jak został rozwiązany sposób rozróżnienia liczb – parzysta/nieparzysta. Najpopularniejszym rozwiązaniem, które z pewnością niejednokrotnie widziałeś jest użycie operatora modulo. Jest to najprostszy, ale nie najlepszy sposób. Dlaczego? Ponieważ modulo jest nieco wolniejsze od sposobu zastosowanego w tym listingu. A na czym ów trik się opiera? Na operacjach bitowych. Zauważ, że w zapisie bitowym każdej liczby parzystej na pozycji najmłodszego bita znajduje się 0. Natomiast, jeśli rozważamy liczbę nieparzystą, znajdować się tam będzie jedynka. Spójrz na linijkę 17. Używamy operatora AND do tego, aby wykonać opisany wcześniej algorytm. Więcej o operatorach bitowych napiszę w przyszłości.

Funkcja main

Podobnie jak poprzednio, najwięcej dzieje się w funkcji main. Jej zawartość jest nieco analogiczna w stosunku do wcześniejszego przykładu. Najpierw generujemy liczby. Po ich wygenerowaniu i wypisaniu wątek główny praktycznie wykonał swoje zadanie. Jeszcze tylko uruchamiamy dwa wątki potomne, których zadaniem jest obliczenie sumy liczb parzystych i nieparzystych. (linijka 39 i 40). Następnie, używając metody join, wątek główny czeka na zakończenie swoich dwóch potomków. Zauważ, że obydwie funkcje liczenia sumy liczb wykonują się równolegle.

Synchronizacja wątków – po co i dlaczego?

W powyższych przykładach była tylko jedna zmienna, którą współdzieliły wszystkie wątki. Była to tablica numbers[] przechowująca liczby, których suma była liczona. Wątki potomne tylko odczytywały tę tablicę. Zmienne evenSum i oddSum były modyfikowane tylko przez jeden wątek na raz, co wynika z kodu programu. Zastanów się, co się stanie, jeśli dwa wątki spróbują w tym samym momencie zapisać coś do tej samej zmiennej? Co się stanie, jeśli w tym samym momencie jeden wątek będzie próbował zapisać coś do zmiennej, a inny będzie odczytywał z tej samej zmiennej? Są to tzw. sytuacje hazardowe, których powinniśmy unikać za wszelką cenę. Służą do tego tzw. blokady. Istnieje co najmniej kilka typów blokad. Dzisiaj nauczymy się korzystać z najprostszej: muteksa.

Synchronizacja wątków – mutex – co to takiego?

Nie będziemy wchodzili w zawiłości budowy systemu operacyjnego i w to, jak muteksy realizowane są po jego stronie. Ogólnie możemy powiedzieć że muteks jest pewnego rodzaju obiektem. Muteks może być zablokowany lub nie. To, co znajduje się między wywołaniem metody lock i unlock będziemy nazywali sekcją krytyczną. W danej chwili tylko jeden wątek może otrzymać dostęp do sekcji krytycznej.

Synchronizacja wątków – przykład z życia

Przełóżmy sobie to na jakąś sytuację z życia. Wyobraźmy sobie, że mamy tylko jeden telefon, z którego chce w jednym momencie skorzystać 10 osób. Telefon jest naszą sekcją krytyczną (może być używany tylko przez jedną osobę na raz). Natomiast poszczególne osoby to uruchomione w SO wątki. Gdy nie zastosujemy synchronizacji, wszyscy rzucą się na raz do tego biednego telefonu i żadna osoba tak naprawdę nie będzie mogła z niego skorzystać. Chyba że się pobiją i wygra najsilniejszy 😉

Załóżmy, że naszym muteksem jest dłoń. Jeśli ktokolwiek dotyka telefonu, nikt inny nie może się do niego zbliżyć. Wtedy sytuacja staje się klarowna i nikt nie bije się o dostęp do telefonu. Kiedy pierwsza osoba chce zacząć korzystać z telefonu, dotyka go (blokada jest zakładana). Nikt inny nie może w tym momencie korzystać z urządzenia. Gdy ta osoba skończy korzystanie z telefonu, odkłada go (blokada jest zdejmowana). W tym momencie z urządzenia może skorzystać inny człowiek.

Synchronizacja wątków – zachowanie systemu operacyjnego

Gdy jakiś wątek dotrze do wywołania metody lock muteksa, system operacyjny mówi sobie: „Taki i taki wątek chce uzyskać dostęp do sekcji krytycznej. Czy powinienem mu na to pozwolić?” Następnie system operacyjny sprawdza, w jakim stanie jest muteks. Jeśli jest zablokowany, wtedy SO mówi „nie dostaniesz pozwolenia. Musisz zaczekać”. W przeciwnym wypadku: „wchodź proszę i wykonuj swoją pracę, a ja oznajmię innym wątkom, że nie mogą się tu dostać dopóki ty nie skończysz”. W tym momencie sekcja krytyczna jest blokowana i nikt inny oprócz wątku który właśnie do niej wszedł, nie ma do niej dostępu. Kiedy wątek zakończy swoją sekcję krytyczną i dotrze do wywołania metody unlock muteksa, system operacyjny oznacza muteks, a zarazem sekcję krytyczną jako dostępną dla innych wątków.

Gdy brakuje synchronizacji …

#include <iostream> #include <thread> #include <chrono> using namespace std; int counter = 0; void manageCounter(int id, int wart, bool increment) { for (int i = 0; i < wart; i++) { int localcounter = counter; if (increment == true) { localcounter++; this_thread::sleep_for(chrono::microseconds(50)); } else { localcounter=localcounter-1; } counter = localcounter; } } int main() { thread thr1(manageCounter, 0, 100, true); thread thr2(manageCounter, 1, 100, false); thread thr3(manageCounter, 2, 100, true); thread thr4(manageCounter, 3, 100, false); thread thr5(manageCounter, 4, 100, true); thread thr6(manageCounter, 5, 100, false); thr1.join(); thr2.join(); thr3.join(); thr4.join(); thr5.join(); thr6.join(); cout <<"Ostateczna wart. licznika:"<<counter <<endl; return 0; }

W powyższym programie popełnione zostało kilka ewidentnych błędów, które mają unaocznić ci, co się stanie jeśli zapomnisz o synchronizacji wielu wątków podczas dostępu do współdzielonych zmiennych.

Co robi funkcja manageCounter? Jak sama nazwa wskazuje, zarządza licznikiem. Wartość licznika najpierw kopiowana jest do lokalnej zmiennej. Następnie, w zależności od argumentu, jest on zwiększany albo zmniejszany określoną liczbę razy. W linijce 11 zauważyłeś pewnie wywołanie funkcji sleep_for. Usypia ona wywoływanie wątku na określony czas. Użyliśmy tej funkcji, aby „symulować”, że zwiększanie wartości licznika zajmuje nieco więcej czasu niż odejmowanie.

Przeanalizujmy działanie funkcji main. Uruchamiamy 6 wątków. Trzy z nich zwiększają wartość licznika. Trzy z nich ją obniżają. Logika podpowiada, że na samym końcu wartość licznika powinna wynosić 0. W końcu niezależnie od tego, w jakiej kolejności wątki będą wykonywały swoją pracę, to sumując wszystkie wykonywane przez nie obliczenia zawsze powinna wyjść taka sama wartość – 0. Czy tak rzeczywiście jest? Otóż, nie do końca. Uruchom program zaprezentowany powyżej i sam się przekonaj.

Dlaczego powyższy program nie działa?

Rozpiszmy sobie, jak powyższy program może działać. Pamiętasz jak mówiliśmy na samym początku, że każdy wątek otrzymuje co jakiś czas dostęp do procesora? Gdy ten czas upłynie, dostęp do CPU odbierany jest aktualnemu wątkowi i przekazywany kolejnemu. Zobaczmy, jakie są możliwe scenariusze wykonania powyższego programu gdy nie zadbamy o synchronizację.

Przypadek I

Wątek 1Wątek 2Wartość zmiennej globalnej counter
int localcounter = counter; ——-0
localcounter++;——-0
——-int localcounter = counter;0
——-localcounter=localcounter-1;0
——-counter=localcounter;-1
counter=localcounter;——-1

Przypadek II

Wątek 1Wątek 2Wartość zmiennej globalnej counter
int localcounter = counter;——0
localcounter++;——0
——int localcounter = counter;0
——localcounter=localcounter-1;0
counter=localcounter;——1
——counter=localcounter;-1

Przypadek III

Wątek 1Wątek 2Wartość zmiennej globalnej counter
int localcounter = counter;——0
localcounter++;——0
counter=localcounter;——1
——int localcounter = counter;1
——localcounter = localcounter-1;1
——counter=localcounter;0

Co tu się wydarzyło?

Jak zauważyłeś, ten prosty przykład kodu bez zastosowania synchronizacji może dać za każdym uruchomieniem całkowicie inne wyniki. W zależności od tego, kiedy wątek zostanie wywłaszczony (czyli kiedy system operacyjny zdecyduje się przekazać CPU innemu zadaniu), ostateczny wynik działania programu może być całkowicie różny. Zjawisko to przedstawiają tabelki znajdujące się nieco wyżej. Na trzy rozważone przypadki, jedynie w przypadku III wątki były wywłaszczane w takiej kolejności która zapewniała że wynik działania programu był prawidłowy.

Zastosujmy blokady

Dowiedziałeś się wcześniej wcześniej, jak działa muteks. Zastosujmy go w praktyce. Zastanówmy się: który fragment kodu powinien być wykonywany i kończony tylko przez jeden wątek na raz? Desynchronizacja może zacząć się już w linijce 7, w momencie, kiedy wartość globalnej zmiennej counter przypisujemy do lokalnej localcounter. Wynika to także z analizy powyższych tabelek. Niebezpieczny fragment kodu kończy się w linijce 15, kiedy do zmiennej globalnej counter przypisujemy nową wartość licznika localcounter. Naprawmy ten błąd i zastosujmy muteksy.

#include <iostream> #include <thread> #include <chrono> #include <mutex> using namespace std; int counter = 0; mutex mut; void manageCounter(int id, int wart, bool increment) { for (int i = 0; i < wart; i++) { mut.lock(); int localcounter = counter; if (increment == true) { localcounter++; this_thread::sleep_for(chrono::microseconds(50)); } else { localcounter=localcounter-1; } counter = localcounter; mut.unlock(); } } int main() { thread thr1(manageCounter, 0, 100, true); thread thr2(manageCounter, 1, 100, false); thread thr3(manageCounter, 2, 100, true); thread thr4(manageCounter, 3, 100, false); thread thr5(manageCounter, 4, 100, true); thread thr6(manageCounter, 5, 100, false); thr1.join(); thr2.join(); thr3.join(); thr4.join(); thr5.join(); thr6.join(); cout <<"Ostateczna wart. licznika:"<<counter <<endl; return 0; }

Skompiluj i uruchom powyższy program. Widzisz, że teraz zawsze zwraca on wartość 0? Jest to oczekiwany przez nas wynik. Przeanalizujmy, jak muteks pozwolił nam uporać się z problemem braku synchronizacji.

Obiekt muteksu zadeklarowaliśmy globalnie. Dlaczego? Ponieważ każdy wątek musi mieć dostęp do tego samego muteksu. System operacyjny musi sobie zapisać, że ten i ten muteks jest zablokowany przez dany wątek. Jeśli inny wątek próbuje zablokować ten sam muteks, system operacyjny nie może mu na to pozwolić. Gdybyśmy obiekt muteksa utworzyli wewnątrz funkcji manageCounter każdy wątek utworzyłby swojego własnego muteksa. Wtedy de facto nie działałyby one poprawnie, gdyż stan obiektu muteksa nie byłby współdzielony między wątkami.

Synchronizacja wątków – proste zadanie

Przypomnij sobie przykład z ludźmi, którzy chcieli skorzystać z jednego telefonu. Twoim zadaniem będzie rozwiązanie postawionego w tamtym przykładzie problemu. Spójrz na poniższy kod.

#include <iostream> #include <thread> #include <mutex> const int SIZE=10; using namespace std; int counter = 0; void call(int personID) { cout<<"Osoba nr :"<<personID<<" dzwoni"<<endl; } void fight(int personID) { cout<<"Osoba nr :"<<personID<<"walczy o dostęp do telefonu"<<endl; } void useThePhone(int personID) { while(counter>0) fight(personID); counter++; call(personID); counter= counter-1; } int main() { thread thr1(useThePhone,0); thread thr2(useThePhone,1); thread thr3(useThePhone,2); thread thr4(useThePhone,3); thr1.join(); thr2.join(); thr3.join(); thr4.join(); }

Funkcja fight nie powinna być uruchomiona ani raz. Wszyscy powinni grzecznie ustawić się w kolejkę i skorzystać z telefonu. Zabezpiecz powyższy program za pomocą muteksów.

Wątki – co dalej?

Ten wpis na blogu zawiera jedynie same podstawy korzystania z wątków. Istnieje wiele więcej problemów synchronizacji niż te które poznałeś w tym artykule. Biblioteka standardowa udostępnia także kilka przydatnych w pracy wielowątkowej klas, których nie poznaliśmy do tej pory. Niemniej, podstawy te pozwoliły ci z pewnością zrozumieć czym jest wielowątkowość, jak ją zastosować w języku C++. Zrozumiałeś także (jeśli wykonałeś zadanie domowe :)) jak działa synchronizacja wątków za pomocą muteksa.

Opublikowany w C++

3 komentarze do “Aplikacje wielowątkowe w języku C++

  1. Konrad Odpowiedz

    Nie wydaje mi się, żeby słowo hazardous tłumaczyło się na polski jako hazardowy. Hazardous to po prostu niebezpieczny.

  2. Piter90 Odpowiedz

    Super. Fajnie zrozumiałem podstawowe działanie wątków poprzez dobry dobór przykładów. Dziękówa 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.