Destruktor std::thread wykonuje niejawne sprawdzenie swojego stanu wewnętrznego. Jeśli wątek pozostaje dołączalny — co oznacza, że reprezentuje aktywny wątek wykonawczy, który nie został jeszcze dołączony ani odłączony — destruktor wywołuje std::terminate, aby zapobiec kontynuacji programu z potencjalnie niebezpiecznym wątkiem. Ten projekt wymusza jawne zarządzanie cyklem życia, ale stwarza znaczące ryzyko dla bezpieczeństwa wyjątków oraz wczesnych ścieżek zwrotu.
std::jthread, wprowadzony w C++20, eliminuje to ryzyko, inkapsulując współpracującą anulację i synchronizację w swoim projekcie RAII. Jego destruktor najpierw sygnalizuje anulację przez wewnętrzny std::stop_source, a następnie automatycznie wywołuje join(), blokując do momentu zakończenia wykonania wątku. To zapewnia, że wątek kończy się łagodnie przed zniszczeniem obiektu, eliminując możliwość przypadkowego zakończenia bez ręcznej interwencji.
// Niebezpieczne: std::thread void risky_task() { std::thread t([]{ /* praca w tle */ }); if (config_error) return; // std::terminate() wywołane tutaj! t.join(); } // Bezpieczne: std::jthread void safe_task() { std::jthread t([](std::stop_token st) { while (!st.stop_requested()) { /* praca */ } }); if (config_error) return; // Bezpieczne: destruktor żąda zatrzymania i dołącza }
Rozważmy aplikację do handlu wysokiej częstotliwości, która uruchamia wątek zasilania danych rynkowych do przetwarzania nadchodzących cytatów. Podczas inicjalizacji, jeśli konfiguracja sieci okaże się nieważna, funkcja wcześniej kończy działanie, niszcząc obiekt std::thread przed wywołaniem join(). Taki scenariusz pojawia się często w aplikacjach związanych z asynchronicznym wejściem/wyjściem, gdzie zdobycie zasobów może nie powieść się po utworzeniu wątku, co prowadzi do natychmiastowych awarii w środowiskach produkcyjnych.
Jednym z rozważanych podejść było owinięcie wątku w ręczny blok try-catch, aby upewnić się, że join() było wykonywane przed każdą ścieżką zwrotu i obsługą wyjątków. Choć jawne, okazało się to kruchym rozwiązaniem; dodanie nowych punktów wyjścia lub refaktoryzacja często wprowadzało regresje, gdzie logika dołączania była pomijana, co prowadziło do sporadycznych wywołań std::terminate podczas odzyskiwania błędów.
Innym rozważanym rozwiązaniem była niestandardowa klasa ScopeGuard, która przechowywała referencję do wątku i dołączała go w swoim destruktorze. Choć to inkapsulowało logikę bezpieczeństwa, powtarzało funkcjonalność już ustandaryzowaną w bibliotece i wymagało utrzymania kodu szablonowego w wielu modułach, zwiększając długi techniczny i nadwyżkę przeglądów.
Zespół ostatecznie przyjął std::jthread po migracji do C++20. Zastępując std::thread, destruktor automatycznie sygnalizował anulację za pomocą std::stop_token i czekał na zakończenie wątku bez ręcznych blokad synchronizacyjnych. To usunęło ciężar zapewnienia czyszczenia podczas rozwijania stosu z wyjątków lub wcześniejszych zwrotów, co zaowocowało kodem, który był zarówno bezpieczniejszy, jak i bardziej utrzymywany.
Dlaczego wywołanie join() dwukrotnie na std::thread skutkuje niezdefiniowanym zachowaniem i jak std::jthread zapobiega temu programowo?
Obiekt std::thread śledzi, czy posiada ważny uchwyt do wątku wykonawczego. Gdy tylko wywołuje się join(), wątek staje się nie-dołączalny, jednak standard nie wymusza, aby kolejne wywołania bezpiecznie sprawdzały ten stan. Ponowne wywołanie join() narusza precondition, że wątek musi być dołączalny, co prowadzi do niezdefiniowanego zachowania, które zazwyczaj objawia się jako awarie, zakleszczenia lub wycieki zasobów.
std::jthread zapobiega temu, czyniąc join() idempotentnym poprzez solidne śledzenie stanu wewnętrznego. Jego destruktor wywołuje join() tylko wtedy, gdy wątek jest dołączalny, a kolejne jawne wywołania nie robią nic, odzwierciedlając zachowanie operacji resetujących wskaźników inteligentnych i zapobiegając przypadkowemu błędowi podwójnego dołączania.
Jak std::jthread's std::stop_token umożliwia współpracującą anulację i dlaczego jest to lepsze niż asynchroniczne prymitywy przerywania wątków?
std::jthread paruje każdy wątek z std::stop_source i przekazuje std::stop_token do funkcji wejściowej wątku. Pracownik okresowo sprawdza stop_requested(), aby płynnie zakończyć swoją pętlę, zapewniając, że inwarianty są utrzymywane, a mutexy są odblokowane. To stanowczo kontrastuje z std::thread, gdzie przerwanie wymaga wywołań specyficznych dla platformy, takich jak pthread_cancel lub TerminateThread, które przymusowo zatrzymują wykonanie w środku instrukcji i mogą pozostawić współdzielone zasoby w stanie uszkodzonym lub zablokowanym.
Co się dzieje z sygnałem anulacji, gdy std::jthread jest przenoszony do innego obiektu, i czy uruchamiany wątek obserwuje to przeniesienie?
Gdy std::jthread jest przenoszony, obiekt źródłowy rezygnuje z właściciela podległego uchwytu wątku i std::stop_source, stając się pusty i nie-dołączalny. Obiekt docelowy przejmuje kontrolę nad wątkiem. Kluczowe jest to, że std::stop_token przekazane do funkcji roboczej pozostaje ważne, ponieważ odnosi się do stop_state zarządzanego przez std::stop_source, który istnieje tak długo, jak długo jakikolwiek token lub źródło go referencjonuje. Wątek nadal działa pod nowym właścicielem obiektu jthread, a żądania anulacji za pośrednictwem nowego uchwytu wciąż docierają do oryginalnego pracownika bez zakłóceń.