Sytuacja kobiet w IT w 2024 roku
2.07.20199 min
Vanad Gasparyan

Vanad GasparyanC++ EngineerKPIT

Pozwól, że poodłączam Ci te wątki

Wprowadzenie do std::thread, wielowątkowości i innych ciekawych rzeczy.

Pozwól, że poodłączam Ci te wątki

Z historycznego punktu widzenia wielowątkowość była dużym problemem dla programistów C++, ponieważ przez długi czas nie była wspierana przez język. Dopiero w C++11 stała się standardem. A teraz "wielowątkowość" jest łatwiejsza niż kiedykolwiek. Ale czy na pewno?

Krótka odpowiedź brzmi: "Absolutnie tak, ale tylko jeśli używasz jej poprawnie". C++ bardzo mocno starał się utrzymać swój wielowątkowy interfejs tak minimalistyczne czystym, jak tylko było to możliwe. Aby udowodnić mój punkt widzenia, zobaczmy jak wygląda klasa std::thread. W niektórych innych językach klasy Thread są dość duże, mają wiele udostępnionych funkcji, które sprawiają, że nie jest łatwo je opanować. Ale ma to pewien dobry efekt uboczny: musimy poświęcić więcej czasu na zrozumienie wszystkich tych funkcji, a także wielowątkowości ogólnie. Nasza klasa std::thread jest jednak tak prosta, że można ją całkowicie opisać w 4 prostych zdaniach dla kogoś z podstawowym zrozumieniem wielowątkowości.

Klasa std::thread

Najpierw, tak jak obiecałem, opiszę tę klasę w czterech zdaniach.

  • Obiekt klasy std::thread można zinstancjonować z czymś wywoływalnym (callable) i jego parametrami, co spowoduje jego natychmiastowe uruchomienie.
  • Następnie obiekt znajduje się w stanie joinable, co oznacza, że musi zostać połączony lub odłączony zanim wyjdzie poza zasięg (scope).
  • Jeśli w dowolnym momencie obiekt zostanie połączony (przez wywołanie join()), wątek rodzica będzie czekał, aż dziecko zakończy swoje zadanie.
  • Jeśli jednak jest odłączony (przez wywołanie detach()), będzie działał w tle a wątek rodzica nie będzie na niego czekał.

Te cztery zdania całkiem prosty ale dość kompletny opis tej klasy, z wyjątkiem tego, że pomija czego nie należy robić, a jeśli się to robi, to jakie są tego konsekwencje. Tak więc "rozmontujmy sobie" te zdania.

Obiekt klasy std::thread można zinstancjonować z czymś wywoływalnym (callable) i jego parametrami, co spowoduje jego natychmiastowe uruchomienie.

Istnieją trzy sposoby konstruowania obiektu std::thread. Jak wspomniano wyżej, jeden z konstruktorów pobiera callable i jego parametry (jeśli są). Callable to w zasadzie wszystko, co można traktować jako funkcję, to znaczy wskaźniki funkcji, wskaźniki funkcji statycznych i niestatycznych funkcji składowych, funktory, lambdy.

“Wskaźnik na niestatyczną funkcję składową" różni się nieco i wygląda na skomplikowany (linia 22). Składnia &ClassName::functionName jest taka, jaka powinna być, a dodatkowy pierwszy parametr jest wskaźnikiem do obiektu, na którym funkcja ta ma być wywołana (this). Tak więc, ten konstruktor tworzy nowy wątek, który od razu uruchamia nasz callable, a nasz obiekt jest rodzajem uchwytu na tym wątku. Może zwrócić wyjątek std::system_error, jeśli wątek nie może zostać uruchomiony (ograniczenia OS).

Innym dostępnym konstruktorem jest konstruktor przenoszący.

W tym przykładzie, tworzony jest nowy wątek i t1 jest uchwytem do niego, a podczas pracy, t2 przejmuje własność od t1. Teraz t2 zająć się losem naszego wątku.

Ostatni, domyślny konstruktor tworzy obiekt, który nie tworzy żadnego nowego wątku i nie jest z żadnym związany.

Zauważ, że konstruktor kopiujący został usunięty co ma sens: powinien istnieć jeden i tylko jeden obiekt reprezentujący określony wątek. Później zobaczymy, że konstruktor przenoszący nie jest tylko ekstrawagancką opcją, ale w niektórych przypadkach koniecznością.

Następnie obiekt znajduje się w stanie joinable, co oznacza, że musi zostać połączony lub odłączony zanim wyjdzie poza zasięg.

To, czy obiekt std::thread jest joinable, czy nie, decyduje o tym, czy możesz go łączyć lub odłączyć czy nie. W dowolnym momencie można sprawdzić czy obiekt jest w stanie pozwalającym na łączenie, wywołując funkcję bool joinable().

Krótko mówiąc, jeśli obiekt można łączyć lub odłączyć, t0 trzeba coś z nim zrobić. Jeśli w dowolnym miejscu obiekt jest związany z wątkiem, który nie został jeszcze połączony lub odłączony, można go łączyć. Na początku jego cyklu życia zależy to od konstruktora. Jeśli w konstruktorze był callable i udało się wystartować nowy wątek, jest umożliwia łączenie/joinable (linia 4), jeśli użyto konstruktora domyślnego, nie umożliwia łączenie/joinable (linia 3), a jeśli użyto konstruktora przenoszącego (lub później przypisano mu move), zależy od obiektu, z którego przejmuje własność. Zauważ, że przeniesiony obiekt, t2 w linii 5, zwraca do stanu stworzonego domyślnym konstruktorem, więc nie można go łączyć. Jednak obiekt, do którego robisz przeniesienie, t1 w linii 5, nie może być łączony, w przeciwnym razie zostanie wywołany std::terminate().

Najważniejszy jest moment, gdy obiekt wyjdzie poza zasięg i zostanie zniszczony. W tym momencie nie może być możliwe dołączenie, w przeciwnym razie ponownie wywoła std::terminate().

Jeśli w dowolnym momencie obiekt zostanie łączony (poprzez wywołanie join()), wątek rodzicielski będzie czekał, aż dziecko zakończy swoje zadanie.

Jeśli jednak jest odłączony (przez wywołanie detach()), będzie działał w tle a wątek rodzicielski nie będzie na niego czekał.

Nie mam nic więcej do powiedzenia na temat tych dwóch właściwości. Być może ważne jest, aby pamiętać, że jedna i tylko jedna z tych funkcji musi być wywołana na obiekcie, i to tylko wtedy, gdy możne być łączony, w przeciwnym razie zwracany jest std::system_error. Rozważmy następującą sytuację, aby zrozumieć, jak i kiedy wątki powinny być łączone lub odłączone.

Funkcja sleep(int seconds) służy do symulacji kodu, który działa przez kilka sekonds sekund. W ten sposób stworzyliśmy dwa nowe wątki, nazywając je thread_0 i thread_1. Potrzeba 5 sekund, aby thread_0 zakończył swoje zadanie, a 7 dla thread_1, a po 12 linii obie są już uruchomione. Główny wątek kontynuuje swoje zadanie i ma 3 funkcje do wywołania, każda trwa odpowiednio 3, 6 i 1 sekundę. Jak wspomniano wyżej, przed linią 20, tj. kiedy t1 i t2 wychodzą poza zasięg i mają zostać zniszczone, muszą być łączone lub odłączone. Jeśli zdecydujesz się na odłączenie obiektu thread, nie ma znaczenia, na której linii to zrobisz. Jednak w przypadku połączenia wątków, jest to ważne. Robienie tego zaraz po skonstruowaniu wątków (linia 13) nie ma sensu: nie byłoby współbieżności. Jeśli dołączymy, na przykład, t1 na linii 15, główny wątek dotrze tam o 3 sekundy wcześniej i poczekaj jeszcze 2 sekundy, aż thread_0 zostanie zakończony. Technicznie rzecz ujmując, nie masz żadnej możliwości by dokładnie przewidzieć, jak długo każda funkcja będzie działać i nie powinieneś się nawet przejmować. Nie ma znaczenia, czy główny wątek dotrze do punktu połączenia wcześniej czy później niż kończy się thread_0. Po prostu zmuszamy główny wątek do kontynuowania jego wykonywania od tego momentu tylko wtedy, gdy thread_0 się skończy.

Teraz wszystko jest w porządku, oba obiekty std::thread mogą zostać zniszczone.

W czym więc tkwi problem?

Wygląda na to, że wszystko jest dość proste, kilka łatwych do zapamiętania zasad i nic ci nie będzie. Niestety, tak nie jest. Wszystkie zasady, o których rozmawialiśmy do tej pory, są swego rodzaju "miłe", w taki sposób, że jeśli (przypadkowo) nie będziesz ich przestrzegał, od razu się o tym dowiesz: program zostanie zakończony lub rzuci wyjątkiem.

Wielowątkowość jest dość niebezpieczna, błędy tutaj są bardzo kosztowne. Kiedy znajdziesz problem, debugowanie jest albo trudne albo niemożliwe, ale to nie jest najgorsze. Najgorzej jest, gdy kod ma błąd, ale działa prawidłowo. Może się tak zdarzyć, ponieważ po użyciu wątków, kolejność wykonania nie jest już deterministyczna. Jak długo trwa tworzenie nowego wątku? Ile może być jednoczesnych wątków? W jaki sposób czas procesora jest rozdzielany pomiędzy wątki? Wszystkie te czynniki mają wpływ na ogólną realizację. Pisanie kodu wielowątkowego oznacza bycie przygotowanym na wszystko, co czyni go niebezpiecznym i ekscytującym.

Ale w tym artykule będziemy rozmawiać tylko o dołączeniu i odłączeniu. Odpowiedzieliśmy już na pytanie "jak to zrobić?" i to było całkiem proste. Pytanie, na które teraz odpowiemy, brzmi "który?".

Scenariusz 1

Przed zniszczeniem obiekt std::thread musi być łączony lub odłączony. Można powiedzieć, że łączenie jest bardziej intuicyjne, ponieważ na pewno wiemy, kiedy go potrzebujemy. A ponieważ łączenie i odłączanie wzajemnie się wykluczają, można by pomyśleć, że odłączasz się, kiedy się nie przyłączasz. Oto przykład.

Tak więc wykonaliśmy wstępną część obliczeń i mamy wystarczająco dużo wyników, aby stworzyć raport. Możemy chcieć to zrobić równocześnie z resztą obliczeń, więc "wyślemy wiadomość e-mail" w nowym wątku.

Ostatnią rzeczą, o której musimy zdecydować, jest czy ją podłączyć, czy ją odłączyć. Nie musimy czekać na jej koniec, więc możemy się po prostu odłączyć. istnieje zazwyczaj duża szansa, że się powiedzie. Problem polega na tym, że funkcja sendEmail nie może być niezależna od wszystkiego, jest nadal częścią aplikacji. Musi ona zależeć od jakiegoś innego obiektu lub zasobu, który ma swoje własne życie. Gdybyśmy mieli przekazać topLevelResults przez referencje, główny wątek może wyjść poza ten zasięg, niszcząc topLevelResult, podczas gdy nowy wątek nadal go używa (wtedy wysłanie wiadomości e-mail trwa dłużej niż obliczenie). Ale nawet jeśli jest ona przekazywana przez wartość, może się zdarzyć wiele różnych rzeczy. Główny wątek (i cały program) może zakończyć się wcześnie lub wysłanie wiadomości e-mail może potrwać dłużej.

Scenariusz 2

Wyobraźmy sobie ten sam scenariusz, co wcześniej, ale tym razem zamiast wysyłać e-mail mamy bardzo krótką operację (w porównaniu do kalkulacji). Ponieważ jesteśmy naprawdę pewni, że wkrótce się skończy, zamiast myśleć o odpowiednim miejscu do dołączenia, po prostu ją odłączamy. Teoretycznie niebezpieczeństwo jest takie same, ale w praktyce mniej prawdopodobne.

Scenariusz 3

Jeśli wygooglujesz dlaczego lub kiedy powinienem odłączyć wątek, prawdopodobnie znajdziesz termin "wątek w tle". Tego rodzaju wątki są zazwyczaj tworzone na początku i działają w tle przez cały czas. Przykładem może być logger. Ten scenariusz jest najbardziej powszechny i przy odrobinie wysiłku można zorganizować go tak, żeby było bezpiecznie, ale ponownie nie jest on chroniony przed teoretycznym niebezpieczeństwem opisanym powyżej.

Rozwiązanie

We wszystkich trzech powyższych scenariuszach powinniśmy byli zadać jedno pytanie: Na pewno nie tutaj, ale może gdzie indziej trzeba było je złączyć? Jeśli się nad tym zastanowić, to w pierwszym scenariuszu wątek zajmuje się singletonem ReportManager i nie może działać na wierzchu po tym, jak instancja zostanie zniszczona. W drugim scenariuszu, moglibyśmy mieć singleton, który śledzi wszystkie tego rodzaju rzekomo krótkie zadania, na wszelki wypadek. W trzecim scenariuszu, ten wątek w tle miał do czynienia z jakąś częścią kodu i musiał mieć taką zależność. Na szczęście, obiekty std::thread są ruchome, więc technicznie możemy połączyć te wątki gdziekolwiek indziej. To może sprawić, że kod będzie brzydszy. W takim wypadku przydaje się Idiom RAII.

Pomysł jest bardzo prosty. Zamiast tworzyć obiekt std::thread i odłączać go, pozwalasz tej klasie wykonać zadanie. Najważniejsza funkcja createDetachedTask ma taką samą sygnaturę jak główny konstruktor std::thread. Tworzy wątek, przechowuje w kontenerze i łączy je wszystkie po zniszczeniu. Może to być pomocne we wszystkich 3 scenariuszach omówionych wyżej.

W pierwszym scenariuszu nie chcemy, aby nasz wątek uruchamiał się po okresie życia pojedynczego obiektu ReportManager, więc możemy po prostu dodać składową tego typu w ReportManager, dodać nową funkcję zwaną asyncSendEmail, która uruchomi starą funkcję w nowym wątku.

Zauważ, że Detacher jest niekopiowalny, co jest ograniczeniem, które przyjęliśmy z std::thread, ale gdy mamy do czynienia z singletonami, możemy po prostu użyć Detacher jako klasy bazowej.

I tak samo jest w przypadku innych scenariuszy, chodzi o znalezienie właściwego miejsca.

Podsumowanie

  • Wielowątkowość jest czymś więcej niż tylko klasami i funkcją języka. To cały inny świat ze swoimi dziwactwami i wyzwaniami. Poświęć trochę czasu, aby nauczyć się teorii.
  • Odłączanie się nie jest faux pas. Jest zupełnie legalne, dużo wysokojakościowego kodu produkcyjnego je ma, po prostu może być niebezpieczne. Więc zastanów się uważnie, wybierając między łączenie a odłączeniem.
<p>Loading...</p>