AlgoMaster Logo

Thread Management (join, detach, jthread)

Medium Priority18 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Creating a std::thread is the easy part. The harder part is what happens after, because every running thread has to be either waited for or explicitly cut loose before its std::thread object is destroyed. Getting that step wrong calls std::terminate. This chapter covers join(), detach(), joinable(), why exceptions make manual cleanup fragile, how RAII wrappers fix the problem, and how std::jthread (C++20) bakes the safe pattern into the standard library along with cooperative cancellation through std::stop_token.

The Three States a Thread Object Can Be In

A std::thread object is a handle to a running thread of execution. At any moment, that handle is in one of three states, and the operations allowed on it depend on which state it is in.

The first state is joinable: the object owns a running (or already-finished) thread that nobody has joined or detached yet. This is the state immediately after construction, and it is the only state where calling join() or detach() is legal.

The second state is not joinable: the object has been joined, detached, moved-from, or default-constructed. Calling join() or detach() on a non-joinable thread throws std::system_error. The object still exists, it just no longer owns a thread of execution.

The third state is not really a state of the std::thread object so much as a fatal outcome: if a std::thread is destroyed while still joinable, the destructor calls std::terminate and the program ends. There is no recovery from that.

The diagram shows the lifecycle. A thread starts joinable as soon as it receives a callable, and it stays that way until explicitly resolved. The destructor refuses to clean up a joinable thread, which is the language's way of saying "the decision about what should happen to this work was missing."

std::thread::joinable() checks the current state without committing to anything.

(The "Sending confirmation" line may appear before or after either of the surrounding lines, depending on scheduling. It is guaranteed to appear before the "after join" line.)

The default-constructed std::thread is not joinable because it never had a thread to begin with. The one built from sendEmail is joinable until join() returns, then flips back to not-joinable. Both states are valid; the failure mode is destroying the object while the flag is still on.

What join() Actually Does

join() is a blocking call. It waits for the thread's callable to return, and only then does the function on the calling thread continue. It synchronizes with the background work before moving on.

The order of the two "Main:" lines and "Inventory sync started" can interleave because they run on different threads. What is guaranteed is that "Main: sync confirmed complete" appears after "Inventory sync finished", because join() will not return until the background callable has returned.

After join() returns, the std::thread object is no longer joinable. Calling join() again on the same object throws std::system_error.

(Exact message varies by compiler. The error code is std::errc::invalid_argument.)

join() blocks the calling thread until the worker finishes. If the worker hangs (waiting on I/O that never arrives, for example), the join hangs with it. Do not join from a thread that cannot afford to block, and put timeouts or cancellation around any background work that can stall.

What detach() Actually Does

detach() separates the thread of execution from the std::thread object. The thread keeps running, but there is no longer a handle to it. Nobody can join it, nobody can check whether it finished, and nothing waits for it before the program exits.

After detach(), logger is no longer joinable. The thread itself is still running, just unowned. When main returns, any detached threads that have not finished get terminated abruptly along with the process. That is the catch with detach(): when the work has not completed by the time the program ends, it gets cut off in mid-execution.

A second, subtler problem with detached threads: if the callable captures references or pointers to objects in the calling scope, those objects need to outlive the thread. The detached thread does not know when the scope it was born in goes away.

That program has undefined behavior. The detached worker captures orderId by reference, the local orderId dies when shipOrder returns, and 100 milliseconds later the worker reads it. The actual output might look right, or print garbage, or crash. The behavior is undefined either way.

The rule of thumb: detach() is almost always the wrong answer. Prefer join() (or std::jthread, covered later). When detach() is required, the callable should only touch values it owns (by-value captures, smart pointers, global state, or data structures whose lifetime is independently controlled).

Forgetting to Join: The std::terminate Trap

The classic failure mode: the std::thread destructor runs while the thread is still joinable, and the program ends abruptly.

emailer is destroyed at the closing brace of main. It is still joinable because nobody called join() or detach(). The destructor calls std::terminate, which aborts the process. No graceful shutdown, no destructors of other objects called, just an abrupt end.

This rule is harsh on purpose. The C++ committee considered two alternatives: have the destructor automatically join, or have it automatically detach. Both have problems. Auto-joining means the destructor of a local thread could block forever for reasons that are not obvious at the call site. Auto-detaching silently risks the dangling-reference UB shown above. The standard chose neither, making the failure loud and immediate.

The fix is to always do one of three things before the std::thread goes out of scope: join() it, detach() it, or move it somewhere else that takes responsibility for it.

That is the minimum responsible pattern, and it is enough as long as nothing between the constructor and the join() call ever throws an exception. Which, in real code, is a big condition.

Exceptions Make Manual Cleanup Fragile

Putting the join() after some code that might throw is a bug waiting to happen.

When processOrders(true) runs, the throw happens before priceJob.join() runs. Stack unwinding begins, the destructor of priceJob runs, the thread is still joinable, and std::terminate fires. The catch block in main is never reached.

The standard fix in pre-C++20 code is a try/catch inside processOrders that joins on the way out:

That works, but it is noisy. Every function that owns a std::thread has to remember this pattern, on every exception path. Forgetting it once causes the whole program to terminate the next time an exception fires through that scope. There is a better way.

RAII to the Rescue: A joining_thread Wrapper

The fix is the same pattern C++ uses everywhere resources need cleanup: wrap the thread in an object whose destructor handles the cleanup. The wrapper owns the std::thread, and in its destructor it calls join() if the thread is still joinable. Now the rule "always join before destruction" is enforced by the language's normal scope rules, even on the exception path.

Stack unwinding now does the right thing. When processOrders throws, syncJob's destructor runs, sees that the underlying thread is still joinable, and calls join(). Only after the worker thread has finished does the unwind continue up to main, where the catch block runs.

What is not in processOrders anymore: no try/catch, no explicit join() call, no special-casing the exception path. The RAII wrapper makes the safe behavior the default, for the price of writing the wrapper once.

A few notes on the wrapper's design:

  • The std::thread is held by value rather than inherited from. Either works; composition tends to be cleaner in practice.
  • The destructor checks joinable() because a moved-from joining_thread will not be joinable, and calling join() on a non-joinable thread throws.
  • Copy operations are deleted because std::thread is non-copyable. Move is fine and follows the same semantics as std::thread's move.
  • No detach() shortcut is exposed. That is deliberate: a joining_thread is for cases where the work must finish before the scope ends.

Some books call this scoped_thread instead of joining_thread. The names are interchangeable; the idea is identical.

Wrapping a std::thread in a joining_thread adds no runtime overhead compared to a manual try/catch/join pattern. The compiler inlines the destructor and the underlying thread is moved, not copied. The only "cost" is the discipline to use the wrapper instead of a raw std::thread.

std::jthread: RAII Built In (C++20)

Writing a joining_thread wrapper is a rite of passage in older codebases. C++20 added std::jthread ("joining thread") to the standard library so the wrapper is no longer needed. It behaves like std::thread except its destructor calls join() if the thread is still joinable, instead of calling std::terminate.

C++20 only: every std::jthread example below requires g++ -std=c++20 file.cpp -pthread or later. With -std=c++17, the header <thread> does not declare std::jthread.

Same output and same flow as the joining_thread example, without writing the wrapper. The std::jthread destructor sees a joinable thread, calls join(), then lets the unwind continue. No try/catch machinery in the function that owns the thread.

std::jthread has the same constructors as std::thread, the same join() / detach() / joinable() methods, and the same move semantics. The destructor is the difference, and it is a big one. For new code on C++20 or later, prefer std::jthread over std::thread unless there is a specific reason to manage the lifetime by hand.

Cooperative Cancellation: std::stop_token and request_stop()

std::jthread ships with a second feature that addresses a long-standing pain point: how to politely ask a thread to stop. C++ does not (and should not) have a thread-killing primitive, because abruptly stopping a thread leaves locks held, files open, and memory in inconsistent states. What std::jthread provides instead is a cooperative cancellation channel.

Every std::jthread automatically maintains a std::stop_source. When the callable accepts a std::stop_token as its first parameter, the framework passes one in. Calling request_stop() on the std::jthread flips a flag inside the shared state, and the running thread can poll that flag at safe points and decide to exit.

A few details. The lambda's first parameter is a std::stop_token. The caller did not pass it; std::jthread recognized that the first parameter type is std::stop_token and injected one automatically. Then priceJob.request_stop() set the flag, the worker checked it on the next iteration, and bailed out cleanly. When main returns, the std::jthread destructor calls join() (which is now a near-instant operation because the thread already exited).

This is "cooperative" cancellation because the thread chooses where and when to check. When a worker is stuck inside a blocking read() system call, request_stop() will not unstick it. The worker has to be designed to check the token at points where it is safe to abandon work.

The diagram shows the loop. The main thread requests a stop, which sets a flag in a state shared between the std::jthread and any token derived from it. The worker thread polls that flag at safe checkpoints. When it sees the request, it returns from the callable.

There is also an automatic request_stop() baked into the destructor itself. When a std::jthread is destroyed while the worker is still running, the destructor first calls request_stop() and then join(). So a long-running cancellable loop whose owning scope exits has its destructor signal the worker to stop and wait for it, all in one step. That is a meaningful difference from std::thread, which would just block on join() and hope the work finishes.

No explicit request_stop() call was needed. The destructor did it. That is a small piece of code with a meaningful safety property: a long-running cancellable worker shuts itself down automatically when its owning scope exits.

When to Detach (Almost Never)

detach() is a real tool with a narrow valid use case. Most of the time, picking it is a code smell. The places where it genuinely fits:

  • Fire-and-forget logging or telemetry where the work is short, owns its own data, and completion before program exit does not matter.
  • Daemon threads that should run for the entire lifetime of the program and have no clean shutdown point. (In a real system a clean shutdown is usually preferred anyway, but there are exceptions, like a watchdog that the OS will kill along with the process.)
  • Worker pools managed by another abstraction that takes responsibility for the threads at a higher level, where the std::thread handle is no longer needed once the worker is wired up.

Even in those cases, std::jthread plus request_stop() is usually a better fit. The reasons to avoid detach():

  • A detached thread that captures references to local variables produces undefined behavior the moment the local scope ends.
  • A detached thread is killed mid-execution when the program exits, regardless of whether it was doing critical work like flushing a buffer or releasing a lock on a shared resource.
  • A detached thread has no observable completion. There is no way to tell whether the work succeeded, failed, or is still running. For anything important, that is a problem.
ChoiceWhen it fitsWhen it is wrong
join()The result is needed before continuing.The work might hang and the caller cannot afford to block.
detach()Fire-and-forget, no shared state, no observable completion needed.Almost any time the callable refers to data outside itself.
std::jthread + request_stop()A long-running worker that can be cancelled and joined automatically.Pre-C++20 codebases (use a joining_thread wrapper instead).
Move into a worker poolA thread should outlive the current scope but still be observable.When the pool's lifetime is shorter than the work.

The general rule: prefer std::jthread (or joining_thread on pre-C++20), then join() on a raw std::thread, and only use detach() when none of the others apply and the callable has been verified to own everything it touches.

Putting It All Together: A Cancellable Background Job

A slightly larger example ties the pieces together. A price update job runs in the background, the main thread does some other work, and if the price update takes too long, the main thread cancels it. Note the -std=c++20 requirement.

The exact number of products processed before cancellation depends on scheduling, but the pattern is clear. The worker checked stop_requested() between products, saw the cancellation, and exited cleanly. The std::jthread destructor joined automatically. No std::terminate, no dangling references, no abandoned partial work that the next product could not recover from.

The same example without std::jthread is wordier. A manual std::atomic<bool> flag, a joining_thread wrapper, and a place to call flag.store(true) from the main thread are all required. The end result is the same; the C++20 way builds it into the standard library.

Quiz

Joining & Detaching Quiz

10 quizzes