Multithreading

Joining and Detaching Threads

Once a std::thread object has been instantiated, the new thread of execution started then runs independently of the thread that created it. At this point, the std::thread object becomes a handle to a currently executing concurrent activity.

A property that a handle possesses that is vital for understanding std::thread is whether or not the handle is joinable.

A std::thread is considered joinable as long as it is a handle to a currently executing thread that has neither joined nor been detached; the destructor of std::thread will analyze this property.

If the std::thread object is joinable at the point of its destruction, then the destructor will invoke std::terminate and cause the abortion of the whole program.

The reason why the standard library adopts such a stringent policy is a refusal to second guess the programmer's intentions. 

A non explicit join would block the calling thread for an unpredictable amount of time, while a non explicit detach would leave the secondary thread executing long after the handle that previously controlled it has terminated. Each of these has definite behavioral consequences, and so they should only be explicitly specified by the programmer before the std::thread object ceases to exist.

When join is invoked on a joinable std::thread, the calling thread is blocked until the thread represented by that std::thread object completes execution. Two effects will always occur after the call to join has completed:

  • The secondary thread will have completed all work and released all underlying OS level thread resources.

  • Any side effects from the secondary thread will be observable in the thread that invoked join. In terms of the C++ memory model, the completion of the secondary thread synchronizes with the completion of the call to join.

Once join completes, the std::thread object will no longer be joinable. Any further call to join on that object, or on a default constructed or previously detached thread, will throw a std::system_error.

#include <iostream>
#include <thread>

void worker()
{
    std::cout << "Secondary thread is working\n";
}

int main()
{
    // Declare t before code needs to call it
    std::thread t(worker);
    t.join();
    std::cout << "Secondary thread has finished\n";
    return 0;
}

The following code example defines a function worker and creates a std::thread object named t which is used to execute worker. After constructing t, the program proceeds to t.join(), which causes the main thread to stall until the secondary thread has completed execution of the worker function.

Only then will the final message be printed, and only once that has happened will the program terminate. The destructor of t is called with a non joinable object, and therefore nothing further happens.

This necessity of joining (or detaching) before destruction becomes an issue as soon as the creating function is allowed to exit either via an exception or an early return. Consider the following:

void do_work()
{
    std::thread t([] {
        // long running background task
    });

    // ... more code that might throw

    t.join();   // this line is never reached if an exception is thrown
}

If an exception is raised after a thread has been created but before join has been called, when the stack is unwound the std::thread object will be destroyed while it is still joinable. In doing so, the destructor will invoke std::terminate and cause the program to crash even if the original exception was recoverable.

The same scenario arises in the case of early return, goto, or any similar path that causes control to bypass the join call.

Before C++20, the standard workaround for this issue involved encapsulating the std::thread within a simple RAII class whose destructor would always be called when it went out of scope, thus guaranteeing that a join would always be attempted.

#include <thread>

class ThreadGuard {
    std::thread& t;

public:
    explicit ThreadGuard(std::thread& t_) : t(t_) {}

    ~ThreadGuard()
    {
        if (t.joinable())
            t.join();
    }

    ThreadGuard(const ThreadGuard&) = delete;
    ThreadGuard& operator=(const ThreadGuard&) = delete;
};

void do_work()
{
    std::thread t([] {
        // background work
    });

    ThreadGuard guard(t);

    // ... code that might throw or return early
    // the guard's destructor will join t automatically
}

When the function do_work is called, a secondary thread is created and then passed to a ThreadGuard object, which stores a reference to it. Every exit from do_work (whether by normal return, early return, or exception) will cause the destructor of ThreadGuard to be invoked and then join the thread, provided it has not already done so (e.g., through a previous join or detachment).

The copy constructor and copy assignment operator of ThreadGuard are explicitly disabled to avoid the creation of two ThreadGuard objects that reference the same std::thread.

This solution is effective but it means that programmers have to remember to put the guard in place each time. Furthermore, in each codebase it has to be implemented (or copied).

Rather than join a thread, it can be detached. This completely severs the connection between the std::thread object and the execution of the thread that it previously managed.

After detach has successfully completed, the std::thread object will no longer be joinable and thus no longer threatens to abort the program if not joined. The secondary thread continues to execute completely independently; the program has lost any control over the future execution of that thread.

#include <iostream>
#include <thread>
#include <chrono>

void background()
{
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Background task finished\n";
}

int main()
{
    std::thread t(background);
    t.detach();
    std::cout << "Main thread continues immediately\n";
    // t is now non joinable; its destructor is harmless
    return 0;
}

In the code example above, the secondary thread is detached almost immediately after it is created. The main thread then prints its own message and immediately exits while the secondary thread still goes on sleeping.

When the whole process exits, all resources will be reclaimed by the OS.

Detaching should only be done when the thread of execution is guaranteed to live longer than the thread creating it, and furthermore is never going to be waited on or stopped by the programmer's code. Because the program is no longer able to join the thread, it cannot even guarantee that it has finished before any objects which the thread might continue to use are disposed of.

If the detached thread is ever going to access any object owned by the thread that detached it, this object must outlive the process or an error will occur.

A std::thread object is not copyable because at no point should there ever be two std::thread objects referring to the same thread of execution, but it is move assignable/move constructible, and any move means that the receiving std::thread object takes ownership of whether the thread is joined or detached.

std::thread create_worker()
{
    return std::thread([] {
        // work
    });
}

int main()
{
    std::thread t = create_worker();   // ownership transferred by move
    t.join();
    return 0;
}

If create_worker constructs a temporary std::thread and then returns a move constructed reference to that thread, then the primary thread receives control of that thread by move construction into t. Once the temporary std::thread object is moved from, it's not joinable so its destructor will have no effect: t has now been given ownership of that thread of execution and will be responsible for joining or detaching it.

A move assignment has one crucial exception: if the destination object is still joinable when move assignment occurs, std::terminate is called on the current thread to avoid implicitly taking control away from a still joinable thread.

In C++20, a new class called std::jthread was introduced into . This is effectively a std::thread that does the following: if the thread has not been detached or joined, its destructor will join it, ensuring that the program doesn't abort with std::terminate.

It can also accept a stop token for cooperative cancellation.

#include <iostream>
#include <thread>

void do_work()
{
    std::jthread t([] {
        std::cout << "Background work\n";
    });

    // ... code that might throw or return early
    // t's destructor will join automatically
}

When do_work completes (no matter how), the destructor of t is called. This destructor joins the thread if it has not already been joined. The need for explicit guards for simple RAII managed threads is eliminated.

The std::jthread is the default choice for worker threads in any code that uses C++20, since it avoids this common lifetime error. While std::thread is useful for threads which are explicitly designed to run independently of their owner (i.e., those being detached) or which need to be precisely joined (perhaps for fine grained control over memory synchronization), worker threads that should simply outlive their immediate scope without requiring explicit joining should all use std::jthread.

When a class has a data member of type std::thread and also contains data used by that thread, careful attention must be paid to the order of destruction for class members. Class members are always destroyed in the reverse order of their declaration: therefore, the thread data member must be placed last in the class declaration, ensuring that the data it manipulates does not cease to exist while the thread destructor attempts to join it.

class Task {
    std::vector<int> data;          // declared first
    std::jthread worker;            // declared last

public:
    Task()
        : data{1, 2, 3},
          worker([this] {
              // safe to use data here
          })
    {
    }
};

When a Task is destroyed, the worker member (which manages the thread) is destroyed first; the destructor will then attempt to join the thread while data still exists in memory. After a successful join, the destructor will complete and then data will be destroyed.

If worker were declared before data instead, this thread destructor would attempt to join while data could have already been destroyed.

0 comments

No comments yet. Be the first.

Add a comment

About CppLearn

CppLearn is an interactive, free reference for modern C++. In each lesson, you'll find out how the language works, rather than just lines of code that you can copy and paste. We cover concepts starting from beginner lessons on fundamentals and proceed to templates, move semantics, smart pointers, and areas that usually trap or scare C++ learners.