Skip to content
VLSI Mentor

C++ Part I · Module 26 · Concurrency Fundamentals

std::thread: Starting, Joining and Detaching

std::thread is a handle to a running thread of execution, and its interface is small enough to learn in a page. What makes it worth a chapter is that the class makes one decision which is unusual in the standard library and surprising the first time it happens: a thread object destroyed while it still represents a running thread does not detach, does not wait, and does not throw. It calls std::terminate, and your process is gone before the next statement executes. That is a deliberate choice rather than an oversight, and the reasoning behind it is worth understanding because it tells you which of the two obligations — join or detach — you are expected to discharge and why the library refuses to guess. The chapter measures all four outcomes, including the C++20 type that removes the problem by joining in its own destructor, and the cost of starting a thread, which is the number that determines whether a thread is the right granularity for the work at all.

Advanced18 min readC++Part IFundamentalsConcurrency

Part I · Module 26 · Chapter 26.2 · std::thread: Starting, Joining and Detaching

1. Starting One

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#include <thread>

void work(int id);

std::thread t(work, 7);            // starts running immediately
std::thread u([]{ work(8); });     // a lambda is the usual form
t.join();
u.join();

Three facts about construction, all of which matter later.

The thread starts as soon as the object is constructed. There is no separate start(). By the time the constructor returns, the new thread may already have finished, or may not have begun; both are possible and your code must not care.

The arguments are copied into the thread's own storage, which is a lifetime rule rather than a performance note. std::thread t(work, localString) copies the string; std::thread t(work, std::ref(localString)) does not, and then the caller must guarantee the string outlives the thread. That is Chapter 25.4's lifetime question with a new way to get it wrong, and §6 is the detail.

A std::thread is move-only. You cannot copy a thread handle — there is one thread and one owner of it — which is the same ownership model as std::unique_ptr and for the same reason. Chapter 14.2 §4 is the argument.

2. The Three Outcomes, Measured

A std::thread that represents a running thread is joinable. Before it is destroyed you owe it exactly one of two things: a join() or a detach(). Here is what each does, and what happens if you do neither.

Neither:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
{ std::thread t([]{ }); }      // destroyed while still joinable
std::printf("if you can read this, nothing happened\n");
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  creating a std::thread and letting it go out of scope, still joinable
libc++abi: terminating

Exit status 134, and the line after the scope never printed. The destructor called std::terminate. There is no exception to catch, no warning at compile time, and no way to recover — Chapter 22.6 §4 measured the same mechanism for a noexcept violation, with the same outcome and the same exit code.

join():

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  joinable before join: 1
  joinable after join : 0

Exit 0. join() blocks until the thread finishes and then leaves the object non-joinable, so the destructor has nothing to do. This is what you want almost always.

detach():

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  joinable after detach: 0

Exit 0. The thread keeps running and the handle gives up its claim on it. The object is immediately non-joinable, so the destructor is satisfied — and you now have a thread nobody can wait for, which §4 is about.

And the C++20 alternative:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  jthread went out of scope with no join() and the process continued

Exit 0. std::jthread joins in its destructor, so the obligation is discharged automatically. §5 is why this is the type to reach for.

3. Why the Library Terminates Rather Than Choosing

This is the design question worth understanding, because the answer tells you what the library thinks you should do.

The destructor has two plausible behaviours and both are wrong:

It could join. Then a thread object going out of scope silently blocks, possibly forever, at a point in the code where nothing says "wait here". A function returning early — or unwinding from an exception — would block in its own cleanup. That is a hang with no visible cause, which is strictly harder to debug than a crash.

It could detach. Then the thread keeps running while the objects its lambda captured by reference are destroyed, which is Chapter 25.4's dangling reference with a second thread involved. The failure is a use-after-free in another thread, arriving later, with no connection to the scope that caused it.

So the standard chose the third option: refuse, loudly, at the point where the programmer failed to say which they meant. std::terminate is a worse user experience and a far better engineering outcome than either silent choice — which is exactly Chapter 23.7 §4's argument about a crash being preferable to a silent skip.

std::jthread exists because, for the overwhelmingly common case, joining is the right answer and the hang risk is acceptable when it is the documented behaviour of the type rather than a surprise from a base class's destructor.

4. Detach, and Why It Is Almost Always Wrong

detach() is the one part of this interface that reads as a convenience and behaves as a hazard.

You can no longer wait for the thread, so you cannot know whether it finished, and you cannot know whether it is still running when main returns. When main returns, the runtime begins destroying static objects and then exits the process; a detached thread still running at that moment is executing in a world where globals are being torn down underneath it.

You cannot know whether anything it captured is still alive. A detached thread holding a reference to a local is Chapter 25.4's case 1, delivered asynchronously.

It cannot be cancelled, because C++ has no thread cancellation. The only ways to stop it are a flag it checks (Chapter 26.7 §4) or std::jthread's stop token.

The legitimate uses are narrow: a thread that genuinely runs for the whole life of the program, owns everything it touches, and whose unfinished state at exit is acceptable. A logging thread is the standard example — and even then, having main set a flag and join is better, because it means the last log lines get written.

The practical rule: use join(), or better std::jthread. If you are reaching for detach(), you probably want a thread that outlives a scope, which means it should be owned by something that outlives that scope — a member of a class, or a thread pool.

5. jthread, Which Removes the Problem

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
{
    std::jthread j([]{ work(); });     // no join call anywhere
}                                      // joins here

Two features, both of which address something in §§3–4.

It joins in its destructor, so the obligation cannot be forgotten and §2's termination cannot happen. This is Chapter 22.4's RAII applied to a thread: the acquisition is the construction, the release is the join, and the release happens on every exit path including a thrown exception.

It carries a stop token, which is the cooperative cancellation C++ otherwise lacks:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::jthread j([](std::stop_token st) {
    while (!st.stop_requested()) { doSomeWork(); }
});
// j.request_stop() happens automatically in the destructor, before the join

The destructor requests the stop and then joins, so a loop written against the token terminates. That makes jthread the right default for a worker loop, where a plain std::thread needs the flag in Chapter 26.7 §4 and a way to set it before joining.

The honest caveat about the destructor-joins behaviour is §3's objection, now made explicit: the destructor can block. For a jthread whose body is a bounded piece of work that is fine. For one running an unbounded loop that ignores the stop token, the destructor hangs — so the token is not optional in that shape.

6. The Two Lifetime Traps

Starting a thread creates a second context whose lifetime is independent of the scope that created it, and the two mistakes that follow are worth naming precisely.

A captured reference to a local:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void start()
{
    int budget = 200;
    std::thread t([&budget]{ use(budget); });    // captures by REFERENCE
    t.detach();                                   // ...and the scope is about to end
}

With join() this is safe, because the scope cannot exit before the thread finishes. With detach() it is Chapter 25.4's dangling reference, and the measurement there is sobering: ASan with default options missed both stack-lifetime cases, and -Wreturn-stack-address only sees escapes decidable inside one function — which a lambda capture handed to a thread is not.

A reference argument whose owner dies:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::thread t(work, std::ref(config));   // config must outlive the thread

std::ref exists so you can pass a reference deliberately, and it transfers the whole outlives obligation to you. Without it the argument is copied, which is safe and sometimes surprising — a std::thread taking a std::string by value in its function signature still copies it twice unless you move it.

The defence for both is the same as Chapter 25.4 §7's: capture by value unless you can state why the referent outlives the thread, and prefer join() so that the statement is trivially true.

7. What a Thread Costs

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. the cost of starting and joining one thread
   std::thread + join (empty body) :    …  ns = … microseconds

Median over seven runs: 14.4 µs, range 11.2–27.3 µs. The range is wide because thread creation enters the kernel and competes with everything else on the machine.

Put beside the other primitives in this module:

operationcostfrom
plain ++counter0.28 nsChapter 26.4 §3
uncontended atomic fetch_add1.88 nsChapter 26.7 §3
mutex lock + unlock4.57 nsChapter 26.4 §3
start and join a thread~14,400 ns§7

Starting a thread is about three thousand times a mutex lock. Two consequences:

Never create a thread per work item unless each item is worth far more than 14 µs. Chapter 26.1 §4 is the arithmetic.

Create threads once and reuse them. That is what a thread pool is, and the existence of that number is its entire justification.

std::thread::hardware_concurrency() returns 8 here, and Chapter 26.1 §1 is the warning that comes with it: this machine's eight cores are four performance and four efficiency cores, so the count is an upper bound on useful threads rather than a target. It is also allowed to return 0 if the implementation cannot tell, so code using it needs a fallback.

8. Returning a Value, and Exceptions

A thread's function returns void as far as std::thread is concerned — there is nowhere for a return value to go. Two ways to get one out:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 1. write to something the caller owns
int result = 0;
std::thread t([&result]{ result = compute(); });
t.join();                         // the join is what makes reading `result` safe

// 2. std::async, which gives you a future
auto fut = std::async(std::launch::async, compute);
const int result2 = fut.get();    // and this rethrows anything compute() threw

The second form handles the thing the first cannot: an exception that escapes a thread's function calls std::terminate. Not propagated to the creating thread — there is no mechanism for that — so a worker that can throw must catch at its own top level:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::thread t([&]{
    try { compute(); }
    catch (...) { record(std::current_exception()); }   // and the caller rethrows after join
});

std::future does this for you by storing the exception and rethrowing it from get(), which is why std::async is the better choice when the work can fail. Chapter 22.1 §1's taxonomy still applies: an expected failure should be a returned value rather than an exception crossing a thread boundary.

Note also that the join() in form 1 is doing double duty. It waits, and it establishes the ordering that makes reading result defined — without it, reading result while the thread may still be writing is Chapter 26.3's data race.

9. The Table

the questionthe answermeasured
when does the thread startat construction; there is no start()§1
what are the argumentscopied, unless you use std::ref§1
can I copy a std::threadno — move-only, like unique_ptr§1
what do I owe a joinable threadexactly one of join() or detach()§2
what if I do neitherstd::terminate — libc++abi: terminating, exit 134§2
why not just join in the destructoran unexplained hang in cleanup§3
why not detachan asynchronous use-after-free§3
what does jthread dojoins in its destructor, and carries a stop token§5
is detach() ever rightrarely — a lifetime-of-program thread owning everything§4
what does a thread cost to startmedian 14.4 µs [11.2–27.3]§7
relative to a mutex lockabout 3,000×§7
what does hardware_concurrency() give8 here — and may return 0§7
what if the thread function throwsstd::terminate — catch inside, or use std::async§8

10. What to Actually Do

  • Use std::jthread where C++20 is available. The obligation belongs to the type, not to your memory.
  • Otherwise join(), on every path, and prefer a scope whose exit obviously reaches the join.
  • Avoid detach(). If a thread must outlive a scope, give it an owner that outlives the scope — a class member, or a pool.
  • Capture by value unless you can state why the referent outlives the thread; join() is what makes that statement trivial.
  • Use std::ref only deliberately, knowing it hands you the whole lifetime obligation.
  • Catch at the top of every thread function, or use std::async and let the future carry the exception. An escaping exception terminates the process.
  • Write a stop token or a flag into any loop a thread runs, so it can be asked to finish.
  • Do not create a thread per item. 14 µs of overhead; use a pool.
  • Treat hardware_concurrency() as an upper bound, handle a return of 0, and remember the cores may not be equivalent.
  • Remember join() also provides the ordering that makes reading the thread's results defined.

11. Mistakes Worth Making Once

A std::thread going out of scope on an early return. libc++abi: terminating, exit 134, and the next line never runs.

Adding detach() to silence that. You have replaced a crash with a dangling reference in another thread.

Capturing a local by reference and detaching. Chapter 25.4 measured how little tooling sees this shape.

Letting an exception escape a thread function. std::terminate, with no chance to handle it.

Reading a result without joining. A data race — Chapter 26.3.

A thread per work item. 14 µs each.

Assuming hardware_concurrency() threads are equivalent. Four of these eight are efficiency cores.

A jthread whose loop ignores the stop token. The destructor hangs instead of terminating — a different bad outcome.

std::ref by habit. It silently transfers a lifetime obligation.

12. Interview Relevance

The frozen curriculum marks this chapter High, and the question is reliably "what happens if a std::thread is destroyed without being joined?"

The answer is std::terminate — measured here as libc++abi: terminating and exit 134, with the statement after the scope never executing. What distinguishes a strong answer is the why: the destructor cannot join, because that is an unexplained block in cleanup code, and it cannot detach, because that abandons a thread holding references to objects about to be destroyed. So the standard refuses to guess and fails at the point of the omission — a crash at the cause rather than a hang or a use-after-free far from it.

The follow-up is usually jthread, and the point to make is that it is not merely a convenience: it moves the obligation from the programmer to a type, which is the same move as unique_ptr and lock_guard. Mentioning the stop token, and that the destructor requests the stop before joining, shows you have used it.

If cost comes up, that starting and joining a thread is ~14 µs against ~4.6 ns for a mutex lock — a factor of about 3,000 — explains thread pools in a sentence.

13. What You Should Be Able to Say Now

  • That a thread starts at construction, that its arguments are copied unless you use std::ref, and that std::thread is move-only.
  • That a joinable std::thread owes exactly one of join() or detach(), and that doing neither calls std::terminate — measured, libc++abi: terminating and exit 134, with the following statement never reached.
  • Why the library terminates: joining would be an unexplained hang in a destructor, detaching would abandon a thread holding references to dying objects, so it fails at the omission instead.
  • That std::jthread joins in its destructor and carries a stop token requested before the join — and that its destructor can still block if the loop ignores the token.
  • That detach() leaves a thread nobody can wait for, cancel, or reason about at exit, and that the alternative is an owner with a longer lifetime.
  • That starting and joining a thread costs a median 14.4 µs [11.2–27.3] against 4.57 ns for a mutex lock — about 3,000× — which is the argument for a pool.
  • That an exception escaping a thread function calls std::terminate, and that std::async's future stores and rethrows it.
  • That join() also establishes the ordering that makes reading the thread's results defined.

14. Exercises

Exercise 1 — the four outcomes. Write one program with four modes and reproduce §2's results, checking the exit status of each. Confirm that the statement after the joinable-destruction scope never executes.

Exercise 2 — the early return. Write a function that creates a thread and returns early on a condition. Confirm the termination. Then fix it three ways: a join() before the return, a jthread, and a scope guard.

Exercise 3 — the dangling capture. Capture a local by reference, detach, and let the scope exit. Run it under ASan with and without detect_stack_use_after_return=1 and record what each reports.

Exercise 4 — the escaping exception. Throw from a thread function and record what happens. Then catch inside the thread and rethrow after the join. Then do the same with std::async and compare the code.

Exercise 5 — the stop token. Write a jthread running an unbounded loop that ignores its stop token, and observe the destructor hanging. Then honour the token.

Exercise 6 — the cost. Measure thread creation on your machine as a median over several runs, and find the task size at which a thread per task breaks even.

15. Summary

  • A thread starts at construction, its arguments are copied unless you use std::ref, and std::thread is move-only — one thread, one owner.
  • A joinable std::thread owes exactly one of join() or detach(). Doing neither calls std::terminate: measured as libc++abi: terminating, exit 134, with the statement after the scope never executing.
  • That is a deliberate refusal to guess. Joining in the destructor would be an unexplained block in cleanup; detaching would abandon a thread holding references to objects being destroyed. Both fail far from the cause, so the library fails at it.
  • A thread is not a resource the handle owns — it is a peer, which is why its destructor has no "merely cleanup" option, unlike a file or a lock.
  • std::jthread joins in its destructor and carries a stop token requested before the join. It works because the obligation now belongs to a type, the same move as unique_ptr and lock_guard. Its destructor can still block if the body ignores the token.
  • detach() is the option with no owner — no way to wait, cancel, or reason about it at exit — which is why it is almost always wrong. A thread that must outlive a scope needs an owner that does.
  • Starting and joining a thread costs a median 14.4 µs [11.2–27.3], against 4.57 ns for a mutex lock and 1.88 ns for an uncontended atomic — about 3,000×, which is the whole argument for a thread pool.
  • An exception escaping a thread function calls std::terminate. Catch at the thread's top level, or use std::async so the future stores and rethrows it.
  • join() does two jobs: it waits, and it establishes the ordering that makes reading the thread's results defined — without which it is Chapter 26.3's subject.

Chapter 26.3 is that subject: what exactly goes wrong when two threads touch one location, and why the version with no synchronisation produced the correct answer every time.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.