Linux System Programming · intermediate · ~10 min

Returning data from threads

- 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.

Overview

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.

Why it matters

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.

Core concepts

The return contract

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 one rule that governs everything: lifetime

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.

Three safe ways to return, one fatal way

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 — —
  • Packed integer. For a result that fits in a pointer (a count, an error code, a small enum), cast through 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;.
  • Static/global. 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.

Failure signalling and ownership

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?

x lives on the worker's stack, which is destroyed the instant the thread exits, so result dangles — 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, malloc a block, copy into it, return that, and free it in the joiner.

Syntax notes

#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) ....
  • Returning from the start routine and calling pthread_exit(p) are equivalent ways to produce the result p.
  • Cast integers through intptr_t (not directly to void *) to stay portable and warning-clean.
  • A joinable thread's resources are only released when it is joined (or detached). Every joinable thread must be joined exactly once.

Lesson

How a thread returns a value

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.

Safe ways to return data

  • Cast a small value into the pointer (for example, (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.
  • Return a static or global variable. This works, but it is not reentrant: every thread shares the same storage, so concurrent threads would overwrite each other.
  • Return a malloc'd struct. The pointer stays valid after the thread exits. The joiner is then responsible for calling free.

What never works

  • Returning the address of a stack (local) variable is always wrong. The thread's stack is gone the moment it exits, so the pointer dangles.

Code examples

#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;
}

Line by line

  • 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.
  • The loop of 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.

Common mistakes

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 */

Debugging tips

  • Valgrind (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.
  • AddressSanitizer (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.
  • ThreadSanitizer (cc -fsanitize=thread -g) flags the data race you get from sharing one static result across concurrent workers.
  • gdb: put a breakpoint after 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.
  • printf sanity check: print the pointer value in the worker just before 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.

Memory safety

  • Dangling stack pointers are the #1 hazard. Never return &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.
  • Ownership must be explicit. For heap results, decide up front that the joiner frees, and do it exactly once. Freeing twice (e.g. worker frees on an error path and joiner frees) is a double-free; freeing zero times is a leak.
  • Failure sentinels prevent NULL derefs. If a worker can fail (allocation, I/O), return NULL and make the joiner test before dereferencing. Otherwise a failed worker becomes a crash in the joiner.
  • Static/global results are not reentrant. Sharing one result object across concurrent threads is a data race (undefined behaviour, torn reads). Use per-thread heap results, or protect the shared object with a mutex and don't return a pointer into it.
  • Join or detach every thread. A joinable thread that is never joined leaks both its return value and kernel/library thread resources. Detach threads whose result you don't need.
  • Type punning through 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.

Real-world uses

  • Thread pools and task runners hand each task a slot to write its result, or collect results via join; the encode-int-vs-malloc decision here is exactly what pool implementations formalize into a future/result object.
  • Parallel decomposition (summing an array, hashing chunks of a file, rendering tiles of an image): each worker returns its partial result and the main thread combines them — the multi-worker pattern in this lesson.
  • Wrappers around blocking calls run a syscall on a helper thread and return its result or error code so the caller can time out. The NULL-means-error convention shows up constantly.
  • Best practice: prefer returning a small 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.

Practice tasks

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.

Summary

  • A thread start routine returns void *; the joiner receives it through the void ** passed to pthread_join.
  • The single governing rule: the returned pointer must stay valid after the thread exits — never return the address of a stack local.
  • Three safe channels: pack a small int through intptr_t, use static/global storage (not reentrant), or return a malloc'd block the joiner frees.
  • Make ownership explicit: for heap results the joiner frees, exactly once — double-free and leak are the two failure modes.
  • Use NULL as a failure sentinel and check it before dereferencing; always join or detach every thread.
  • Cast integers through intptr_t, keep encode/decode symmetric, and lean on ASan/TSan/Valgrind to catch dangling returns and races.

Practice with these exercises