C++11
multithreading
exception handling
programming
software development

What happens when an exception goes unhandled in a multithreaded C11 program?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In C++11, exceptions do not automatically cross thread boundaries. If a worker thread throws and the exception escapes the thread’s entry function, the runtime does not deliver that error back to the main thread. Instead, the program calls std::terminate, which usually ends the entire process immediately.

What Actually Happens

Each std::thread runs its own top-level function. That function is the boundary of exception handling for that thread. If an exception is thrown inside the thread and nothing catches it before the function returns, the C++ runtime invokes std::terminate().

That is the critical rule: an unhandled exception in a std::thread does not merely stop that thread. It terminates the program unless you installed a custom terminate handler, and even then the default outcome is still abnormal shutdown.

A minimal example shows the behavior:

cpp
1#include <iostream>
2#include <stdexcept>
3#include <thread>
4
5void worker() {
6    throw std::runtime_error("boom from worker");
7}
8
9int main() {
10    std::thread t(worker);
11    t.join();
12    std::cout << "This line is never reached\n";
13}

On a normal implementation, the process ends when the exception escapes worker. The join call does not catch anything because the exception never traveled through the std::thread object.

Why Exceptions Do Not Propagate Through std::thread

std::thread is a threading primitive, not a result container. It manages execution, lifetime, and joining, but it does not store thrown exceptions for later retrieval. That design keeps the threading primitive simple, but it means error propagation is your responsibility.

If you need to send failure information back to another thread, you must catch the exception inside the worker and transfer it yourself. The standard library gives you two common ways to do that:

  • 'std::exception_ptr for manual capture and rethrow'
  • 'std::future from std::async, which stores exceptions automatically'

Capturing Exceptions with std::exception_ptr

The explicit pattern is to catch everything in the worker, save std::current_exception(), then rethrow on the joining side.

cpp
1#include <exception>
2#include <iostream>
3#include <stdexcept>
4#include <thread>
5
6void worker(std::exception_ptr& eptr) {
7    try {
8        throw std::runtime_error("database update failed");
9    } catch (...) {
10        eptr = std::current_exception();
11    }
12}
13
14int main() {
15    std::exception_ptr eptr;
16    std::thread t(worker, std::ref(eptr));
17    t.join();
18
19    if (eptr) {
20        try {
21            std::rethrow_exception(eptr);
22        } catch (const std::exception& ex) {
23            std::cout << "Worker error: " << ex.what() << '\n';
24        }
25    }
26}

This approach preserves the failure instead of letting it kill the process unexpectedly.

Using std::async When You Want Result Propagation

If the goal is simply to run work concurrently and retrieve a result or exception later, std::async is often a better fit than a raw thread.

cpp
1#include <future>
2#include <iostream>
3#include <stdexcept>
4
5int compute() {
6    throw std::runtime_error("calculation failed");
7}
8
9int main() {
10    auto fut = std::async(std::launch::async, compute);
11
12    try {
13        int value = fut.get();
14        std::cout << value << '\n';
15    } catch (const std::exception& ex) {
16        std::cout << "Caught from future: " << ex.what() << '\n';
17    }
18}

Here the exception is stored in the future and rethrown by get(). That is much closer to how many developers expect threaded errors to behave.

Process-Level Consequences

Unhandled thread exceptions are especially dangerous because they can interrupt the program in the middle of shared-state changes. Buffers may not flush. Locks may remain conceptually important even if the process is already dying. Log lines may be missing. External systems may observe a partially completed operation.

In practice, this means worker threads should have a clear policy:

  • catch exceptions at the thread boundary
  • log enough context to diagnose the failure
  • communicate the error to a coordinator thread
  • let the main application decide whether recovery is possible

That policy is far safer than letting random background threads become termination points.

Common Pitfalls

The first pitfall is assuming that join() behaves like a function call and will rethrow the worker exception. It does not. join() only waits for the thread to finish.

Another mistake is catching exceptions deep inside the worker and silently ignoring them. That avoids std::terminate, but it also hides real failures and can leave the application in a bad state.

A related issue is forgetting that detach() makes recovery even harder. Once a detached thread throws and terminates the process, there is no joining code around it to report what happened cleanly.

Finally, developers sometimes choose std::thread when they really want task-style result handling. If the operation produces either a value or an error, std::async, futures, or a task framework often match the problem better.

Summary

  • If an exception escapes a std::thread entry function, C++11 calls std::terminate().
  • Exceptions do not automatically propagate from worker threads to the thread that calls join().
  • Use std::exception_ptr to capture and rethrow worker failures explicitly.
  • Use std::async and std::future when you want built-in result and exception propagation.
  • Always define error-handling policy at the thread boundary instead of letting background failures abort the process.

Course illustration
Course illustration

All Rights Reserved.