Lei Mao bio photo

Lei Mao

Machine Learning, Artificial Intelligence, Computer Science.

Twitter Facebook LinkedIn GitHub   G. Scholar E-Mail RSS

Introduction

When we write multithread C++ programs, we might be curious why we always have to call join() or detach() for the new threads. In some scenarios, if we forgot to call join() or detach() for the new threads, there might be peculiar program crashes which are hard to troubleshoot.


In this blog post, I would like to discuss the consequence of not calling join() or detach() for the new threads, and some of the methods that could make sure that join() or detach() for the new threads are always called.

Consequences for No Join or Detach

The consequence of not calling join() or detach() for the new threads is program termination due to std::terminate was called.


Let’s check the following example. The main function in the main thread instantiated a new_thread which sleeps 5 seconds, then the main thread itself sleeps 1 second before it goes out of the scope.

// example.cpp
// g++ -std=c++14 example.cpp -lpthread -o example
#include <iostream>
#include <thread>

void thread_sleep(int sleep_seconds, bool verbose)
{
    std::thread::id this_id = std::this_thread::get_id();
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping for " << sleep_seconds << " seconds ..." << std::endl;
    }
    std::chrono::seconds sleep_duration{sleep_seconds};
    std::this_thread::sleep_for(sleep_duration);
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping complete! " << std::endl;
    }
}

int main()
{
    std::thread new_thread{thread_sleep, 5, true};
    thread_sleep(1, true);
    // new_thread.join();
    // new_thread.detach();
    return 0;
    // new_thread destructor will be called
}

We compiled the program using the following command.

$ g++ -std=c++14 example.cpp -lpthread -o example

When we executed the program, the program crashed.

$ ./example
Thread 139772469839680 sleeping for 1 seconds ...
Thread 139772469835520 sleeping for 5 seconds ...
Thread 139772469839680 sleeping complete! 
terminate called without an active exception
Aborted (core dumped)

This is because, when the execution of the main function finished, the destructor of new_thread will be automatically called for garbage collection. In the description of the destructor std::thread::~thread, If *this has an associated thread (joinable() == true), std::terminate() is called. If the std::thread object has been called with join() or detach(), joinable() would become false, otherwise it is always true. Because new_thread had been called with join() or detach() before its destructor was called, joinable() was true and std::terminate() was called and the C++ runtime was killed.


Although it terminates the program brutally, such design has its rationale. Without the std::terminate() mechanism in the destructor std::thread::~thread, if the users wanted to do join(), but forgot to call join(), the new_thread will run in the background just like the detach() behaviors. This might cause undefined behaviors.

Caveats

In addition to forget writing join() or detach() in the code, which is easy to find out, sometimes we have written join() or detach() in the code but they were not called due to exceptions. This will also results in program crash and might be difficult to troubleshoot.


The most conventional way is to add join() or detach() in the catch block.

// example.cpp
// g++ -std=c++14 example.cpp -lpthread -o example
#include <iostream>
#include <thread>

void thread_sleep(int sleep_seconds, bool verbose)
{
    std::thread::id this_id = std::this_thread::get_id();
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping for " << sleep_seconds << " seconds ..." << std::endl;
    }
    std::chrono::seconds sleep_duration{sleep_seconds};
    std::this_thread::sleep_for(sleep_duration);
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping complete! " << std::endl;
    }
}

void broken_func()
{
    throw std::runtime_error{"Runtime error!"};
}

int func()
{
    std::thread new_thread{thread_sleep, 5, true};
    try
    {
        thread_sleep(1, false);
        broken_func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
        // join new_thread in the catch block
        // since the other join will never be called if there is an exception in the try block
        if (new_thread.joinable())
        {
            new_thread.join();
        }
        throw;
    }
    new_thread.join();
    return 0;
}

int main()
{
    try
    {
        func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
    }
}

There are other ways to deal with the thread issue, such as using RAII.


We could create an instance of ThreadGuard right after the std::thread object is created. This instance of ThreadGuard would make sure the std::thread object always call join() or detach() before going out of scope.

// example.cpp
// g++ -std=c++14 example.cpp -lpthread -o example
#include <iostream>
#include <thread>

class ThreadGuard
{
public:
    explicit ThreadGuard(std::thread& t_) : t(t_) {}
    ~ThreadGuard()
    {
        if(t.joinable())
        {
            t.join();
        }
    }
    ThreadGuard(ThreadGuard const&) = delete;
    ThreadGuard& operator=(ThreadGuard const&) = delete;
private:
    std::thread& t;
};

void thread_sleep(int sleep_seconds, bool verbose)
{
    std::thread::id this_id = std::this_thread::get_id();
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping for " << sleep_seconds << " seconds ..." << std::endl;
    }
    std::chrono::seconds sleep_duration{sleep_seconds};
    std::this_thread::sleep_for(sleep_duration);
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping complete! " << std::endl;
    }
}

void broken_func()
{
    throw std::runtime_error{"Runtime error!"};
}

int func()
{
    std::thread new_thread{thread_sleep, 5, true};
    // g's destructor will always be called even if an exception throws in the try block
    ThreadGuard g(new_thread);
    try
    {
        thread_sleep(1, false);
        broken_func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
        throw;
    }
    return 0;
}

int main()
{
    try
    {
        func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
    }
}

Implementing some std::thread wrapper is also not a bad idea. Boost has a ScopedThreads library whose wrapper implementation is similar to the following ScopedThread class.

// example.cpp
// g++ -std=c++14 example.cpp -lpthread -o example
#include <iostream>
#include <thread>

class ScopedThread
{
public:
    explicit ScopedThread(std::thread t_) : t(std::move(t_))
    {
        if(!t.joinable())
        {
            throw std::logic_error("No thread");
        }
    }
    ~ScopedThread()
    {
        t.join();
    }
    ScopedThread(ScopedThread const&) = delete;
    ScopedThread& operator=(ScopedThread const&) = delete;
private:
    std::thread t;
};

void thread_sleep(int sleep_seconds, bool verbose)
{
    std::thread::id this_id = std::this_thread::get_id();
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping for " << sleep_seconds << " seconds ..." << std::endl;
    }
    std::chrono::seconds sleep_duration{sleep_seconds};
    std::this_thread::sleep_for(sleep_duration);
    if (verbose)
    {
        std::cout << "Thread " << this_id << " sleeping complete! " << std::endl;
    }
}

void broken_func()
{
    throw std::runtime_error{"Runtime error!"};
}

int func()
{
    ScopedThread st{std::thread{thread_sleep, 5, true}};
    try
    {
        thread_sleep(1, false);
        broken_func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
        throw;
    }
    return 0;
}

int main()
{
    try
    {
        func();
    }
    catch(const std::exception& e)
    {
        std::cerr << e.what() << std::endl;
    }
}

Notes

Although program termination during C++ program development and test helps us to find out the missing join() and detach() during runtime, if the program is not well tested and the missing join() and detach() go into the deployment, it will be a big headache to realize. We don’t want our program to crash during deployment! Try to think of all the possible scenarios, catch the exceptions and deal with them appropriately!

References