Joining and Detaching Threads

When you create a std::thread, you establish a connection between your code and a system thread. This connection must be properly managed. Before the std::thread object is destroyed, you must explicitly choose one of two paths:

  1. Join - Wait for the thread to complete
  2. Detach - Let the thread run independently
If you do neither, your program calls `std::terminate()` and crashes.
void work() {
    std::cout << "Working...\n";
}

int main() {
    std::thread t(work);
    // If we reach here without join() or detach(), CRASH!
}  // std::terminate() called

Joining Threads

join() blocks the calling thread until the target thread completes execution:

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

void long_task() {
    std::cout << "Task starting...\n";
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Task complete!\n";
}

int main() {
    std::thread t(long_task);

    std::cout << "Main: waiting for task...\n";
    t.join();  // Blocks here for ~2 seconds
    std::cout << "Main: task finished!\n";

    return 0;
}

Output:

Main: waiting for task...
Task starting...
Task complete!
Main: task finished!

After join() returns:

  • The thread has definitely finished executing
  • Any side effects from the thread are visible (synchronization)
  • The std::thread object becomes non-joinable

You Can Only Join Once

std::thread t(work);
t.join();
t.join();  // ERROR: Throws std::system_error

Always check joinable() if there's any possibility the thread was already joined:

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

Joining Multiple Threads

When you have multiple threads, you often want to wait for all of them:

std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
    threads.emplace_back(task, i);
}

// Wait for all threads to complete
for (auto& t : threads) {
    t.join();
}

The order you join doesn't affect correctness - join() waits for that specific thread regardless of which completes first.

Detaching Threads

detach() separates the thread of execution from the std::thread object. The thread continues running in the background, but you lose all ability to interact with it.

void background_work() {
    for (int i = 0; i < 5; ++i) {
        std::cout << "Background: " << i << '\n';
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }
}

int main() {
    std::thread t(background_work);
    t.detach();  // Thread runs independently

    // Main thread continues immediately
    std::cout << "Main: continuing...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Main: done\n";
    return 0;
}

Possible output:

Main: continuing...
Background: 0
Background: 1
Main: done
Notice the background thread may not finish - when `main()` returns, the program exits and detached threads are terminated abruptly.

When to Use Detach

Detached threads are appropriate for:

  • Daemon tasks - Background work that should run for the program's lifetime
  • Fire-and-forget operations - Tasks where you don't need the result
  • Services - Long-running threads that handle requests

The Dangers of Detach

Detaching is inherently risky. Consider the following problems carefully before using `detach()`.

1. No cleanup guarantee:

void risky() {
    std::ofstream file("log.txt");
    std::thread t([&file]() {
        std::this_thread::sleep_for(std::chrono::seconds(2));
        file << "Log entry\n";  // file might be destroyed!
    });
    t.detach();
}  // file destroyed, but thread still references it

2. Abrupt termination:

int main() {
    std::thread t([]() {
        // Do important work...
        std::this_thread::sleep_for(std::chrono::seconds(10));
        // Clean up resources...
    });
    t.detach();

    // Main thread finishes quickly
}  // Detached thread killed mid-execution!

3. No return value or exception handling:

std::thread t([]() {
    throw std::runtime_error("Error!");  // Who catches this?
});
t.detach();  // Exception will call std::terminate()

Making Detached Threads Safer

If you must use detached threads:

1. Don't reference local variables:

// BAD
void bad() {
    std::vector<int> data = {1, 2, 3};
    std::thread t([&data]() { process(data); });
    t.detach();
}

// GOOD - copy or move data into thread
void good() {
    std::vector<int> data = {1, 2, 3};
    std::thread t([data = std::move(data)]() { process(data); });
    t.detach();
}

2. Use shared ownership:

void better() {
    auto data = std::make_shared<std::vector<int>>();
    std::thread t([data]() {  // Shared pointer copied
        // data stays alive as long as thread runs
        process(*data);
    });
    t.detach();
}

3. Coordinate shutdown properly:

std::atomic<bool> shutdown_requested{false};

void background_service() {
    while (!shutdown_requested) {
        // Do work...
    }
    std::cout << "Service shutting down cleanly\n";
}

int main() {
    std::thread t(background_service);
    t.detach();

    // Do main work...

    shutdown_requested = true;
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    // Give thread time to notice and clean up
}

Exception Safety

What happens if an exception is thrown before you join?

void dangerous() {
    std::thread t(work);

    might_throw();  // If this throws...

    t.join();       // ...we never reach here
}  // t is destroyed while joinable -> terminate()!

RAII Thread Guard

The solution is to ensure join() (or detach()) happens even if an exception is thrown:

class JoiningThread {
    std::thread t_;
public:
    template<typename... Args>
    explicit JoiningThread(Args&&... args)
        : t_(std::forward<Args>(args)...) {}

    ~JoiningThread() {
        if (t_.joinable()) {
            t_.join();
        }
    }

    // Prevent copying
    JoiningThread(const JoiningThread&) = delete;
    JoiningThread& operator=(const JoiningThread&) = delete;

    // Allow moving
    JoiningThread(JoiningThread&&) = default;
    JoiningThread& operator=(JoiningThread&&) = default;

    std::thread& get() { return t_; }
};

void safe() {
    JoiningThread t(work);

    might_throw();  // Even if this throws...

    // ...destructor will join
}

try-catch Approach

Alternatively, use try-catch:

void alternative_safe() {
    std::thread t(work);

    try {
        might_throw();
    } catch (...) {
        t.join();  // Join even on exception
        throw;     // Re-throw the exception
    }

    t.join();
}

This is more verbose and error-prone than RAII, but sometimes necessary.

Checking Thread State

joinable()

Returns true if the thread can be joined or detached:

std::thread t;
std::cout << t.joinable() << '\n';  // false (default constructed)

t = std::thread(work);
std::cout << t.joinable() << '\n';  // true

t.join();
std::cout << t.joinable() << '\n';  // false (joined)

After Moving

std::thread t1(work);
std::cout << t1.joinable() << '\n';  // true

std::thread t2 = std::move(t1);
std::cout << t1.joinable() << '\n';  // false (moved from)
std::cout << t2.joinable() << '\n';  // true (now owns thread)

t2.join();

Choosing Between Join and Detach

Use join() when... Use detach() when...
You need the thread's result You don't need the result
Thread uses local/scoped resources Thread is fully self-contained
You need to know when it's done Fire-and-forget operation
You want exception propagation Long-running background task
Most of the time! Rarely, and with caution
**Best practice:** Default to `join()`. Use `detach()` only when you have a specific reason and have carefully considered the lifetime issues.

Summary

  • join() blocks until the thread completes; use it to wait for results and ensure cleanup
  • detach() lets threads run independently but sacrifices control and safety
  • Every std::thread must be joined or detached before destruction
  • Use RAII wrappers to ensure threads are joined even when exceptions occur
  • joinable() tells you if a thread can still be joined or detached
  • Prefer join() over detach() unless you have a compelling reason