Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 2

Joining Threads with pthread_join

Recommended resources

Hello! Welcome back to our course on C programming with Pthreads.

In the last lesson, we mastered the essential pattern for passing data into a thread using a dynamically allocated struct. Now, we'll solve the other half of the puzzle: how to get results back out of a thread once its work is done.

Today's lesson addresses the learning outcome: Join threads to synchronize termination and retrieve return values using pthread_join. You'll learn how pthread_join serves a dual purpose of synchronization and data retrieval, and you'll master the memory management patterns required to do this safely and without leaks.

Why We Need to Join Threads

When main creates a thread, it spawns a new, independent path of execution. If main simply continues and then exits, the entire process, including all of its threads, may be terminated abruptly. To get a result from a thread, the creating thread must do two things:

  1. Wait for the child thread to finish its computation.
  2. Receive the result that the child thread provides.

The pthread_join function is the POSIX threads API for accomplishing both of these tasks.

Pthread Join Flow Diagram
This diagram illustrates the fundamental thread lifecycle. The 'Master Thread' (e.g., `main`) uses `pthread_create` to spawn workers. The master then uses `pthread_join` to wait for each worker to complete its task and terminate, effectively synchronizing execution and allowing for results to be collected.

The pthread_join Function

The function signature for pthread_join is:

int pthread_join(pthread_t thread, void **retval);

Let's break down its parameters:

  • pthread_t thread: The identifier of the thread you want to wait for.
  • void **retval: This is the mechanism for retrieving the return value. It is a pointer to a void*. The function needs a double pointer so it can modify the void* in your calling function to point to the data returned by the thread.

A thread's start routine is defined to return a void*. pthread_join captures this pointer and, via the retval argument, places it into a void* variable that you provide in the calling thread.


The Core Pattern: Heap Allocation and Pointer Passing

Just as it's unsafe to pass pointers to stack variables into a thread, it's a critical error for a thread to return a pointer to its own stack variables. When a thread's function returns, its stack is destroyed, and any pointer to it becomes a dangling pointer, leading to segmentation faults or corrupted data.

The solution is the inverse of the pattern from our last lesson: the child thread must allocate memory on the heap for its return value. The calling thread then becomes responsible for freeing this memory after it has retrieved the value.

This video from CodeVault explains this concept perfectly. It demonstrates the wrong way (returning a stack pointer) and then shows the correct, heap-based solution.

Get return value from a thread (pthread_join)

This video, 'Get return value from a thread (pthread_join)', walks through the entire process, including the critical mistake to avoid and the correct memory management pattern.

Please watch from the beginning to 07:28. Pay close attention to these key points: The explanation of pthread_join's second void** parameter (02:16). The demonstration of the segmentation fault from returning a pointer to a local variable (04:02). The correct implementation using malloc in the thread and free in main (05:23). The check to confirm that the pointer address is the same in both the child and main thread.

To summarize the crucial pattern shown in the video:

  1. In the child thread:
    • Allocate heap memory for the result (e.g., int* result = malloc(sizeof(int));).
    • Perform the calculation and store the value (*result = 42;).
    • Return the pointer (return result;).
  2. In the main thread:
    • Declare a pointer to hold the result (e.g., void* thread_return_value;).
    • Call pthread_join(thread_id, &thread_return_value);.
    • Cast the void* back to the correct type and use it (e.g., int final_result = *(int*)thread_return_value;).
    • Crucially, free the memory that was allocated by the child thread (free(thread_return_value);).

This division of responsibility (malloc in the child, free in the parent) is a fundamental pattern for returning heap-allocated data from threads.


Alternative: Augmenting the Input struct

The malloc/free pattern works well, but it splits memory management responsibility across functions, which can sometimes be complex to track in large projects. An elegant alternative leverages the struct we used for passing arguments. Instead of having the child thread allocate new memory, it can simply write its result back into a field in the same struct it received.

This keeps memory management unified: the main thread allocates the struct before pthread_create and frees it after pthread_join.

The following resource explains this approach clearly.

6.5. Thread Arguments and Return Values

The article 'Thread Arguments and Return Values' from our previous lesson also details several ways to handle return values. We will now focus on the first and most integrated approach.

Please read the text accompanying 'Code Listing 6.11' and 'Code Listing 6.12'. This demonstrates 'Approach 1' where the struct passed to the thread is augmented to include a field for the return value. Note how pthread_join is called with NULL for the second argument, as the result is retrieved through the args pointer.

This pattern is often cleaner when the input and output data for a thread are closely related. The main thread retains full control over the memory lifecycle.


Putting it into Practice: Parallel Sum

Let's write a program that uses threads to compute the sum of a large array of numbers. Each thread will sum a different chunk of the array and return its partial sum. The main thread will then join each thread, collect the partial sums, and compute the final total.

This example will use the malloc-in-thread pattern, as it's the most general-purpose approach.

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

#define NUM_THREADS 4
#define ARRAY_SIZE 1000

// Struct for passing data TO the thread
typedef struct {
    int* array;
    int start_index;
    int end_index;
} thread_args_t;

// Thread function to sum a portion of the array
void* sum_array_portion(void* arg) {
    thread_args_t* args = (thread_args_t*)arg;
    long long partial_sum = 0;

    for (int i = args->start_index; i < args->end_index; i++) {
        partial_sum += args->array[i];
    }
    
    // The input args are no longer needed, free them as per the last lesson's pattern
    free(args);

    // Now, prepare the return value
    // 1. Malloc memory on the heap for the result
    long long* return_value = malloc(sizeof(long long));
    if (return_value == NULL) {
        // In a real app, handle this error more gracefully
        pthread_exit(NULL); 
    }
    
    // 2. Store the result in the allocated memory
    *return_value = partial_sum;
    
    // 3. Return the pointer to the heap-allocated memory
    printf("Thread finished, partial sum: %lld\n", *return_value);
    return (void*)return_value;
}

int main() {
    pthread_t threads[NUM_THREADS];
    int array[ARRAY_SIZE];
    long long total_sum = 0;

    // Initialize array with some values
    for (int i = 0; i < ARRAY_SIZE; i++) {
        array[i] = i + 1;
    }

    int chunk_size = ARRAY_SIZE / NUM_THREADS;

    for (int i = 0; i < NUM_THREADS; i++) {
        // Malloc a struct for each thread's arguments
        thread_args_t* args = malloc(sizeof(thread_args_t));
        args->array = array;
        args->start_index = i * chunk_size;
        args->end_index = (i == NUM_THREADS - 1) ? ARRAY_SIZE : (i + 1) * chunk_size;

        pthread_create(&threads[i], NULL, sum_array_portion, args);
    }

    // Join threads and collect results
    for (int i = 0; i < NUM_THREADS; i++) {
        void* partial_sum_ptr;
        
        // 4. Join the thread and get the pointer to the result
        pthread_join(threads[i], &partial_sum_ptr);
        
        // 5. Cast the pointer and dereference it to get the value
        long long partial_sum = *(long long*)partial_sum_ptr;
        total_sum += partial_sum;
        
        // 6. Free the memory that the thread allocated
        free(partial_sum_ptr);
    }

    printf("Final total sum: %lld\n", total_sum);
    // Verification: sum of 1 to 1000 is n*(n+1)/2 = 1000*1001/2 = 500500
    printf("Expected sum: %lld\n", (long long)ARRAY_SIZE * (ARRAY_SIZE + 1) / 2);

    return 0;
}

Compile and run this code (gcc sum.c -o sum -pthread). You'll see each thread computing its partial sum, and the main thread correctly aggregating them into the final result, verifying the entire process works.

Test your understanding!

Modify the "Parallel Sum" code example above to use the "augmented struct" pattern instead of the malloc-in-thread pattern.

You will need to:

  1. Add a long long partial_sum; member to the thread_args_t struct.
  2. In the thread function, instead of mallocing new memory for the result, store the partial sum directly into args->partial_sum.
  3. The thread function can now return NULL because the result is passed back via the argument struct.
  4. In main, after pthread_join, you will retrieve the result from the args struct that was originally created for that thread. Hint: You will need to keep an array of the args pointers to access them after the join.
  5. Ensure you still free the args struct in main after you have retrieved the result.
Show answer

Here is a possible implementation using the augmented struct pattern.

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

#define NUM_THREADS 4
#define ARRAY_SIZE 1000

// 1. Struct is now for both arguments AND return value
typedef struct {
    int* array;
    int start_index;
    int end_index;
    long long partial_sum; // Field for the result
} thread_args_t;

void* sum_array_portion(void* arg) {
    thread_args_t* args = (thread_args_t*)arg;
    args->partial_sum = 0; // Initialize

    for (int i = args->start_index; i < args->end_index; i++) {
        args->partial_sum += args->array[i];
    }
    
    printf("Thread finished, partial sum: %lld\n", args->partial_sum);
    
    // 3. Return NULL, as the result is in the struct
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    thread_args_t* thread_args_array[NUM_THREADS]; // Keep track of arg structs
    int array[ARRAY_SIZE];
    long long total_sum = 0;

    for (int i = 0; i < ARRAY_SIZE; i++) {
        array[i] = i + 1;
    }

    int chunk_size = ARRAY_SIZE / NUM_THREADS;

    for (int i = 0; i < NUM_THREADS; i++) {
        // Malloc a struct for each thread
        thread_args_array[i] = malloc(sizeof(thread_args_t));
        thread_args_array[i]->array = array;
        thread_args_array[i]->start_index = i * chunk_size;
        thread_args_array[i]->end_index = (i == NUM_THREADS - 1) ? ARRAY_SIZE : (i + 1) * chunk_size;

        pthread_create(&threads[i], NULL, sum_array_portion, thread_args_array[i]);
    }

    // Join threads and collect results
    for (int i = 0; i < NUM_THREADS; i++) {
        // The second argument to join is NULL now
        pthread_join(threads[i], NULL);
        
        // 4. Retrieve result from the struct
        total_sum += thread_args_array[i]->partial_sum;
        
        // 5. Free the struct, which is now the main thread's responsibility
        free(thread_args_array[i]);
    }

    printf("Final total sum: %lld\n", total_sum);
    printf("Expected sum: %lld\n", (long long)ARRAY_SIZE * (ARRAY_SIZE + 1) / 2);

    return 0;
}

Conclusion

You have now learned the complete round-trip of data communication with a thread: passing data in and getting a result back out.

Key Takeaways:

  • pthread_join blocks the calling thread until the target thread terminates, providing essential synchronization.
  • The second argument, void **retval, allows pthread_join to deliver a void* returned by the thread back to the caller.
  • A thread must never return a pointer to its own local (stack) variables.
  • Pattern 1 (General): The child thread mallocs memory for its result, and the parent thread frees it after the join.
  • Pattern 2 (Integrated): The argument struct is augmented with fields for results. The parent thread mallocs and frees the struct, unifying memory management.

Preview of the Next Lesson:
Joining a thread is essential when you need its result or must ensure it has completed before proceeding. But what if you don't care? Sometimes, you want to launch a "fire and forget" task. In the next lesson, we will cover pthread_detach, which formally tells the system you will not be joining a thread, and discuss the scenarios where this is the appropriate design choice.