Linux System Programming · intermediate · ~10 min
- By the end you can return a value from a worker thread and collect it in the joining thread via `pthread_join`. - By the end you can choose correctly between packing a small integer into the `void *`, using shared static storage, and returning a `malloc`'d struct. - By the end you can explain why returning the address of a stack local is always a dangling-pointer bug. - By the end you can define clear ownership: who allocates the result and who frees it. - By the end you can signal failure from a thread and handle it in the joiner without leaking or crashing.
You already know from Passing arguments to threads that pthread_create hands your thread function a single void *, and that the safe pattern is to point it at storage that outlives the thread (a malloc'd block or a value packed into the pointer itself). Returning data is the mirror image of that skill: instead of data flowing in through the argument, it flows out through the thread's return value and back to whoever calls pthread_join.
This lesson focuses entirely on the output side of the contract. The mechanics are small — a thread function returns void *, and the joiner receives it through a void ** — but the lifetime rules matter enormously. Get them wrong and you get a dangling pointer, a data race, or a leak. Get them right and threads become clean, composable units that compute a result and hand it back.
Real programs fan work out to threads precisely so they can collect answers back: a parser thread returns a syntax tree, a checksum thread returns a hash, a download thread returns bytes read or an error code. If the returned pointer aims at freed or stack memory, the joiner reads garbage — a classic use-after-free that is exploitable when an attacker can influence timing or the freed bytes. Defining who owns and frees each result is the difference between a program that scales cleanly across cores and one that corrupts memory under load. Getting return-value lifetimes right is a core defensive habit in any multithreaded C service.
A thread start function has exactly this signature:
void *worker(void *arg);
Whatever it returns (or passes to pthread_exit) becomes available to a thread that joins it. pthread_join takes the address of a void * and writes the returned pointer there:
void *result;
pthread_join(tid, &result); /* result now holds what worker returned */
Think of void * as a 64-bit envelope. You can put a real pointer in it, or you can put a small integer encoded as a pointer. The joiner must know which convention was used and decode accordingly — the type system will not help you here.
worker thread joining thread
------------- --------------
compute ...
return P; --------- P -------------> pthread_join(tid, &result)
(thread exits, result == P
stack destroyed) decode/use P, then free if owned
The pointer you return must still be valid after the thread exits. When a thread finishes, its stack is torn down. Any pointer into that stack immediately dangles. So the returned pointer must aim at storage whose lifetime is independent of the thread: the pointer's own bits (an encoded integer), static/global memory, or the heap.
| Method | How | Lifetime-safe? | Reentrant? | Who frees |
|---|---|---|---|---|
| Pack an int into the pointer | return (void *)(intptr_t)v; |
Yes (no memory involved) | Yes | Nobody |
| Static/global variable | return &g_result; |
Yes | No — shared by all threads | Nobody |
malloc'd struct |
return r; |
Yes | Yes | The joiner |
| Address of a local | return &local; |
NO — dangling | — | — |
intptr_t: return (void *)(intptr_t)42;. intptr_t (from <stdint.h>) is an integer type wide enough to hold a pointer, so the round-trip is lossless. The joiner decodes with int v = (int)(intptr_t)result;.return &g_result; keeps the storage alive, but every thread shares that one object, so two workers running at once clobber each other. Only safe with a single worker or external synchronization — not reentrant.malloc'd struct. Allocate on the heap inside the thread; the block outlives the thread's stack. The returned pointer is valid until someone frees it. Ownership transfers to the joiner, which must call free.Because the channel is a single pointer, a common convention is to return NULL to mean "failed" (e.g. malloc returned NULL). The joiner checks for NULL before dereferencing. Ownership is the other half of the contract: with the heap method, the joiner frees. If nobody joins a joinable thread, its result (and its thread resources) leak — always either join or detach.
Knowledge check: A worker does int x = 7; return &x; and the joiner reads *(int *)result. What is wrong, and what is the smallest fix?
xlives on the worker's stack, which is destroyed the instant the thread exits, soresultdangles — reading it is undefined behaviour. The smallest fix here is to not use memory at all:return (void *)(intptr_t)7;and decode with(int)(intptr_t)result. If the value were too big to pack,malloca block, copy into it, return that, and free it in the joiner.
#include <pthread.h>
#include <stdint.h> /* intptr_t for packing integers into void* */
/* Thread start routine: takes one void*, returns one void*. */
void *worker(void *arg);
/* Wait for `thread` to finish; store its return value into *retval.
* retval may be NULL if you don't want the value.
* Returns 0 on success, or an errno-style error code (does NOT set errno). */
int pthread_join(pthread_t thread, void **retval);
/* Exit the current thread, making `retval` its return value.
* Equivalent to `return retval;` from the start routine. Never returns. */
void pthread_exit(void *retval);
Key points:
pthread_join's second argument is void ** — pass &some_void_ptr, or NULL to discard the result.pthread_* functions return the error code directly; they do not set errno. Check the return value, e.g. if (pthread_join(t, &r) != 0) ....pthread_exit(p) are equivalent ways to produce the result p.intptr_t (not directly to void *) to stay portable and warning-clean.A thread function returns a void * (a generic pointer that can point to any type). The thread that calls pthread_join passes a void ** to receive that pointer back.
The key rule: the returned pointer must stay valid after the thread exits. When a thread ends, its stack is destroyed. Any pointer into that stack becomes invalid.
(void *)(intptr_t)42). This is safe for integers that fit in a pointer. intptr_t is an integer type guaranteed to hold a pointer value.malloc'd struct. The pointer stays valid after the thread exits. The joiner is then responsible for calling free.#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
/* A heap-allocated result the joiner takes ownership of and must free. */
typedef struct {
long sum; /* 1 + 2 + ... + n */
int n; /* the input we were asked to process */
} result_t;
/* Method 1: return a small integer packed into the void* itself. */
static void *square_worker(void *arg) {
int n = (int)(intptr_t)arg; /* unpack the argument */
return (void *)(intptr_t)(n * n); /* pack the result back */
}
/* Method 2: return a malloc'd struct; the joiner owns and frees it. */
static void *sum_worker(void *arg) {
int n = (int)(intptr_t)arg;
result_t *r = malloc(sizeof *r); /* survives thread exit */
if (!r) return NULL; /* signal failure clearly */
r->n = n;
r->sum = (long)n * (n + 1) / 2;
return r; /* pointer stays valid */
}
int main(void) {
/* --- Method 1: value squeezed into the pointer --- */
pthread_t sq;
pthread_create(&sq, NULL, square_worker, (void *)(intptr_t)9);
void *raw = NULL;
pthread_join(sq, &raw); /* raw now holds 81 */
int squared = (int)(intptr_t)raw;
printf("square: 9*9 = %d\n", squared);
/* --- Method 2: several threads, each returning its own heap struct --- */
enum { N = 4 };
pthread_t workers[N];
int inputs[N] = { 10, 20, 30, 40 };
for (int i = 0; i < N; i++)
pthread_create(&workers[i], NULL, sum_worker,
(void *)(intptr_t)inputs[i]);
for (int i = 0; i < N; i++) {
void *ret = NULL;
pthread_join(workers[i], &ret); /* collect this thread */
result_t *r = ret; /* void* -> result_t* */
if (r) {
printf("sum 1..%d = %ld\n", r->n, r->sum);
free(r); /* joiner owns the memory */
} else {
printf("worker %d: allocation failed\n", i);
}
}
return 0;
}
typedef struct { long sum; int n; } result_t; — the shape of a heap result. It carries both the answer and the input so the joiner can label each result correctly, even though the threads finish in an unpredictable order.square_worker — demonstrates Method 1. int n = (int)(intptr_t)arg; unpacks the argument that pthread_create delivered. return (void *)(intptr_t)(n * n); packs the answer into the pointer's bits — no memory is allocated, so there is nothing to free and nothing to dangle.sum_worker — demonstrates Method 2. malloc(sizeof *r) allocates on the heap, which outlives the thread's stack. if (!r) return NULL; turns an allocation failure into a clear signal the joiner can test. Filling r->n and r->sum and return r; hands a still-valid pointer back.pthread_create(&sq, NULL, square_worker, (void *)(intptr_t)9) — launches the square worker with argument 9, encoded the same way we will decode it.pthread_join(sq, &raw) — blocks until the worker exits and writes its returned pointer into raw. Note we pass &raw (a void **).int squared = (int)(intptr_t)raw; — decodes the packed integer using the exact reverse of the encoding.pthread_create calls — fans out four independent workers, each with its own input. Each will allocate its own result struct, so there is no shared state to race on.pthread_join(workers[i], &ret) then result_t *r = ret; — collects one worker and reinterprets the generic void * as our concrete type. Joining in a loop is fine; each thread is joined exactly once.if (r) { ...; free(r); } — the ownership rule made concrete: the joiner checks for the failure sentinel, uses the data, then frees it. Skipping free here would leak one struct per worker.1. Returning the address of a stack local (the classic dangling pointer)
/* WRONG */
void *worker(void *arg) {
int result = 42;
return &result; /* result dies when the thread's stack unwinds */
}
Why it breaks: the thread's stack is destroyed at exit, so the joiner dereferences freed memory — undefined behaviour that may print garbage, crash, or silently "work" until it doesn't.
/* FIXED: pack it, or malloc it */
void *worker(void *arg) {
return (void *)(intptr_t)42; /* no memory involved */
}
2. Forgetting to free a heap result in the joiner
/* WRONG */
void *ret;
pthread_join(t, &ret);
result_t *r = ret;
printf("%ld\n", r->sum); /* r is never freed -> leak per join */
Why it breaks: ownership transferred to the joiner, but nobody frees it; under a loop or server this leaks steadily.
/* FIXED */
result_t *r = ret;
printf("%ld\n", r->sum);
free(r);
3. Casting an int straight to void * (and back) instead of through intptr_t
/* WRONG / non-portable, warns */
return (void *)(n * n);
int v = (int)raw; /* may truncate the pointer */
Why it breaks: converting between a narrow int and a pointer directly is implementation-defined and can lose bits; compilers warn for good reason.
/* FIXED */
return (void *)(intptr_t)(n * n);
int v = (int)(intptr_t)raw;
4. Using shared static storage from concurrent workers
/* WRONG when >1 thread runs */
static result_t g;
void *worker(void *arg) { g.sum = compute(arg); return &g; }
Why it breaks: every thread writes the same object, so results overwrite each other — a data race and wrong answers.
/* FIXED: give each thread its own heap result */
result_t *r = malloc(sizeof *r);
r->sum = compute(arg);
return r; /* joiner frees */
valgrind --leak-check=full ./prog) is your first stop. "definitely lost" points at heap results the joiner never freed; "Invalid read/write" of a freed or stack block points at a dangling return pointer.cc -fsanitize=address -g) catches use-after-return and use-after-free at the moment of the bad access, with a stack trace showing both where the memory was freed/left scope and where it was used — often clearer than Valgrind for stack-return bugs.cc -fsanitize=thread -g) flags the data race you get from sharing one static result across concurrent workers.pthread_join, then print result and print *(result_t *)result. If the fields are nonsense right after a valid-looking return, suspect a dangling or wrong-type pointer. info threads confirms which threads have exited.return and in the joiner just after pthread_join; they must match. If they match but the contents are garbage, the storage's lifetime is the problem, not the plumbing.&local or a pointer to anything with automatic storage. The stack is gone at thread exit; this is a use-after-return, pure undefined behaviour.NULL and make the joiner test before dereferencing. Otherwise a failed worker becomes a crash in the joiner.void * is unchecked. The compiler cannot verify that the void * you decode matches what the worker encoded. Keep the encode/decode conventions symmetric and documented next to each other.future/result object.NULL-means-error convention shows up constantly.malloc'd struct with an explicit status field over overloading the pointer with both success and error meanings; document ownership in a comment at the point of return; and in higher-level code wrap the raw void * in a typed helper so callers never touch the cast. In long-running services, run under ASan/TSan in CI to catch dangling-return and race regressions before they ship.Write a worker that receives an int n (packed via intptr_t) and returns n * 2 packed back into the void *. In main, create it, join it, decode, and print the result.
Change the worker to allocate a result_t { int input; int doubled; } on the heap, fill both fields, and return the pointer. In the joiner, print both fields and free the struct. Confirm with Valgrind that there are no leaks.
Launch 5 workers at once, each doubling a different input from an array. Join them in a loop and print input -> doubled for each. Verify each worker returns its own heap block (no shared static).
Add failure handling: make the worker return NULL when its input is negative, and have the joiner detect NULL and print an error line instead of dereferencing. Test with a mix of positive and negative inputs.
Refactor task 3 to detach one of the threads instead of joining it (its result is not needed) while still joining the rest. Explain in a comment why the detached thread's return value must not be malloc'd storage you intended to free (nobody will free it), and choose an appropriate return convention for it.
void *; the joiner receives it through the void ** passed to pthread_join.intptr_t, use static/global storage (not reentrant), or return a malloc'd block the joiner frees.NULL as a failure sentinel and check it before dereferencing; always join or detach every thread.intptr_t, keep encode/decode symmetric, and lean on ASan/TSan/Valgrind to catch dangling returns and races.