Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 47

Managing C++ Threads: Creation, Joining, and Detaching

Recommended resources

Hello! Welcome back to our series on C++ concurrency.

In our last lesson, we took a deep dive into the system-level mechanics of thread creation. We established that std::thread on Linux is a C++ wrapper around the Pthreads library, which in turn uses the kernel's clone() system call to create a new 'task' that shares its parent's address space. This gave us a solid "first principles" foundation for what a thread is.

Today, we move up the abstraction stack to the C++ API itself. Our goal is to implement thread creation, joining, and detaching, and manage std::thread object lifetimes. This lesson is about mastering the C++ tools for controlling the OS thread resources we explored previously. We will cover how to start threads, pass them work, and, most importantly, how to manage their lifecycles correctly to avoid common pitfalls.

What is std::thread? An RAII Handle

Think of a std::thread object not as the thread itself, but as a C++ handle to the underlying OS thread of execution (the kernel task we discussed). This is a crucial distinction. The std::thread object follows the RAII (Resource Acquisition Is Initialization) idiom:

  • Acquisition: Creating a std::thread object acquires and starts an OS thread.
  • Release: The std::thread object's destructor is responsible for ensuring the OS thread resource is handled properly before the handle goes out of scope.

We will see that this "release" step is something you must manage explicitly.


Creating and Starting a Thread

Creating a thread is straightforward: you construct a std::thread object and pass it a callable entity (like a function or lambda) and its arguments. A key point to remember is that the new thread begins execution immediately upon the construction of the std::thread object. There is no separate .start() method as in some other languages.

Back to Basics: Concurrency - Arthur O'Dwyer - CppCon 2020

Let's start with a quick introduction to std::thread from Arthur O'Dwyer's CppCon talk. This will cover the basic syntax and the immediate start of execution.

Watch from 07:20 to 09:30. Focus on how the std::thread object is constructed with a lambda and the explanation that it starts executing immediately.

Here's a simple example:

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

void do_work(int id, int delay_ms) {
    std::cout << "Thread " << id << " starting work." << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms));
    std::cout << "Thread " << id << " finished work." << std::endl;
}

int main() {
    std::cout << "Main thread starting child thread." << std::endl;
    std::thread worker(do_work, 1, 500); // Create and start the thread

    // Let's discuss what needs to go here...
    
    worker.join(); // We'll get to this shortly
    std::cout << "Main thread finished." << std::endl;
    return 0;
}

In this code, the worker thread is created and immediately starts running the do_work function with arguments 1 and 500.


The Argument Passing Pitfall: Why std::ref is Necessary

This brings us to a critical "under the hood" detail that aligns perfectly with your goal of understanding C++ internals. What happens if you try to pass an argument by reference to a thread function?

Consider this:

void increment(int& value) {
    value++;
}

int main() {
    int my_value = 10;
    std::thread t(increment, my_value); // This will NOT compile!
    t.join();
    // if it did compile, would my_value be 11?
}

This code fails to compile. The reason is subtle and reveals how std::thread is designed for safety. The constructor of std::thread is a variadic template that, by default, copies or moves its arguments into internal storage. It does this to prevent dangerous situations where a thread might operate on a reference to a variable that has gone out of scope (e.g., a local variable in the parent thread).

This "decay-copy" mechanism strips the reference-ness from the arguments. To understand precisely why this happens, we need to look at the typical implementation of the std::thread constructor.

Deep (ish) Dive into Microsoft std::thread Constructor Implementation

This article provides a fantastic deep-dive into the std::thread constructor's implementation. It will answer exactly why arguments are copied and how std::ref solves the problem.

Read the sections 'Introduction', 'Some Equivalent Mechanism' (focusing on std::decay), and 'Conclusion and Trivia' (focusing on std::reference_wrapper). The key is to understand that std::decay removes references, causing a copy, and that std::ref wraps the reference in a type that survives this decay process.

As the article explains, the arguments are decayed, which turns an int& into an int, and a copy is made. To pass a true reference, you must wrap the argument in std::ref, which creates a std::reference_wrapper. This wrapper is a copyable object, but it holds a pointer to the original variable, and the thread machinery knows how to unwrap it to get the reference on the other side.

The correct way to write the previous example is:

#include <iostream>
#include <thread>
#include <functional> // Required for std::ref

void increment(int& value) {
    value++;
}

int main() {
    int my_value = 10;
    std::thread t(increment, std::ref(my_value)); // Correct!
    t.join();
    std::cout << "Final value: " << my_value << std::endl; // Prints "Final value: 11"
    return 0;
}

Managing the Lifecycle: join() and detach()

Once a thread is launched, you have two choices for managing its lifecycle, represented by two std::thread member functions: join() and detach(). You must call one of them.

C++ Thread Lifecycle: Join and Detach
This diagram illustrates the two paths for a thread's lifecycle after creation. Thread `t1` is managed via `join()`, forcing the `main` thread to wait. Thread `t2` is `detach()`ed, running independently until it finishes or the program terminates.

join(): Waiting for Completion

Calling join() on a std::thread object blocks the calling thread (e.g., main) until the thread represented by the object finishes its execution. This is how you synchronize the completion of a child thread with its parent.

  • Once join() returns, the OS thread has finished, and the std::thread object is no longer associated with it.
  • A std::thread object that can be joined is called joinable. After join() is called, it becomes non-joinable.
  • Calling join() on a non-joinable thread is an error that will terminate your program. You can check with the joinable() member function.

detach(): Letting Go

Calling detach() severs the connection between the std::thread object and its underlying OS thread. The C++ object becomes non-joinable, and the OS thread is now a "daemon" thread. It will continue to run in the background. Its resources will be reclaimed by the OS when it finishes, but you can no longer communicate with it or wait for it.

This is dangerous. If main() finishes and the program exits, all detached threads are abruptly terminated, regardless of what they are doing. This can lead to corrupted files, inconsistent state, and other hard-to-debug problems.

The following video demonstrates these concepts practically.

Join And Detach In C++

This CppNuts video clearly demonstrates the behavior of join() and detach(), including the common pitfalls associated with each.

Watch the following segments: join() (00:28 - 05:04): Pay attention to how main waits for the worker thread to finish. Double-join() (05:38 - 07:57): Understand why you must use t.joinable() before calling join(). detach() (08:09 - 13:43): Observe how main can terminate before the detached thread completes its task, and see how adding a sleep in main changes this behavior.


The Cardinal Rule: A std::thread's Destructor is Strict

This brings us to the most important rule of std::thread lifetime management. When a std::thread object's destructor is called (e.g., it goes out of scope), it checks if the thread is still joinable.

If it is joinable (meaning you have called neither join() nor detach()), the destructor will call std::terminate(), aborting your entire program.

This is a deliberate, safety-first design choice. A joinable std::thread object being destroyed would leave an active OS thread unmanaged—a leaked resource. C++ prevents this by making it a fatal error.

Join And Detach In C++

Let's watch the final crucial point from the same video, which explains the consequence of not calling join() or detach().

Watch from 16:01 to 16:50. This section explicitly states the rule that the destructor terminates the program if the thread is still joinable.

Test your understanding!

Consider the following three code snippets. For each one, predict the program's behavior and the likely output.

Snippet A:

int main() {
    std::thread t([]{
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Thread finished." << std::endl;
    });
    // No join() or detach()
    return 0;
}

Snippet B:

int main() {
    std::thread t([]{
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Thread finished." << std::endl;
    });
    t.detach();
    std::cout << "Main finished." << std::endl;
    return 0;
}

Snippet C:

int main() {
    std::thread t([]{
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Thread finished." << std::endl;
    });
    t.join();
    t.join(); // Called a second time
    return 0;
}
Show answer

Snippet A: The program will terminate immediately by calling std::terminate. When t goes out of scope at the end of main, its destructor is called. Since t is still joinable, this is a fatal error. You will likely see an "abort" message and "Thread finished." will never be printed.

Snippet B: The program will print "Main finished." and then exit immediately. Because the thread is detached, main does not wait for it. The child thread is running in the background but will be abruptly killed when the main function returns and the process terminates. "Thread finished." will almost certainly not be printed.

Snippet C: The program will wait for 1 second, print "Thread finished.", and then terminate immediately. The first call to t.join() is valid and blocks until the thread completes. After it returns, t is no longer joinable. The second call to t.join() is on a non-joinable thread, which is an error that invokes std::terminate.


Conclusion

You now have the fundamental tools to manage C++ threads. We've seen that std::thread is more than just a function call; it's a resource handle whose lifecycle you are responsible for.

Key Takeaways:

  • A std::thread object is a handle to an OS thread; creating it starts execution immediately.
  • Arguments to a thread's function are copied by default for safety. To pass a true reference, you must use std::ref.
  • You must manage a thread's lifecycle:
    • join(): To wait for the thread to complete.
    • detach(): To let it run independently (use with caution).
  • The Golden Rule: The destructor of a std::thread will call std::terminate() if the thread is still joinable. Always ensure you call either join() or detach() before the std::thread object is destroyed.

What's Next?

We've learned how to launch multiple threads of execution. This power comes with new challenges. When multiple threads try to access and modify the same data (like a shared counter or a queue), we can get unpredictable results. This problem is called a race condition. In our next lesson, we will analyze race conditions at the memory level and introduce the most fundamental synchronization primitive for preventing them: the std::mutex.