C++ / CONCURRENCY AND PARALLELISM
std::thread and joining every thread you start
Start threads with std::thread, pass them arguments safely, and guarantee every thread is joined before its handle is destroyed.
What you will learn
- Launch work with std::thread and wait for it with join() on every exit path
- Use std::ref to hand a thread a real reference instead of a silent copy
- Keep threads in a vector, join them all, then read the results they wrote
- Explain why destroying a joinable thread calls std::terminate, and prevent it with RAII
Understanding std::thread and joining every thread you start
A std::thread object is a handle to a running function, not the function itself. The moment the constructor returns, a second thread is already executing your callable on its own stack while main carries on independently, so you now have two lifetimes to track: the scope of the handle object and the execution of the callable. join() ties them back together: it blocks the calling thread until the callable has returned, then releases the handle so joinable() reports false.
That is why the destructor is so strict. If a std::thread is destroyed while joinable() is still true, the destructor calls std::terminate: no unwinding, no message of yours, the process just dies. Both friendlier-sounding alternatives are worse, because silently joining would make an innocent closing brace block for an unbounded time, and silently detaching would leave a thread reading the locals of the frame being destroyed. The language forces you to state which one you meant, and that is what joining every thread you start really amounts to.
The arguments you pass to the constructor are decay-copied into storage owned by the new thread and then handed to the callable, so a thread never refers to a caller variable unless you ask for it with std::ref, and an array or string literal collapses to a pointer. Anything the thread does reach through a reference, pointer, or by-reference capture must stay alive until the join, which is another reason to join in the scope that owns the data. The join pays you back with visibility: everything the thread wrote before returning is observable by the joining thread afterwards, so launch, join, then read needs no extra machinery.
<functional>
<iostream>
<thread>
<vector>
// Each worker writes only to its own slot, so the slots need no locking.
void sum_range(int from, int to, long long& out) {
long long total = 0;
for (int i = from; i <= to; ++i) total += i;
out = total;
}
int main() {
const int workers = 4;
std::vector<long long> results(workers, 0);
std::vector<std::thread> pool;
pool.reserve(workers);
for (int w = 0; w < workers; ++w) {
int from = w * 250 + 1;
// std::ref is required: thread arguments are copied by default.
pool.emplace_back(sum_range, from, from + 249, std::ref(results[w]));
}
std::cout << std::boolalpha;
std::cout << "launched " << pool.size() << " threads\n";
std::cout << "pool[0].joinable() before join: " << pool[0].joinable() << "\n";
for (std::thread& t : pool) {
t.join(); // blocks until that worker's function has returned
}
std::cout << "pool[0].joinable() after join: " << pool[0].joinable() << "\n";
long long total = 0;
for (int w = 0; w < workers; ++w) {
std::cout << "worker " << w << " summed " << results[w] << "\n";
total += results[w];
}
std::cout << "grand total " << total << "\n";
}
A std::thread object owns a running thread, and that ownership must be resolved with join() or detach() before the object is destroyed, or the destructor calls std::terminate.
Worked examples
Copies by default, references by request
Shows that thread arguments are copied unless you wrap them in std::ref.
<functional>
<iostream>
<string>
<thread>
void mutate(std::string label, int& counter) {
label += "-touched"; // edits the thread's own copy
counter += 1; // edits main's variable through the reference
}
int main() {
std::string label = "job";
int counter = 0;
std::thread t(mutate, label, std::ref(counter));
t.join();
std::cout << "label = " << label << "\n";
std::cout << "counter = " << counter << "\n";
}
Example explained
Line 1label is copied into storage owned by the new thread and that copy is passed to mutate(), so main's string cannot change.
Line 2std::ref(counter) stores a reference_wrapper, which the invocation unwraps into the int& parameter, so the increment lands on main's variable.
Line 3Without std::ref this line would not compile: a freshly made copy cannot bind to int&.
Line 4Reading counter after t.join() is safe because join() waits until the worker's write has completed.
A destructor that cannot forget the join
Shows how putting the join in a destructor keeps it on the path an exception takes.
<iostream>
<stdexcept>
<thread>
struct joining_thread {
std::thread t;
~joining_thread() {
if (t.joinable()) {
t.join();
std::cout << "destructor joined the worker\n";
}
}
};
int main() {
int computed = 0;
try {
joining_thread guard{std::thread([&computed] {
for (int i = 1; i <= 10; ++i) computed += i;
})};
std::cout << "worker launched\n";
throw std::runtime_error("failure after launch");
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << "\n";
}
std::cout << "computed = " << computed << "\n";
}
Example explained
Line 1guard owns the thread, so its destructor runs on every way out of the try block: fall-through, return, or a throw.
Line 2The joinable() check matters because the member could have been moved out; calling join() on a moved-from thread throws std::system_error.
Line 3The destructor line prints before "caught:" because block-scope objects are destroyed during unwinding, before the handler body runs.
Line 4computed is read only after that join returned, so all ten additions are finished and visible.
Ownership moves, it is never copied
Shows what joinable() actually reports as a thread handle is moved and then joined.
<iostream>
<thread>
<utility>
int main() {
std::thread worker([] { /* trivial work, may finish immediately */ });
std::cout << std::boolalpha;
std::cout << "worker before move: " << worker.joinable() << "\n";
std::thread owner = std::move(worker); // handle transferred, no new thread
std::cout << "worker after move: " << worker.joinable() << "\n";
std::cout << "owner after move: " << owner.joinable() << "\n";
owner.join();
std::cout << "owner after join: " << owner.joinable() << "\n";
}
Example explained
Line 1std::thread has no copy constructor, so std::move hands the operating-system handle to owner and leaves worker empty.
Line 2worker.joinable() is false afterwards, which is exactly why its destructor is now harmless and why you must not join it.
Line 3owner.joinable() stays true even if the lambda already finished: joinable() reports possession of a handle, not whether code is still running.
Line 4join() releases that handle, so joinable() turns false and a second join() would throw std::system_error.
Important notes
detach() is not a shortcut around joining: after detaching you can never learn whether the work finished, and returning from main tears down globals while the detached thread may still be using them.
C++20's std::jthread joins in its own destructor, so the hand-written RAII wrapper is only needed on C++11/14/17 toolchains.
Common mistakes
Letting a std::thread variable go out of scope without join() or detach(): the destructor sees joinable() == true and calls std::terminate, so the process aborts even though the work itself succeeded.
Calling join() inside the same loop that creates the threads: each worker is waited for before the next is launched, so the code runs strictly serially and is no faster than a plain loop.
Handing the thread a reference or pointer to a local and then leaving the scope before joining: the thread writes into a stack frame that no longer exists, which is undefined behaviour that often surfaces as garbage values much later.
Try it yourself
Change, predict, then run
Rewrite the four-worker sum so the number of chunks comes from std::thread::hardware_concurrency(), falling back to 4 when it returns 0, and confirm that the total for 1..1000 is still 500500 no matter how many threads were launched.
Open the C++ workspaceCheck your understanding
A function constructs a std::thread and then throws an exception before reaching t.join(). What happens?
- The thread object's destructor sees joinable() == true and calls std::terminate, so the process aborts
- The exception propagates normally because the runtime joins the thread for you during unwinding
- The thread is silently detached and keeps running until its callable returns
- The exception is rethrown inside the worker thread, which then exits
Show answer
The destructor of std::thread requires joinable() to be false; when it is not, it calls std::terminate, so the exception never reaches a handler. The silent-detach option is tempting because detach() exists and would let the work finish, but detaching must be an explicit choice, since the thread would otherwise keep using the locals of the frame that just unwound. Only std::jthread joins during destruction.