Skip to content

Getting a result back

Level: 101 · anyone who has joined a thread and then wondered where its answer went

One line: Only Rust's join and C's pthread_join hand back what the thread returned; in Go, C++, Java and Python, joining only waits, and the value — and the failure — travels back through a second object: a channel or a future.

A thread that computes something has to get the answer back to whoever started it. Every program below computes the same sum, 1 to 100, on another thread, and then starts a second thread that fails instead, to show which path a failure takes.

What joining returns The value comes back through A failure comes back as
Rust Result<T, Box<dyn Any + Send>> join() itself Err, holding the panic payload
Go there is nothing to join: go is a statement a channel a value you send, conventionally an error
C an int about the join itself pthread_join's second argument, a void * whatever the two functions agreed on, such as NULL
C++ void a std::future from std::async, or a variable the lambda captured get() rethrows the exception
Java void a Future from ExecutorService.submit, or a variable the lambda captured get() throws ExecutionException, with the exception as its cause
Python None a Future from ThreadPoolExecutor.submit result() re-raises the exception

Rust

Verified output of result_back_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

join() returned Ok(5050)
join() returned Err, and the payload is the panic message: "no total today"

join is the only way to get the T back, and its Result is not about the join: Err means the thread panicked, and the payload is whatever panic! was given — here a &str. The program silences the panic hook so that the transcript holds only what join returned; without that line, the panic message would also go to stderr.

The Rust library's Spawning a thread ↗ goes further: move, and the handles of thread::scope, which hand back values the same way.

result_back_rs.rs

result_back_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.

//! Getting a result back. In Rust, `join` returns what the thread returned --
//! or, if it panicked, the panic payload -- as a Result.
//!
//!   rustc --edition 2024 result_back_rs.rs -o result_back_rs && ./result_back_rs

use std::thread;

fn main() {
    let handle = thread::spawn(|| (1..=100u64).sum::<u64>());
    let joined = handle.join(); // Result<u64, Box<dyn Any + Send>>
    println!("join() returned {joined:?}");

    // A panicking thread prints its message to stderr through the panic hook.
    // This silences the hook so the transcript holds only what join() hands back.
    std::panic::set_hook(Box::new(|_| {}));

    let failing = thread::spawn(|| -> u64 { panic!("no total today") });
    match failing.join() {
        Ok(total) => println!("join() returned Ok({total})"),
        Err(payload) => {
            let message = payload.downcast_ref::<&str>().copied().unwrap_or("?");
            println!("join() returned Err, and the payload is the panic message: {message:?}");
        }
    }
}

Go

Verified output of result_back_go.go — regenerated by tools/run_examples.py, never hand-typed.

received from the channel: 5050
received a result whose err is "no total today"

There is no handle to ask. go is a statement, not an expression, and the spec's section on Go statements ↗ says that a function started this way has its return values discarded when it completes. So a goroutine's answer is always something it sends. The Go library's A goroutine has no handle ↗ takes this further — results collected back into input order, errors joined, and a one-slot channel used as a future — and The first error cancels the rest ↗ builds errgroup's behaviour from the standard library.

The program uses two channels: one of int, and one of a small struct carrying an error beside the value, which is the shape a result channel takes once the goroutine can fail. The errgroup package packages that pattern for a group of goroutines; it lives in golang.org/x/sync, outside the standard library, so no program here runs it.

result_back_go.go

result_back_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Getting a result back. In Go, `go f()` is a statement, not an expression:
// there is no handle, and a function's return values are discarded. A result
// comes back as a value sent on a channel -- and so does an error.
//
//  go build result_back_go.go && ./result_back_go
package main

import (
    "errors"
    "fmt"
)

type result struct {
    total int
    err   error
}

func sumTo100() int {
    total := 0
    for i := 1; i <= 100; i++ {
        total += i
    }
    return total
}

func main() {
    totals := make(chan int)
    go func() { totals <- sumTo100() }()
    fmt.Println("received from the channel:", <-totals)

    results := make(chan result)
    go func() { results <- result{err: errors.New("no total today")} }()
    r := <-results
    fmt.Printf("received a result whose err is %q\n", r.err)
}

C

Verified output of result_back_c.c — regenerated by tools/run_examples.py, never hand-typed.

pthread_join returned 0, and handed back a pointer to 5050
pthread_join returned 0, and handed back NULL

pthread_join returns 0 for "joined". A non-zero value is an error number about the join itself — POSIX ↗ lists, for example, a thread that tries to join itself — and never means that the thread's work failed.

The work's result is the void * the thread function returned, which pthread_join writes through its second argument. It must not point into the finished thread's stack, which is why the thread mallocs the total and the joining side frees it. What counts as failure is a contract between the two functions and nothing else: here, NULL.

result_back_c.c

result_back_c.c in full — pasted here by tools/run_examples.py from the file CI runs.

/* Getting a result back. In C, a thread function returns a void *, and
 * pthread_join's second argument receives it. What it points to, and what
 * counts as failure, is a contract between the two functions.
 *
 *   cc -std=c17 -Wall -Wextra -pedantic -pthread result_back_c.c -o result_back_c && ./result_back_c
 */
#define _POSIX_C_SOURCE 200809L

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

static void *sum_to_100(void *arg) {
    (void)arg;
    /* The result must outlive this thread's stack, so it lives on the heap
       and the joining thread frees it. */
    unsigned long *total = malloc(sizeof *total);
    if (total == NULL) {
        return NULL;
    }
    *total = 0;
    for (unsigned long i = 1; i <= 100; i++) {
        *total += i;
    }
    return total;
}

static void *no_total_today(void *arg) {
    (void)arg;
    return NULL; /* "failure", because this program says NULL means failure */
}

int main(void) {
    pthread_t t;
    pthread_create(&t, NULL, sum_to_100, NULL);

    void *returned;
    int rc = pthread_join(t, &returned);
    unsigned long *total = returned;
    printf("pthread_join returned %d, and handed back a pointer to %lu\n", rc, *total);
    free(total);

    pthread_create(&t, NULL, no_total_today, NULL);
    rc = pthread_join(t, &returned);
    printf("pthread_join returned %d, and handed back %s\n", rc,
           returned == NULL ? "NULL" : "a pointer");
    return 0;
}

C++

Verified output of result_back_cpp.cpp — regenerated by tools/run_examples.py, never hand-typed.

join() returns void: true
after join(), the variable the lambda captured holds 5050
std::async handed back a std::future<long>, and get() returned 5050
get() rethrew the thread's std::runtime_error: no total today

std::thread has no result, and the first line is the compiler confirming that join() returns void. The first way back is a variable the lambda captured by reference, which is safe here only because the read comes after join().

std::async with std::launch::async runs the function on a new thread and returns a std::future. get() waits, then returns the value or rethrows whatever the function threw. libc++ on the Mac and libstdc++ on Linux print the same four lines.

result_back_cpp.cpp

result_back_cpp.cpp in full — pasted here by tools/run_examples.py from the file CI runs.

// Getting a result back. In C++, std::thread::join() returns void. The value
// comes back through something else: a captured variable, or a std::future.
//
//   c++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread result_back_cpp.cpp -o result_back_cpp && ./result_back_cpp

#include <future>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <utility>

static long sum_to_100() {
    long total = 0;
    for (long i = 1; i <= 100; ++i) {
        total += i;
    }
    return total;
}

int main() {
    std::cout << std::boolalpha;
    std::cout << "join() returns void: "
              << std::is_void_v<decltype(std::declval<std::thread&>().join())> << "\n";

    long total = 0;
    std::thread t([&total] { total = sum_to_100(); });
    t.join();
    std::cout << "after join(), the variable the lambda captured holds " << total << "\n";

    std::future<long> future = std::async(std::launch::async, sum_to_100);
    std::cout << "std::async handed back a std::future<long>, and get() returned "
              << future.get() << "\n";

    std::future<long> failing = std::async(std::launch::async, []() -> long {
        throw std::runtime_error("no total today");
    });
    try {
        failing.get();
    } catch (const std::runtime_error& e) {
        std::cout << "get() rethrew the thread's std::runtime_error: " << e.what() << "\n";
    }
}

Java

Verified output of result_back_java.java — regenerated by tools/run_examples.py, never hand-typed.

after join(), the array the lambda captured holds 5050
Future.get() returned 5050
Future.get() threw ExecutionException, caused by java.lang.IllegalStateException: no total today

Thread.join() is void, so the first version writes into a one-element array: a lambda may capture only local variables that are effectively final, and the array reference is one, even though its element changes.

ExecutorService.submit returns a Future. get() waits and returns the value, or throws an ExecutionException whose cause is what the task threw. The executor here starts one virtual thread per task, and closing it at the end of the try block waits for them.

result_back_java.java

result_back_java.java in full — pasted here by tools/run_examples.py from the file CI runs.

// Getting a result back. In Java, Thread.join() returns void. A Future from an
// ExecutorService carries the value back, and get() wraps a thrown exception in
// an ExecutionException.
//
//   java result_back_java.java      (Java 25: a compact source file, which imports java.base)

void main() throws Exception {
    long[] box = new long[1];
    Thread thread = Thread.ofPlatform().start(() -> box[0] = sumTo100());
    thread.join();
    IO.println("after join(), the array the lambda captured holds " + box[0]);

    try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
        Callable<Long> sum = this::sumTo100;
        Future<Long> future = pool.submit(sum);
        IO.println("Future.get() returned " + future.get());

        Callable<Long> noTotalToday = () -> {
            throw new IllegalStateException("no total today");
        };
        Future<Long> failing = pool.submit(noTotalToday);
        try {
            failing.get();
        } catch (ExecutionException e) {
            IO.println("Future.get() threw ExecutionException, caused by " + e.getCause());
        }
    }
}

long sumTo100() {
    long total = 0;
    for (int i = 1; i <= 100; i++) {
        total += i;
    }
    return total;
}

Python

Verified output of result_back_py.py — regenerated by tools/run_examples.py, never hand-typed.

Thread.join() returned None
Future.result() returned 5050
Future.result() re-raised ValueError('no total today')

Thread.join() returns None whatever the target returned; the value is discarded. ThreadPoolExecutor.submit returns a Future, whose result() waits and returns the value, or re-raises the exception the call raised. Leaving the with block shuts the pool down and waits for its work.

result_back_py.py

result_back_py.py in full — pasted here by tools/run_examples.py from the file CI runs.

"""Getting a result back. In Python, Thread.join() returns None and the target's
return value is thrown away. A Future from an executor carries the value back --
and re-raises the exception if the call raised one.

    python3 result_back_py.py
"""

import threading
from concurrent.futures import ThreadPoolExecutor


def sum_to_100() -> int:
    return sum(range(1, 101))


def no_total_today() -> int:
    raise ValueError("no total today")


thread = threading.Thread(target=sum_to_100)
thread.start()
print("Thread.join() returned", thread.join())

with ThreadPoolExecutor() as pool:
    future = pool.submit(sum_to_100)
    print("Future.result() returned", future.result())

    failing = pool.submit(no_total_today)
    try:
        failing.result()
    except ValueError as e:
        print(f"Future.result() re-raised {e!r}")

What to do

  • Reach for the future-shaped API before a bare thread and a captured variable: join in Rust, a result channel in Go, std::async in C++, ExecutorService.submit in Java, ThreadPoolExecutor.submit in Python. They carry the failure as well as the value. A captured variable carries only the value, and leaves the reader to check that the read comes after the join.
  • In C, write the contract down beside the thread function: what the void * points to, who frees it, and which value means failure.

See also