Wątki i wyjątki. Jak radzić sobie z nieoczekiwanymi zachowaniami w wielowątkowym kodzie.
Nieoczekiwane zachowanie czyli wyjątek to sytuacja, w której nasz kod zachował się w sposób, który normalnie nie występuje. Może być to wywołane problemami z zasobami jak brak pamięci podręcznej lub brak dostępu do pliku. Powodów może być wiele, najczęściej nie zależą od nas. Jednak możemy się na takie sytuacje przygotować i pomimo wystąpienia wyjątków, nasz program nie przerwie działania.
Rodzaje wyjątków
Rodzajów wyjątków w STL mamy całkiem sporo i są one pogrupowane w podklasy dziedziczące po std::exception. I tak mamy na przykład std::runtime_error, który sam w sobie nie jest zgłaszany, jest jednak klasą bazową dla innych, między innymi std::range_error, std::overflow_error, std::underflow_error. Część wyjątków dodana została w późniejszych wersjach C++.
Wyjątki dotyczą różnych problemów, na które nasz program może natrafić, brak elementu w kontenerze - std::out_of_range, rzutowanie referencji typów niepołączonych hierarchią - std::bad_cast (przy wskaźnikach dostaniemy nullptr, a wyjątek nie jest rzucany) czy problemy z alokowaniem pamięci - std::bad_alloc. To tylko kilka przykładów, po dokładne szczegóły odsyłam do dokumentacji: “std::exception”.
Prócz standardowych wyjątków, możemy również zdefiniować własne, po prostu dziedzicząc po std::exception lub jej klasie pochodnej.
class ReadFileException : public std::exception
{
public:
ReadFileException(const std::string inputFileName)
: fileName(inputFileName)
{}
const char* what() const noexcept override
{
return fileName.c_str();
}
private:
const std::string fileName;
};
Rzucanie i łapanie wyjątków
Rzucanie wyjątków w C++ jest banalnie proste, wystarczy użyć słowa kluczowego throw na obiekcie klasy wyjątka. Najczęściej będzie to obiekt tymczasowy. Jeszcze nie spotkałem się, z potrzebą składowania obiektów wyjątków, niemniej jest to jak najbardziej możliwe.
throw ReadFileException("file.txt");
Jak widać, rzucanie wyjątków jest proste, łatwe i czytelne. Inaczej jest z ich łapaniem. Obsługa wyjątków C++ jest już bardziej złożona. Do przechwytywania wyjątków służy blok try/catch.
try
{
if(fileHandler.openFile("file.txt"))
{
const auto fileContent{ fileHandler.read() };
//...
}
fileHandler.closeFile();
}
catch(const ReadFileException& ex)
{
std::cout << "Cannot read file: " << ex.what() << "\n";
fileHandler.closeFile();
}
Block try jest prosty, to w nim umieszczamy kod, który może rzucić wyjątek. Block catch służy do przechwytywania wyjątków określonego typu oraz ich obsługi, na przykład zwolnienie zasobów takich jak pamięć czy mutex. Po opuszczeniu bloku catch, praca programu będzie kontynuowana. Bloków catch może być wiele, w zależności od tego jakie operacje muszą zostać wykonane w stosunku do typu rzuconego wyjątku.
try
{
if(fileHandler.openFile("file.txt"))
{
const auto fileContent{ fileHandler.read() };
//...
}
fileHandler.closeFile();
}
catch(const ReadFileException& ex)
{
std::cout << "Cannot read file: " << ex.what() << "\n";
fileHandler.closeFile();
}
catch(const std::system_error& ex)
{
std::cout << ex.what() << " with code: " << ex.code() << "\n";
return 0;
}
Dodatkowo wyjątki łapane są według hierarchii dziedziczenia, to znaczy, że jeśli nie zdefiniujemy w bloku catch określonego typu wyjątku, ale jego rodzica już tak, to ten wyjątek również zostanie obsłużony.
try
{
if(fileHandler.openFile("file.txt"))
{
const auto fileContent{ fileHandler.read() };
//...
}
fileHandler.closeFile();
}
catch(const std::system_error& ex)
{
std::cout << ex.what() << " with code: " << ex.code() << "\n";
return 0;
}
catch(const std::exception& ex) // ReadFileException zostanie tutaj przechwycony
{
std::cout << "Cannot read file: " << ex.what() << "\n";
fileHandler.closeFile();
}
Należy zwrócić uwagę na hierarchię dziedziczenia, jeśli pierwszy blok catch będzie ustawiony na klasę bazową, a następny na klasę pochodną, to wyjątek nigdy nie zostanie złapany przez drugi blok catch.
try
{
if(fileHandler.openFile("file.txt"))
{
const auto fileContent{ fileHandler.read() };
//...
}
fileHandler.closeFile();
}
catch(const std::exception& ex) // Wyjątek typu std::system_error dziedziczy po std::exception i zostanie tutaj przechwycony
{
std::cout << "Cannot read file: " << ex.what() << "\n";
fileHandler.closeFile();
}
catch(const std::system_error& ex) // Ten kod nigdy się nie wykona
{
std::cout << ex.what() << " with code: " << ex.code() << "\n";
return 0;
}
Kompilator może nas poinformować ostrzeżeniem w stylu:
main.cpp:71:1: warning: exception of type ‘std::system_error’ will be caught by earlier handler [-Wexceptions]
71 | catch(const std::system_error& ex)
| ^~~~~
main.cpp:66:1: note: for type ‘std::exception’
66 | catch(const std::exception& ex)
| ^~~~~
Bywają jednak takie sytuacje, gdy chcemy, by każdy wyjątek obsłużyć tak samo i nie ma dla nas znaczenia jaki to typ. Jest na to sposób. C++ nieczęsto stosuje składnię z użyciem wielokropka (…). To właśnie jeden z tych przypadków.
try
{
if(fileHandler.openFile("file.txt"))
{
const auto fileContent{ fileHandler.read() };
//...
}
fileHandler.closeFile();
}
catch(...) // Łapiemy wszystkie wyjątki lecz kosztem braku informacji z metody what()
{
fileHandler.closeFile();
}
Wielokropek powinien być używany jako ostatni blok catch. Nie zalecałbym takiej obsługi wyjątków jako domyślny sposób. Niemniej, warto wiedzieć o jego istnieniu ;).
Wszystkie te sposoby obsługi wyjątków się łączą. Możemy dowolnie definiować liczbę i rodzaje bloków catch (zgodnie z hierarchią dziedziczenia). Możemy także zagnieżdżać całe bloki try/catch.
try
{
if(fileHandler.openFile("file.txt"))
{
try
{
const auto fileContent{ fileHandler.read() };
//...
}
catch(const ReadFileException& ex)
{
std::cout << "Cannot read file: " << ex.what() << "\n";
}
}
fileHandler.closeFile();
}
catch(const std::system_error& ex)
{
std::cout << ex.what() << " with code: " << ex.code() << "\n";
return 0;
}
catch(...)
{
std::cout << "Something unexpected happened!\n";
return 0;
}
Trzeba jednak zachować umiar bo możemy skończyć z bardzo nieczytelnym kodem, w którym ciężko w szybki sposób zweryfikować, w który blok catch wyjątek zostanie złapany.
C++11 udostępnia nam też słowo kluczowe noexcept, którym możemy oznaczyć funkcje i metody nierzucające wyjątków. Czyli takie, które używają operacji bezpiecznych pod względem wyjątków i/lub same je obsługują. Słowo kluczowe noexcept możemy także zastosować do konstruktorów i destruktora klasy.
int add(int a, int b) noexcept
{
return a + b;
}
class FileHandler
{
public:
FileHandler() noexcept;
~FileHandler() noexcept;
bool openFile(const std::string& fileName);
std::string read() const;
void closeFile() noexcept;
private:
File file;
};
Żeby móc oznaczyć funkcję lub metodę jako noexcept. Wszystkie operacje i wywoływane funkcje/metody także powinny być oznaczone jako noexcept, by zachować bezpieczeństwo w kontekście wyjątków. Niestety kompilator nas nie poinformuje, jeżeli ten warunek nie jest spełniony. Co jeśli oznaczymy naszą funkcję/metodę jako noexcept, a z jakiegoś powodu jednak rzuci wyjątek? Specyfikacja podpowiada, że zostanie wywołana funkcja std::terminate(), która zakończy działanie naszego programu niezależnie od tego czy dany kod był w bloku try/catch czy nie.
noexcept jest równoznaczne z noexcept(true). Natomiast domyślnie wszystkie funkcje i metody oznaczone są jako noexcept(false). Dlaczego dodano osobno noexcept oraz noexcept(true/false)? Głównie ze względu na szablony i metaprogramowanie, gdzie o tym czy funkcja lub metoda może lub nie może rzucać wyjątków kompilator dowiaduje się dopiero w trakcje kompilacji i konkretyzacji szablonów.
noexcept jest traktowane jako część typu funkcji. To znaczy, że jeśli mamy wskaźniki na funkcje, które różnią się tylko noexcept, to będą one traktowane jako osobne typy. Tak samo jeżeli chodzi o parametry szablonu.
using funcPtr1 = bool(const int);
using funcPtr2 = bool(const int) noexcept;
noexcept nie można za to stosować do przeciążania funkcji, gdyż nie wchodzi w skład jej sygnatury.
int add(int a, int b) noexcept;
int add(int a, int b); // Błąd kompilacji, redefinicja funkcji "add"
Zaletą noexcept jest przede wszystkim optymalizacja. Kompilator nie musi generować dodatkowego kodu do zwijania stosu po wystąpieniu wyjątku. Może także dobrać bardziej optymalne algorytmy STL. Łatwiej jest kompilatorowi inline’ować funkcję/metodę. Binarka wynikowa, również ma mniejszy rozmiar.
Przechwytywanie wyjątku wewnątrz wątku
Przejdźmy teraz do wielowątkowego przechwytywania wyjątków. Nie jest to rzecz taka prosta. Spójrz na ten kod, czy jest on bezpieczny pod względem wyjątków?
std::thread calucalteSumThread;
try
{
calucalteSumThread = std::thread([]()
{
throw std::runtime_error("calculation error!");
});
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
catch(...)
{
std::cerr << "Something unexpected happened!\n";
}
if (calucalteSumThread.joinable())
{
calucalteSumThread.join();
}
Wydawać by się mogło, że tak. Przecież mamy blok catch zarówno na wyrzucany wyjątek std::runtime_error jak i …. Jednak tak nie jest. Po uruchomieniu tego kodu w prostej funkcji main zostanie wywołany std::terminate().
terminate called after throwing an instance of 'std::runtime_error'
what(): calculation error!
Dzieje się tak dlatego, że wątek traktowany jest jako osobny proces pomimo, iż należy do głównego wątku naszej aplikacji. Jednym z rozwiązań tego problemu jest obsługa wyjątków wewnątrz wątku i nie wyrzucanie ich na zewnątrz, tworząc wątek bezpieczny względem wyjątków.
std::thread calucalteSumThread;
try
{
calucalteSumThread = std::thread([]()
{
try
{
throw std::runtime_error("calculation error!");
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
});
}
catch(...)
{
std::cerr << "Something unexpected happened!\n";
}
if (calucalteSumThread.joinable())
{
calucalteSumThread.join();
}
Wynikiem będzie tylko komunikat przechwyconego wyjątku, a nasz program będzie kontynuował pracę:
calculation error!
Przekierowanie wyjątku do wątku głównego
Tworzenie osobnego bloku try/catch w wątku i poza nim może doprowadzić do niepotrzebnej złożoności. Możemy też potrzebować obsłużyć wyjątek w głównym wątku naszej aplikacji, gdy wyjątek wystąpi wewnątrz wątku, aby poprawnie zareagować na taką sytuację. C++ od wersji 11 wraz z całą obsługą wyjątków daje nam kilka narzędzi, które rozwiązują ten problem: std::async, std::packaged_task i promise. Każde z nich umożliwia przekierowanie wyjątków z wątku pobocznego do wątku głównego.
Zacznijmy od std::async. To szablon funkcji o zmiennej liczbie parametrów umożliwiający uruchomienie przekazanej funkcji lub metody w osobnym wątku. Zwraca obiekt std::future, który po wywołaniu metody get() zwróci wynik lub wyjątek jeśli wystąpił.
auto exceptionFutureObj = std::async(std::launch::async, []()
{
throw std::runtime_error("calculation error!");
});
try
{
exceptionFutureObj.get();
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
Widać tutaj prostotę tego rozwiązania. Wątek poboczny nie zawiera już bloku try/catch. Cała obsługa wyjątku dzieje się pod spodem std::async. Kod jest czysty i zrozumiały. By mieć pewność, że funkcja przekazana jako parametr uruchomi się w osobnym wątku, trzeba ustawić tryb uruchamiania na std::launch::async. W trybie std::launch::deffered funkcja zostanie uruchomiona w tym samym wątku dopiero w momencie wywołania metody get() lub wait() na zwróconym przez async obiekcie future. Domyślnie, to implementacja decyduje jaki tryb uruchamiania zostanie wykorzystany.
Drugim narzędziem, którym możemy przekazać wyjątki z wątku pobocznego do głównego jest std::packaged_task.
std::packaged_task<void()> task([]()
{
throw std::runtime_error("calculation error!");
});
auto exceptionFutureObj = task.get_future();
std::thread exceptionTaskThread(std::move(task));
try
{
exceptionFutureObj.get();
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
exceptionTaskThread.join();
Pod względem przechwytywania wyjątków async i std::packaged_task działają tak samo. Oba zwracają obiekt typu future i w momencie pobierania wartości zwracanej (get()), wyjątek może zostać przechwycony. Zasadnicza różnica pomiędzy nimi jest moment, w którym wątek zostaje uruchomiony. Przy async (z ustawionym std::launch::async), w momencie wywoływania. Przy std::packaged_task, dopiero, gdy task zostanie przekazany do nowego wątku.
Ostatni sposób na przekazanie wyjątków z wątku pobocznego do głównego, to std::promise.
std::promise<void> exceptionPromise;
auto exceptionFutureObj = exceptionPromise.get_future();
std::thread exceptionTaskThread([&exceptionPromise]()
{
try
{
throw std::runtime_error("calculation error!");
}
catch (...)
{
exceptionPromise.set_exception(std::current_exception());
}
});
try
{
exceptionFutureObj.get();
}
catch (const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
exceptionTaskThread.join();
Widać tutaj od razu, że blok try/catch powrócił do ciała naszego wątku pobocznego. Mimo to, jedynym dla nas potrzebnym blokiem catch jest ten z wielokropkiem, gdyż zależy nam na przekazywaniu wyjątków do wątku głównego. promise daje nam największą kontrolę nad tym, kiedy wątek zostanie uruchomiony, kiedy i jaki wyjątek zostanie przekazany wyżej. Możemy także przekazać jakąś wartość w std::promise, w polu value. Może w celu debuggowym albo jako wartość domyślną. Zależy od tego co będzie nam potrzebne.
Wielokrotne przechwytywanie wyjątków
Na koniec jeszcze kwestia przechwytywania wyjątków z różnych wątków pobocznych w wątku głównym. Gdy przy użyciu std::async, uruchomimy dwa wątki i w obu zostaną wyrzucone wyjątki to musimy zadbać o to, by każdy został poprawnie obsłużony.
auto exceptionFutureObj1 = std::async(std::launch::async, []()
{
std::cout << "First thread\n";
throw std::runtime_error("calculation error!");
});
auto exceptionFutureObj2 = std::async(std::launch::async, []()
{
std::cout << "Second thread\n";
throw std::system_error(std::make_error_code(std::errc(EDEADLK)), "system error!");
});
try
{
exceptionFutureObj2.get(); // Rzucenie wyjątku std::system_error, przejście do bloku catch
exceptionFutureObj1.get();
}
catch(const std::system_error& ex)
{
std::cout << ex.what() << " with code: " << ex.code() << "\n";
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
A tutaj wynik działania powyższego fragmentu kodu:
First thread
Second thread
system error!: Resource deadlock avoided with code: generic:35
W tym przypadku, pomimo, iż oba wątki zostały uruchomione, i oba z pewnością rzuciły wyjątki to tylko jeden zostanie przechwycony. Nie liczy się moment rzucenia wyjątku, a moment odebrania wyniku z obiektu future. Dopiero wtedy wyjątek rzucany jest w wątku głównym. Dlatego w powyższym przykładzie, wyjątek std::runtime_error nie został przechwycony. Drugi get() po prostu się nie wykonał.
Aby uniknąć takich sytuacji należy każdą próbę odebrania wyniku z future opakować blokiem try/catch osobno. Możemy do tego utworzyć szablonowy handler.
template <typename Future>
void exceptionHandler(Future& future)
{
try
{
future.get();
}
catch(const std::runtime_error& ex)
{
std::cerr << ex.what() << "\n";
}
}
int main()
{
constexpr int numOfThreads{ 5 };
std::vector<std::future<void>> futures;
for(int idx = 0; idx < numOfThreads; idx++)
{
futures.push_back(std::async(std::launch::async, [idx]()
{
std::cout << "Thread nr: " << idx << "\n";
throw std::runtime_error("error from thread: " + std::to_string(idx));
}));
}
for(auto& future : futures)
{
exceptionHandler(future);
}
return 0;
}
A oto wynik:
Thread nr: 0Thread nr: 1
Thread nr: 2
Thread nr: 4
Thread nr: 3
error from thread: 0
error from thread: 1
error from thread: 2
error from thread: 3
error from thread: 4
Jak widać wszystkie wyjątki zostały przechwycone. Widać też asynchroniczność w logowaniu do std::cout. W takim przypadku lepiej użyć jakiegoś własnego loggera bezpiecznego dla wątków.
Podsumowanie
W tym wpisie, starałem się zebrać wszystkie najważniejsze informacje dotyczące wyjątków w C++. Od tego jakie wyjątki są dostępne w tym języku programowania, poprzez ich rzucanie i obsługę, kończąc na przekazywaniu ich pomiędzy wątkami. C++ wciąż się zmienia i ewoluuje. Może w przyszłości dojdą nowe mechanizmy związane z wyjątkami. Choć trzeba przyznać, że już teraz mamy spory wachlarz narzędzi do radzenia sobie z nimi :).
Autor: Tadeusz Biela
Programista C++ | Entuzjasta TDD | Fan unit testów
Zostaw komentarz