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.
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:
joinin Rust, a result channel in Go,std::asyncin C++,ExecutorService.submitin Java,ThreadPoolExecutor.submitin 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¶
- Who waits when main returns? — the previous lesson: what happens to a thread nobody joins.
- The Rust library's Channels ↗ — the Go pattern in Rust, where ownership of the value moves with it.
- The Go library's A worker pool ↗ — Go's answer to
ExecutorServiceandThreadPoolExecutor, with results returned in input order. - Concepts: Future and promise · Join · Channel · Thread pool and executor