C++0x to propozycja nowego standardu języka C++. Została ona zaakceptowana przez komitet ISO 12 sierpnia 2011 roku. Tak oto narodził się C++11. Ponieważ zawiera on dużo zmian i nowości, zarówno po stronie samego języka, jak i biblioteki standardowej, warto się z nimi zapoznać już teraz. W tym odcinku omówiona zostanie jedna z najdłużej wyczekiwanych zmian, która w końcu doczekała się realizacji. Mowa oczywiście o wątkach.
Stopień trudności: 2
Dowiesz się:
Powinieneś wiedzieć:
Jeszcze nie tak dawno mówiło się dość powszechnie o nieprzywiązywaniu większej wagi do wydajności tworzonego kodu. Po co spędzać dziesiątki czy setki godzin przed kompilatorem, by przyspieszyć przetwarzanie o 20-30%, jeśli za półtora roku standardem będzie dwukrotnie szybszy sprzęt więc program i tak będzie chodził znacznie szybciej? Nazwano to zjawisko ,,darmowym lunchem'', ponieważ raz napisany program, wraz z upływem czasu, wykonywał się szybciej, bez żadnej ingerencji ze strony twórcy.
Przed paru laty stwierdzenie to okazało się jednak mało adekwatne do rzeczywistości - na rynek weszły pierwsze, powszechnie dostępne, procesory wielordzeniowe. Przyrost częstotliwości taktowania procesorów zmalał praktycznie do zera. W zamian pojawiły się nowe architektury i dodatkowe rdzenie. Jednak by móc je wykorzystać, program musiał robić z nich użytek. Programowanie wielowątkowe stało się koniecznością. Zjawisko to trafnie podsumował Herb Sutter, w swoim artykule z 2005 roku pt. ,,The free lunch is over: a fundamental turn toward concurrency in software''.
Aby móc wykorzystać rdzenie, program musi więc wykonywać się równolegle. Ale to jeszcze nie koniec. Zwykły podział aplikacji na 2 lub 4 wątki logiczne, żeby wysycić 2-4 rdzeni, najczęściej obecnie spotykanych, to jedynie połowiczne rozwiązanie. Wykorzystamy obecny dziś sprzęt do maximum, ale co ze sprzętem jutra? Jak wiadomo z prawa Moore'a liczba tranzystorów w procesorach będzie się podwajać co około 1,5-2 lata. Więc nasz program, napisany na czterordzeniowy procesor nie przyspieszy wykonywania na nowym, ośmiordzeniowym procesorze. Dlaczego? Ponieważ nie wykorzysta dodatkowych 4 rdzeni. Aby napisać program naprawdę przyszłościowo, musimy móc wykorzystać dowolnie dużą liczbę rdzeni, jaką zapewne będziemy mieli dostępne w bliskiej przyszłości. Przecież już w 2009 roku Intel zaprezentował 48-rdzeniowy procesor (,,single-chip cloud computer''). Obecnie w sklepach można kupić ośmio i dwunastordzeniowe układy. Rolą twórców oprogramowania jest zapewnienie odpowiedniej skalowalności, ze względu na liczbę rdzeni.
Najpowszechniejszym sposobem na wykorzystanie wielu rdzeni, w ramach jednej aplikacji, jest zastosowanie wątków. C++03 niestety nie wspominał o nich ani słowem. Idąc z duchem czasu, dodanie wsparcia dla wątków, zgodnych z najnowszym trendami, stało się jedną z najbardziej wyczekiwanych nowości w C++11.
Nim przejdziemy do konkretów, autorzy czują się zobowiązani jasno zdefiniować cel i zakres niniejszej publikacji. O programowaniu współbieżnym napisano wiele książek. Nie jest to zbieg okoliczności. Temat jest niezwykle rozległy i wymagający od twórcy systemu informatycznego. Część standardu C++11 dotycząca wielowątkowości to około 70 stron technicznej specyfikacji. Wprowadzonych zostało dużo pojęć, klas, funkcji, szablonów... Samo ich omówienie spokojnie mogłoby zająć kilka artykułów. Postanowiono więc zrezygnować z opisywania podstawowych zagadnień związanych z programowaniem wielowątkowym i skupić się na najważniejszych spośród nowych elementów, dostarczanych przez C++11. Samych zmian jest jednak dużo, więc i tu tematyka została ograniczona do najważniejszych elementów języka i biblioteki standardowej. Choć przegląd taki odległym będzie od pełnego, znajomość tychże zagadnień pozwoli na swobodne posługiwanie się wielowątkowością C++11 oraz umożliwi dalsze zdobywanie wiedzy z tej dziedziny.
Podobnie jak to miało miejsce w poprzednich artykułach, przedstawiony materiał został dobrany nie tylko według subiektywnego uznania autorów co do praktycznej użyteczności, ale i obiektywnego faktu obecnego wsparcia ze strony kompilatorów. Okazuje się, że kompilatory w kwestii współbieżności mogą się pochwalić jedynie częściowym wsparciem. Jakby tego było mało, sama propozycja standardu zmieniała się w czasie i w kilku miejscach GCC zawiera ,,stare'' elementy zmienione w kolejnych wersjach. Pewnych istotnych elementów takich jak na przykład pamięć lokalna wątku (ang. thread local storage) nie ma jeszcze zaimplementowanej wcale. Inne, testowane przez autorów kompilatory, mają wsparcie jeszcze bardziej ubogie, lub wręcz żadne.
Kompilowanie przykładów
Do kompilowania przykładów należy użyć kompilatora GCC w wersji 4.6, ustawiając standard języka na C++0x: g++-4.6 -Wall -std=c++0x src.cpp -lpthread Większość prezentowanych fragmentów kodu NIE będzie działać na starszych wersjach, które miały minimalne lub żadne wsparcie dla C++0x. W przypadku nowszych wersji GCC niektóre przykłady mogą wymagać drobnych modyfikacji. Pamiętajmy, że w chwili wydania GCC 4.6 C++0x nie był zatwierdzonym standardem. Oprócz tego GCC nie jest jeszcze w pełni zgodny z propozycją standardu języka. |
Na użytkowników GCC czyha jeszcze jedna mała ,,pułapka''. Program używający wątków musi zostać skompilowany z opcją -lpthread. Jeśli się o niej zapomni, program się skompiluje i zlinkuje (!) lecz po uruchomieniu zgłosi wyjątek std::system_error o treści Operation not permitted. Oczywiste, prawda?
Ale do rzeczy. Na początek kilka przykładów, łudząco przypominających coś znanego...
Zestaw bibliotek Boost od dawna słyną z bycia źródłem ,,przecieków'' rozwiązań do standardu języka C++. Sprawa nie ma się inaczej w przypadku wątków. Wątki w C++11 bardzo przypominają te znane programistom z biblioteki Boost/Thread. Prostą aplikację, korzystającą z wątków przedstawiono na listingu 1a.
#include <iostream> #include <thread> using std::cout; using std::endl; int main(int, char **argv) { auto call=[&]{ cout<<argv[0]<<": hello thread " <<std::this_thread::get_id()<<endl; }; std::thread th{call}; th.join(); return 0; } |
Listing 1a: Przykładowa aplikacja używająca wątków. |
Aby móc wykorzystywać wątki, należy załączyć nagłówek threads. Deklaruje on klasę std::thread, będącą uchwytem naszego wątku. Jako parametr przyjmuje wywołanie, jakie ma się wykonać w osobnym wątku. Może to być funktor, funkcja, lub wyrażenie lambda (jak w przykładzie).
Warto w tym miejscu zaznaczyć szczególną użyteczność lambd, w połączeniu z wątkami. Uruchamiając wątek, bardzo często chcemy jedynie wywołać, wcześniej przygotowaną funkcjonalność, z pewnymi zadanymi parametrami. Chodzi nam więc jedynie o przygotowanie prostego adaptera interfejsu, do użycia na potrzeb uruchomienia wątku. Wyrażenia lambda pasują tu jak ulał.
Podobnie jak to miało miejsce w bibliotece boost/thread, programista otrzymuje do dyspozycji przestrzeń nazw std::this_thread. Umożliwia ona wykonanie pewnych operacji na bieżącym wątku. Na listingu 1a używana jest ona do pobrania identyfikatora wątku, za pomocą funkcji get_id().
Wątek uruchamia się automatycznie, po utworzeniu obiektu std::thread. Jednak nie łączy się automatycznie, podczas niszczenia obiektu uchwytu. Trzeba więc zadbać o to, by zawołać metodę join() przed zniszczeniem obiektu. Choć na pierwszy rzut oka wydaje się to dziwne, ma swoje uzasadnienie. Przykładowo, jeśli obiekt std::thread byłby niszczony na skutek zwijania stosu, po zgłoszeniu wyjątku, destruktor wołający metodę join() mógłby zatrzymać program, czekając na połączenie wątku, który nawet nie wie, że powinien się skończyć. Wątek jednak trzeba jakoś zatrzymać. Aby zrobić to możliwie ,,najtaniej'', pomijając wszelkie narzuty, postanowiono pozostawić ten aspekt programiście, znającemu kontekst działania. Mówiąc krótko - automatycznie zatrzymywanie i łączenie wątku należy zaimplementować samodzielnie, zgodnie z istniejącymi potrzebami.
Zachowanie takie jest pewną ,,nowością''. Klasa boost::thread zawiera metodę interrupt(), pozwalającą przerwać wykonywanie wątku (w przeznaczonych do tego miejscach). Zrezygnowano jednak z tego rozwiązania, zgodnie z zasadą przyświecającą językowi C++: ,,użytkownik nie powinien płacić za coś, z czego nie korzysta''.
Klasa wątku jest też oczywiście niekopiowalna. Jednak można śmiało ją przenosić, co znakomicie ułatwia umieszczanie wątków w kolekcjach oraz jako pól składowych innych klas.
Na potrzeby niniejszego artykułu kwestię automatycznego łączenia wątków pominiemy. Poprawi to nieco obrazowość przykładów i pozwoli skupić się na meritum. W najprostszym przypadku można to zadanie wykonać za pomocą krótkiej klasy pomocniczej, wykonującej if( th_.joinable() ) th_.join() w destruktorze. Należy pamiętać o sprawdzeniu, czy wątek może być połączony, nawet gdy nie przewidujemy możliwości jego odłączania (std::thread::detach()), gdyż wątek traci tę właściwość po przeniesieniu własności obiektu.
Przykład z listingu 1a był bardzo mocno uproszczony. Pokazał jak uruchomić wątek, jednak z jego uruchomienia nie wynikały żadne korzyści. W praktyce, by praca na wielu wątkach była opłacalna, przeważnie wymagana jest jakaś wymiana informacji pomiędzy uruchomionymi równolegle zadaniami - choćby zwrócenie wyniku. Aby dostęp taki dało się bezpiecznie przeprowadzić, potrzebny jest mechanizm kontroli, gwarantujący wyłączny dostęp na czas operacji odczytu/zapisu.
Do realizacji blokad w C++11 wprowadzono nowe koncepty, wymagające istnienia odpowiednich metod:
W praktyce Wykluczanie realizowane jest za pomocą gotowych klas mutexów, takich jak np.: std::mutex oraz std::recursive_mutex. Obie te klasy realizują koncept ,,lockable''. Przykład użycia mutexu przedstawiono na listingu 2a. Klasa IDGenerator definiuje generyczny interfejs generatora numerów ID, bezpiecznego ze względu na użycie z wielu wątków.
#include <mutex> // ... class IDGenerator { public: virtual ~IDGenerator(void) { } std::string generate(void) { m_.lock(); const std::string id=generateImpl(); m_.unlock(); return id; } private: virtual std::string generateImpl(void) = 0; std::mutex m_; }; |
Listing 2a: ,,Ręczne'' użycie mutexu. |
W kodzie widać wyraźnie sekcję krytyczną. Przykład ten posiada jednak bardzo poważny problem. Co się stanie, gdy implementacja generateImpl() zgłosi wyjątek? Po pierwsze użyty mutex nigdy nie zostanie już odblokowany - każde kolejne wywołanie generate() zatrzyma się więc. Po drugie, nawet gdyby obiekt nie został już nigdy więcej użyty, okazuje się, że kończąc działania programu/funkcji musimy zniszczyć mutex, który jest zablokowany a tego robić nie wolno (niezdefiniowane zachowanie). Jeśli więc wyjątek zostanie zgłoszony, nasz program na pewno przestanie działać poprawnie.
Aby zapobiec tego typu problemom, wprowadzono mechanizm analogiczny do inteligentnych wskaźników. Dodane zostały specjalne klasy, które w konstruktorach blokują mutexy, w destruktorach zaś je zwalniają. Podejście takie gwarantuje silne bezpieczeństwo ze względu na wyjątki - nieważne co się stanie wewnątrz implementacji, blokada zawsze zostanie zniesiona, podczas opuszczania danego zakresu. Kod prezentujący użycie std::unique_lock przestawiono na listingu 2b.
#include <mutex> // ... class IDGenerator { public: virtual ~IDGenerator(void) { } std::string generate(void) { std::unique_lock<std::mutex> lock(m_); return generateImpl(); } private: virtual std::string generateImpl(void) = 0; std::mutex m_; }; |
Listing 2b: Poprawne użycie mutexu, bezpieczne ze względu na wyjątki. |
Wprowadzenie pojęcia ,,lockable'' oznacza jednak znacznie więcej, niż tylko zdefiniowanie interfejsu dla klasy std::mutex. Możliwe stało się używanie własnych klas, zgodnych z tym konceptem, razem z algorytmami blokowania, z biblioteki standardowej.
Typową sytuacją, w której zwykłe blokowanie każdej metody z osobna, nie rozwiązuje problemu jest pobieranie danych, z bezpiecznej ze względu na wątki kolejki, tak długo, jak tylko są one dostępne. Okazuje się bowiem, że aby taki fragment kodu zrealizować, potrzebna jest blokada obejmująca więcej niż jedno wywołanie metody. Problematyczną sytuację przedstawiono na listingu 2c. Listing zakłada istnienie szablonu kolejki Queue, w którym każda metoda z osobna posiada blokowanie mutexu, na własne potrzeby.
int main(void) { typedef Queue<std::string> StrQueue; StrQueue q; // ... if( !q.empty() ) { // Oops - koejka może być już pusta auto e=q.peek(); // Oops - inny wątek mógł także pobrać tę samą wartość q.pop(); cout<<e<<endl; } return 0; } |
Listing 2c: Problem - wyścig, związany z odblokowywaniem mutexu pomiędzy wywołaniami metod. |
Mając do dyspozycji gotowe, generyczne mechanizmy, można śmiało korzystać z gotowych klas i używać ich przemiennie z bazowymi elementami. Klasę implementującą kolejkę zgodną z ,,basic lockable'' oraz jej użycie pokazano na listingu 2d.
template<typename T> class BasicLockableQueue { public: void lock(void) { m_.lock(); } void unlock(void) { m_.unlock(); } void push(T t) { assert( m_.try_lock()==false ); q_.push_back( std::move(t) ); } T peek(void) { assert( m_.try_lock()==false ); return q_.front(); } void pop(void) { assert( m_.try_lock()==false ); q_.pop_front(); } bool empty(void) const { assert( m_.try_lock()==false ); return q_.empty(); } private: mutable std::mutex m_; std::deque<T> q_; }; // ... typedef BasicLockableQueue<std::string> StrQueue; StrQueue q; // ... { std::unique_lock<StrQueue> lock(q); if( !q.empty() ) { auto e=q.peek(); q.pop(); lock.unlock(); cout<<e<<endl; } } |
Listing 2d: Kolejka zgodna z ,,basic lockable'' oraz jej użycie. |
Dzięki zastosowaniu ,,zewnętrznego'' blokowania możliwe stało się wykonywanie kilku operacji w ramach jednej blokady. Aby ułatwić życie programistom, dodane zostały asercje, sprawdzające czy blokady zostały założone, przed wykonaniem faktycznego kodu. Aby możliwie najbardziej skrócić sekcję krytyczną, wykorzystanie zmiennej zostało poprzedzone jawnym zdjęciem blokady, która nie jest już w tym miejscu potrzebna.
Mówiąc o blokadach nie sposób nie wspomnieć o zakleszczeniach (ang. deadlocks). Jednym ze sposobów ich unikania jest blokowanie zasobów w dowolnej, ale zawsze tej samej kolejności. Aby ułatwić to zadanie, biblioteka standardowa dostarcza funkcji lock(), wykonującej właśnie to zadanie. Pobiera ona dowolną liczbę mutexów do zablokowania i blokuje je w odpowiedniej kolejności. Następnie można ,,ubrać'' zablokowane mutex'y w klasy zapewniające odpowiednie zwolnienie blokad. Przykład takiej sytuacji pokazano na listingu 2e.
std::mutex m1; std::mutex m2; lock(m1, m2); std::unique_lock<std::mutex> l1(m1, std::adopt_lock); std::unique_lock<std::mutex> l2(m2, std::adopt_lock); |
Listing 2e: Bezpieczne blokowanie grupy mutexów. |
W ten sposób jednocześnie unikamy zakleszczeń oraz problemów związanych z potencjalnym zgłoszeniem wyjątku, gdzieś dalej w kodzie.
Zapewne każdy programista, programujący dłużej w C++, spotkał się z koniecznością zainicjowania jakichś globalnych danych. Przykładem może tu być zewnętrzna biblioteka wymagająca wykonania initCosTam() przed jej faktycznym użyciem. Jeśli okaże się koniecznym zapewnienie bezpiecznego zainicjowania, przed wejściem do funkcji main(), pojawiają się problemy (kolejność inicjalizacji obiektów globalnych w C++ jest niezdefiniowana). Jeśli do tego jeszcze doda się wątki, problem zaczyna się robić mocno nietrywialny i wymaga nie lada wprawy, by go rozwiązać. Żeby tego dokonać, trzeba się uciekać do pewnych trików i meandrów języka. Kod staje się nieczytelny i zależny od wielu subtelności implementacji, które wymagają dokładnego udokumentowania.
Twórcy standardu C++11 postanowili zaadresować również i ten problem, w sposób prosty i przejrzysty. Wprowadzono funkcję std::call_once oraz flagę std::once_flag, na której owa funkcja operuje. Zakładając konieczność jednorazowego wywołania funkcji initSomeLib(), przykładowa inicjalizacja, wykorzystująca omawiany mechanizm, została przedstawiona na listingu 3a.
#include <thread> void initSomeLib(void); std::once_flag g_initSomeLibDone; void secureInitSomeLib(void) { std::call_once(g_initSomeLibDone, initSomeLib); } // ... secureInitSomeLib(); // OK - bezpieczna inicjalizacja secureInitSomeLib(); // OK - nic nie robi |
Listing 3a: Bezpieczna inicjalizacja zewnętrznej biblioteki. |
Ze względu na zastosowanie mechanizmu bezpiecznej initcjalizacji wewnątrz otoczki w postaci funkcji secureInitSomeLib(), której można w programie używać ,,do woli'', pozbyliśmy się problemu wyścigów wątków oraz konieczności pilnowania, aby biblioteka została zainicjowana dokładnie raz. Kod znacznie też zyskał na czytelności i stał się przenośny.
Tworząc oprogramowanie pracujące na wielu wątkach prędzej czy później pojawia się konieczność rozdzielania pracy za pomocą kolejki zadań. Kiedy zaś pojawia się kolejka zadań, pojawia się także potrzeba oczekiwania na dane. Innymi słowy - potrzeba nam zmiennej warunkowej.
Definicja biblioteki wątków bez zmiennej warunkowej byłaby oczywiście niekompletna, więc i biblioteka standardowa C++11 udostępnia odpowiednią implementację... A w zasadzie dwie: condition_variable oraz condition_variable_any, obie zawarte w pliku nagłówkowym o nazwie condition_variable.
Różnica między condition_variable a condition_variable_any to wspierane blokady. Pierwsza klasa używa std::unique_lock<std::mutex>, podczas gdy druga może pracować z dowolnymi blokadami (wystarcza zgodność z ,,basic lockable'').
Przykładowe zastosowanie condition_variable, do implementacji kolejki, przedstawiono na listingu 4a. Dla skrócenia przykładu do minimum założono, że elementy kolejki są przenoszalne. Obostrzenie takie pozwala bezpiecznie zaimplementować pojedynczą metodę pop(), która jednocześnie pobiera element, usuwa go z kolejki i zwraca do wołającego.
template<typename T> class CondQueue { private: typedef std::unique_lock<std::mutex> Lock; public: void push(T t) { Lock lock(m_); q_.push_back( std::move(t) ); nonEmpty_.notify_one(); } T pop(void) { Lock lock(m_); while( q_.empty() ) nonEmpty_.wait(lock); T tmp=std::move( q_.front() ); q_.pop_front(); return tmp; } private: mutable std::mutex m_; std::condition_variable nonEmpty_; std::deque<T> q_; }; typedef CondQueue<std::string> StrQueue; void consumer(StrQueue &q) { cout<<std::this_thread::get_id()<<": waiting to consume"<<endl; const auto e=q.pop(); cout<<std::this_thread::get_id()<<" got "<<e<<endl; } int main(void) { StrQueue q; // konsumenci std::vector<std::thread> ths; for(size_t i=0; i<30; ++i) ths.push_back( std::thread{ [&]{consumer(q);} } ); // producent for(size_t i=0; i<ths.size(); ++i) q.push( boost::lexical_cast<std::string>(40+i) ); // sprzątanie cout<<"cleanup..."<<endl; std::for_each(ths.begin(), ths.end(), [](std::thread &t){ t.join(); }); return 0; } |
Listing 4a: Kolejka zaimplementowana z użyciem zmiennej warunkowej. |
Podobnie jak miało to miejsce w wątkach zgodnych z POSIXem, czekanie na zmiennej warunkowej (wait(lock)) może zostać przerwane spontanicznie, mimo braku wywołania notify_one() ani notify_all(). Choć takie założenie może się wydawać dość dziwne, z punktu widzenia programisty, jest znacznym uproszczeniem mechanizmu po stronie systemu. Jednak, by program zachowywał się zgodnie z założeniami, programista musi samodzielnie zadbać o sprawdzenie, czy warunek jest już spełniony, czy też wątek został niepotrzebnie obudzony. Dlatego też oczekiwanie na zmiennej warunkowej jest obudowane w pętlę, sprawdzającą warunek konieczny do dalszego przetwarzania (tu - czy kolejka (nie) jest pusta).
Wątki są nowością w C++11, względem C++03. Choć jest to prawdą w kwestii specyfikacji, producenci kompilatorów od dawna już dawali możliwość korzystania z nich za pomocą zewnętrznych bibliotek, takich jak na przykład pthreads. Ponieważ nie posiadały one jednak wsparcia od strony języka, pewne rzeczy były co najmniej mocno nietrywialne do zaimplementowania...
Przykładem takiego problemu były wyjątki, jakie mogą się pojawić w kodzie wykonywanym w wątku, dokładnie tak samo, jak ma to miejsce w ,,zwykłym'' programie. Co zrobić w wątku, gdy wyjątek się pojawi? Jak przekazać wyjątek do programu głównego? C++11 ma na te pytania eleganckie rozwiązanie, z pogranicza języka i biblioteki, a imię jego to std::exception_ptr.
Klasa std::exception_ptr jest uniwersalnym wskaźnikiem na wyjątek. Może przechowywać dowolny wyjątek, jaki zostanie złapany. By jej istnienie miało sens potrzeba oczywiście jeszcze 2 rzeczy: możliwości przechwycenia i zapisania dowolnego wyjątku oraz jego ponownego zgłoszenia. Do przechwytywania dowolnych wyjątków w C++03 wykorzystywano konstrukcję catch(...), która jednak nie dawała dostępu do konkretnego obiektu. Aby móc zapisać przechwycony wyjątek, wprowadzono więc funkcję std::current_exception(), zwracającą obiekt typu std::exception_ptr. Ponowne zgłoszenie wyjątku przechowywanego wewnątrz std::exception_ptr jest realizowane za pomocą funkcji std::rethrow_exception
Przykład przenoszenia wyjątku pomiędzy wątkiem, a programem głównym przedstawiono na listingu 5a. Tworzony jest na nim nowy wątek, który (losowo) może zakończyć się zgłoszeniem i przechwyceniem wyjątku ExceptionA, albo ExceptionB, albo zwykłym wyjściem (brak błędu). Po zakończeniu programu głównego, sprawdzana jest zawartość zmiennej ptr, ustawianej w wątku, w przypadku zgłoszenia błędu. Jeśli błąd został wykryty, wyjątek jest ponownie zgłaszany w programie głównym, zachowując odpowiedni typ.
struct ExceptionA {}; struct ExceptionB {}; void thrower(std::exception_ptr &ptr) { try { switch( rand()%3 ) { case 0: throw ExceptionA{}; case 1: throw ExceptionB{}; case 2: break; } } catch(...) { ptr=std::current_exception(); } } // ... std::exception_ptr ptr; std::thread th( [&ptr]{thrower(ptr);} ); th.join(); try { if(ptr) std::rethrow_exception(ptr); cout<<"all ok!"<<endl; } catch(const ExceptionA &) { cout<<"ExceptionA"<<endl; } catch(const ExceptionB &) { cout<<"ExceptionB"<<endl; } |
Listing 5a: Przenoszenie zgłoszonego wyjątku pomiędzy wątkiem a programem głównym. |
Przenoszenie zgłoszonego wyjątku między wątkami jest przydatne do raportowania błędów. Prócz ,,ręcznego'' przekazywania, mechanizm ten znajduje jeszcze jedno, istotne zastosowanie. Ale o tym za chwilę...
Co kryje przyszłość? Odpowiedź na to pytanie kryje się w pliku nagłówkowym future... :-)
Ponieważ wiele typowych zadań, wymagających zastosowania wątków, sprowadza się do przekazywania danych do obliczeń oraz zebrania wyników (wątki robocze), wprowadzony został mechanizm ułatwiający tę operację. Pomysł jest realizowany za pomocą szablonów std::promise oraz std::future.
std::promise to klasa, której obiekty znajdują się ,,po stronie wątku''. Obiekty klasy std::future są powiązane z konkretnymi instancjami std::promise. Posiadacz obiektu klasy std::future ma możliwość odczytania wyniku, za pomocą metody get(). Jeśli wynik nie jest jeszcze gotowy, metoda zatrzyma działanie programu, do czasu uzyskania konkretnej wartości, jaka może zostać zwrócona. Wartość taka może zostać zapisana przez wątek liczący, poprzez zawołanie metody set_value(), na odpowiednim obiekcie klasy std::promise.
Cały mechanizm komunikacji sprowadza się więc do ,,złożenia obietnicy'' wykonania pewnych obliczeń, przez wątek posiadający instancję std::promise (metoda get_future()). Obietnica owa może być wykorzystana w programie w dowolnym momencie, tak jakby była gotowa do pobrania (choć odczyt wartości może zablokować wykonywanie programu).
Przykładowy program używający mechanizmu future/promise został zaprezentowany na listingu 6a. Zastosowano w nim całkowanie numeryczne zadanej funkcji (func()), na zadanym przedziale (from, to), ze zadanym krokiem (dx). Program uruchamia żądaną liczbę wątków (thCount) liczących wyniki częściowe, dla zadanych, rozłącznych przedziałów. Uzyskana suma jest zapisywana w zmiennej ,,obietnicy'', przypisanej do danego wątku. Wyniki końcowy jest uzyskiwany poprzez zsumowanie wyników cząstkowych wszystkich wątków liczących.
template<double (*Func)(double)> double integrate(double from, double to, double dx) { double sum=0; for(double x=from; x<to; x+=dx) sum+=(*Func)(x)*dx; return sum; } double func(double x) { return 2*x+1; } int main(void) { constexpr size_t thCount=2; // liczba wątków liczących constexpr double from =1.0; constexpr double to =100.0; constexpr double dx =0.000001; constexpr double step=(to-from)/thCount; std::promise<double> prom[thCount]; std::future<double> fut[thCount]; std::vector<std::thread> th; for(size_t i=0; i<thCount; ++i) { auto begin=from+i*step; auto end =from+(i+1)*step; fut[i]=prom[i].get_future(); auto f=[=,&prom]{ prom[i].set_value( integrate<func>(begin, end, dx) ); }; th.push_back( std::thread(f) ); } double sum=0; std::for_each(fut, fut+thCount, [&sum](std::future<double>&f){sum+=f.get();}); cout<<"result is "<<sum<<endl; std::for_each(th.begin(), th.end(), [](std::thread &t){t.join();}); return 0; } |
Listing 6a: Użycie future/promise w całkowaniu numerycznym zadanej funkcji. |
Czymże byłaby jednak możliwość zwracania wyniku obliczeń, bez możliwości zgłoszenia błędu? Aby uzupełnić funkcjonalność o ten aspekt, do klasy std::promise dodano metodę set_exception(). Jak nie trudno się domyślić, wykorzystuje ona poznany już mechanizm przekazywania wyjątków pomiędzy wątkami, do zgłoszenia błędu, podczas wołania std::future::get(). Przykład programu, zgłaszającego błąd za pomocą mechanizmu future/promise przedstawiono na listingu 6b.
struct Exception {}; void computer(std::promise<int> answer) { try { if( rand()%2 ) throw Exception{}; answer.set_value(42); } catch(...) { answer.set_exception( std::current_exception() ); } } // ... std::promise<int> prom; std::future<int> fut=prom.get_future(); std::thread th( [&]{ computer(std::move(prom)); } ); try { const int answer=fut.get(); cout<<"the answer is "<<answer<<endl; } catch(const Exception&) { cout<<"oops..."<<endl; } th.join(); |
Listing 6b: Przekazanie wyjątku za pomocą mechanizmu future/promise. |
Aby jeszcze bardziej ułatwić korzystanie z future/promise powstał specjalny szablon klasy std::packaged_task. Umożliwia on łatwe uruchamianie zadań i zbieranie wyników. Przykład użycia omawianego szablonu przedstawiono na listingu 6c.
template<double (*Func)(double)> double integrate(double from, double to, double dx) { double sum=0; for(double x=from; x<to; x+=dx) sum+=(*Func)(x)*dx; return sum; } double func(double x) { return 2*x+1; } // ... typedef std::packaged_task<double(double,double,double)> Task; constexpr size_t thCount=2; constexpr double from =1.0; constexpr double to =100.0; constexpr double dx =0.000001; constexpr double step=(to-from)/thCount; std::vector<std::thread> th; std::vector<Task> tasks; th.reserve(thCount); tasks.reserve(thCount); for(size_t i=0; i<thCount; ++i) { auto begin=from+i*step; auto end =from+(i+1)*step; tasks.push_back( Task{integrate<func>} ); auto f=[=,&tasks]{ tasks[i](begin, end, dx); }; th.push_back( std::thread(f) ); } double sum=0; std::for_each(tasks.begin(), tasks.end(), [&sum](Task &t){sum+=t.get_future().get();}); cout<<"result is "<<sum<<endl; std::for_each(th.begin(), th.end(), [](std::thread &t){t.join();}); |
Listing 6c: std::packaged_task jako zadanie całkujące. |
Parametrem szablonu std::packaged_task jest sygnatura, używana przez obiekty zadań. W tym przypadku jest to całkowanie zadanej funkcji, na podanym zakresie i z zdanym krokiem. Instancje klas Task tworzy się, podając odpowiednie zadanie (o odpowiedniej sygnaturze) jako parametr konstruktora. Obiekt zadania, podobnie jak klasa std::promise, posiada metodę get_future(), zwracającą ,,obietnicę'' wyniku. Mając zapisany obiekt tej klasy można uruchomić zadanie w osobnym wątku. Aby uczynić kod możliwie elastycznym, liczba wątków do uruchomienia jest parametrem. Ważnym jest przydzielenie odpowiedniej ilości pamięci wektorom, przed wejściem do pętli. Jeśli ten krok zostałby pominięty, wektor mógłby się rozszerzyć, podczas gdy jakieś wątki już używają zdefiniowanych zadań przez referencję, powodując niezdefiniowane zachowanie. Po uruchomieniu wątków dalsze używanie uzyskanej ,,obietnicy'' przebiega dokładnie tak samo, jak w poprzednich przykładach. Ze względu na użycie szablonu do podania funkcji uzyskaliśmy łatwo modyfikowalny kod. Całkowanie numeryczne funkcji liniowej nie jest specjalnie przydatne w praktyce, jednak wprowadzenie skomplikowanej funkcji, wymagającej scałkowania sprowadza się teraz do jej napisania i zmiany parametru szablonu.
Obiekty zadań, użyte na listingu 6c, żyją dłużej niż odpowiadające im wątki. Nic jednak nie stoi na przeszkodzie przeniesienia własności zadania do wątku. Należy jednak uważać, w przypadku łączenia tej właściwości z wyrażeniami lambda. Przykładowy kod-pułapkę pokazano na listingu 6d.
std::thread th; { SomeTask task; th=std::thread{ [&]{ SomeTask t=std::move(task); t(); } }; } th.join(); |
Listing 6d: Niepoprawne użycie wątków i wyrażeń lambda. |
Problemem jest wyścig pomiędzy niszczeniem obiektu task a przenoszeniem jego własności w kodzie wyrażenia lambda, które wykona się dopiero po faktycznym uruchomieniu wątku. Ponieważ uruchomienie kodu użytkownika w wątku przeważnie odbywa się później, niż niszczenie obiektu, zapewne uzyskamy sytuację przenoszenia już zniszczonego obiektu. W przypadku większych obiektów można także trafić na jednoczesne niszczenie i przenoszenie. Tak czy owak - sytuacja mocno problematyczna...
W niniejszym artykule przedstawione zostały podstawowe narzędzia i klasy dające dostęp do wielowątkowości w C++11. Początkowe omówienie podstawowych klas wątku i blokad oraz konceptów przygotowało czytelnika na nieco ciekawsze mechanizmy, takie jak zmienne warunkowe, na mniej typowych rozwiązaniach, takich jak pojedynczej inicjalizacji czy mechanizmie future/promise skończywszy.
Programowanie współbieżne jest już teraźniejszością nie tylko w środowiskach obliczeniowych i komputerach dużej mocy. Prócz wymiernych korzyści, w postaci krótszych czasów wykonania i lepszego wykorzystania dostępnych zasobów, pisanie programów równoległych to także świetna zabawa. Jest to zadanie jednocześnie wymagające i rozwijające wyobraźnię.
Nowe narzędzia języka C++11 pozwalają w sposób jeszcze bardziej przejrzysty i szybki tworzyć przenośny kod wielowątkowy. Z pewnością przyczyni się to do zwiększenia popularności współbieżności wśród programistów.
Jednak jak każda nowość, nim się ,,ustabilizuje'', może powodować sporo problemów. Implementacja wątków z C++11 w obecnych kompilatorach pozostawia wiele do życzenia. Jest to chyba najmniej opracowany aspekt implementacji nowego standardu. GCC nadal posiada niepełne wsparcie, zdarza się trafić na błąd lub (częściej) niewspierany aspekt. Visual Studio nie posiada jeszcze wcale wsparcia dla wątków C++11. W przypadku ICC oraz CLanga wsparcie również wygląda słabo.
Podczas wywiadu podsumowującego ,,C++ and Beyond'', Herb Sutter, Andrei Alexandrescu oraz Scott Meyers zostali zapytani, kiedy można będzie zacząć używać nowych elementów C++11 w kodzie produkcyjnym. Odpowiedź była prosta - należy się spodziewać pierwszych kompilatorów, wspierających nowy standard za około rok. W przypadku twórców kodu przenośnego zapewne trzeba będzie poczekać jeszcze rok dłużej, aż sytuacja na rynku się ustabilizuje.
Doświadczenie autorów pokrywa się z przytaczanymi opiniami. Warto ,,spróbować'' wątków C++11 i tworzyć przykładowe aplikacje, celem opanowania ich stosowania. Z użyciem ich w kodzie produkcyjnym radzimy się jednak jeszcze nieco wstrzymać.
Jak zostało wspominane na początku artykułu, powyższe opracowanie ma na celu jedynie zarysowania możliwości wątków C++11. Należy je potraktować jak wprowadzenie, mające ułatwić dalsze poznawanie nowej specyfikacji. Oprócz już działających pod GCC, lecz pominiętych, aspektów, takich jak synchroniczne i asynchroniczne uruchamianie zadań obliczeniowych w wątkach, z założenia pominięte zostały aspekty niezaimplementowane, takie jak zmienne atomowe czy pamięć lokalna wątku.
Choć pozostały nieopisane, są to nadal bardzo interesujące i przydatne praktycznie elementy. Autorzy gorąco zachęcają do dalszej lektury. Drzwi zostały już otwarte. Teraz każdy musi przez nie przejść samodzielnie, bo zobaczyć co jest dostępne w środku... :-)
W następnym odcinku cyklu przedstawiony zostanie mechanizm generowania liczb losowych. Pokazane zostaną także inne, drobne, acz przydatne elementy języka, takie jak nowa składnia funkcji, użycie decltype() oraz pętla for, pracująca na zakresach.
Bartosz Szurgot
Absolwent Informatyki wydziału Informatyki i Zarządzania Politechniki Wrocławskiej.
Obecnie pracuje we Wrocławskim Centrum Sieciowo-Superkomputerowym jako programista.
Główne zainteresowania techniczne to: programowanie, Linux, urządzenia wbudowane oraz elektronika.
W wolnym czasie tworzy oprogramowanie open-source oraz układy elektroniczne.
W C++ programuje od 9 lat.
Kontakt z autorem:
bartek.szurgot@baszerr.org
Strona domowa autora:
http://www.baszerr.org
Mariusz Uchroński
Absolwent Elektroniki i Telekomunikacji wydziału Elektroniki Politechniki
Wrocławskiej oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego.
Główne nurty zainteresowań technicznych to: programowanie oraz obliczenia HPC, a
w szczególności programowanie GPU w CUDA i OpenCL. W C++ programuje od 5 lat.
Kontakt z autorem:
mariusz.uchronski@gmail.com
Wojciech Waga
Absolwent Informatyki na wydziale Matematyki i Informatyki Uniwersytetu Wrocławskiego, obecnie słuchacz
studiów doktoranckich biologii molekularnej UWr oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego.
Programuje w C++ od 11 lat.
Kontakt z autorem:
wojciech.waga@gmail.com