Skip to main content

Welcome to a Grasp lesson

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

Lesson 64

Making Non-Thread-Safe Functions Thread-Safe with TLS

Recommended resources

Hello! Welcome back to our module on Advanced Threading Concepts and Patterns.

In our last lesson, we mastered the mechanics of Thread-Local Storage (TLS), learning how to create per-thread data using pthread_key_create and managing its lifecycle with destructors. We saw how this provides a clean way to implement patterns like local accumulation without passing pointers through function stacks.

Today, we'll put that knowledge to a very practical and common use. This lesson fulfills the learning outcome: Make a non-thread-safe function safe to use in a multithreaded context by leveraging TLS. We'll diagnose why certain common C programming idioms fail in multithreaded code and then refactor them into fully thread-safe versions without changing their public API.

The Problem: Non-Reentrant Functions

A significant challenge in concurrent programming is dealing with functions that were not designed to be executed by multiple threads simultaneously. A function is non-reentrant if it cannot be safely interrupted midway and then re-entered (by the same or a different thread) before the first call has completed.

A primary cause of non-reentrancy is the use of shared state, particularly static variables. A common C idiom is to use a static buffer to build a return string, avoiding the need for the caller to manage memory. This is perfectly fine in a single-threaded program but creates a classic race condition in a multithreaded one.

Consider this function:

// NOTE: This function is NOT thread-safe!
char* get_time_string() {
    static char time_buffer[30]; // Shared static buffer
    time_t now = time(0);
    struct tm* t = localtime(&now);
    strftime(time_buffer, sizeof(time_buffer), "%Y-%m-%d %H:%M:%S", t);
    return time_buffer;
}

If Thread A calls get_time_string(), it starts writing the date into time_buffer. If the OS scheduler preempts Thread A and runs Thread B, which also calls get_time_string(), Thread B will begin overwriting the same time_buffer. When Thread A resumes, the data it was working on is now corrupted. The result is garbled output or a crash.

The challenge is to fix this without changing the function's signature. The caller expects to receive a char* and not have to worry about memory management. This is where TLS provides an elegant solution.


The Solution: Replacing Static State with Thread-Local State

The strategy is to replace the single, shared static buffer with a unique buffer for each thread. We can achieve this using the TLS pattern we learned in the previous lesson.

  1. Create a global pthread_key_t to identify our per-thread buffer.
  2. Use pthread_once to safely call pthread_key_create a single time, associating our key with a destructor that will free the buffer when the thread exits.
  3. Inside the function, call pthread_getspecific to try to get the current thread's buffer.
  4. If it returns NULL, it's this thread's first time. We malloc a new buffer and store it using pthread_setspecific.
  5. Now, with a guaranteed private buffer, we perform our work and return a pointer to it.

The QNX developer documentation provides a perfect, canonical example of this exact pattern.

pthread_key_create() - QNX

This resource provides a complete, runnable example that transforms a non-thread-safe function into a safe one using TLS. It's a model implementation of the pattern we're studying.

Go to the 'Examples' section and carefully study the C code. Pay close attention to: The global buffer_key. The buffer_key_destruct function, which frees the allocated memory. The logic inside the lookup() function, which implements the 'get, check-if-NULL, create-and-set' pattern.

Deconstructing the QNX Example

Let's break down the key components of the lookup() example you just read. It elegantly solves the problem of providing a per-thread string buffer.

  • Key and Destructor:

    pthread_key_t buffer_key;
    
    void buffer_key_destruct( void *value ) {
        free( value );
        pthread_setspecific( buffer_key, NULL ); // Good practice, not strictly required
    }
    

    A global key is declared. The destructor is simple: it casts the void* back to what it is (a pointer to memory allocated by malloc) and frees it.

  • Key Creation:

    int main( void ) {
        pthread_key_create( &buffer_key, &buffer_key_destruct );
        // ... create threads ...
    }
    

    In main, before any threads are created, the key is created and associated with its destructor. (For robustness, you'd typically wrap this in pthread_once if the function could be called before main sets things up, but this simple example is clear).

  • The Thread-Safe Function lookup():

    char *lookup( void ) {
        char *string;
    
        // 1. Get the thread's specific data
        string = (char *)pthread_getspecific( buffer_key );
    
        // 2. Check if it's been initialized for this thread
        if( string == NULL ) {
            // 3. If not, allocate and initialize it
            string = (char *) malloc( 32 );
            sprintf( string, "This is thread %d\n", pthread_self() );
            
            // 4. Store the new pointer for future calls by this thread
            pthread_setspecific( buffer_key, (void *)string );
        }
        return( string );
    }
    

    This function is the core of the lesson. It perfectly encapsulates the pattern. Every thread that calls lookup() gets a pointer to its own private buffer. The if (string == NULL) block only executes once per thread. All subsequent calls by the same thread will get the already-allocated buffer and skip the if block.

Test your understanding!

Imagine the lookup() function from the QNX example. A program creates two threads, Thread A and Thread B.

  1. Thread A calls lookup() for the first time. It gets a pointer, let's call it ptr_A.
  2. Thread B calls lookup() for the first time. It gets a pointer, let's call it ptr_B.

Is ptr_A the same as ptr_B? Why or why not? What happens inside the function for each call?

Show answer

No, ptr_A and ptr_B will be different. They point to separate memory blocks on the heap.

For Thread A's first call:

  1. pthread_getspecific returns NULL.
  2. The if block is entered.
  3. malloc allocates a new buffer, and ptr_A points to it.
  4. pthread_setspecific associates ptr_A with buffer_key for Thread A.

For Thread B's first call:

  1. pthread_getspecific returns NULL (because Thread A's setspecific call did not affect Thread B).
  2. The if block is entered.
  3. malloc allocates a different new buffer, and ptr_B points to it.
  4. pthread_setspecific associates ptr_B with buffer_key for Thread B.

Hands-On: Fixing a Non-Reentrant Function

Now it's your turn to apply this pattern. Below is a program with a non-reentrant function, get_error_string, which uses a static buffer. The main function creates several threads that call this function in a tight loop. When you run it, you will likely see garbled output because the threads are overwriting each other's results in the shared buffer.

Your task is to fix get_error_string using Thread-Local Storage, following the pattern we just analyzed. You should not change the function's signature or the logic in main.

Starter Code:

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

// --- TODO: Define your pthread_key_t and destructor function here ---
pthread_key_t error_string_key;

// Destructor to free the allocated buffer
void free_error_string(void* buffer) {
    free(buffer);
}

// A non-thread-safe function to convert an error code to a string.
// It uses a static buffer, which is the source of the problem.
char* get_error_string(int error_code) {
    // --- TODO: Replace the static buffer with TLS ---
    
    // Original non-thread-safe implementation:
    // static char buffer[100];
    // sprintf(buffer, "Error code: %d (thread: %lu)", error_code, (unsigned long)pthread_self());
    // return buffer;

    // Correct thread-safe implementation:
    char* buffer = pthread_getspecific(error_string_key);

    if (buffer == NULL) {
        buffer = malloc(100);
        if (buffer == NULL) {
            // In a real application, handle this more gracefully
            return "malloc failed!"; 
        }
        pthread_setspecific(error_string_key, buffer);
    }
    
    sprintf(buffer, "Error code: %d (thread: %lu)", error_code, (unsigned long)pthread_self());
    return buffer;
}

void* thread_func(void* arg) {
    int error_code = *(int*)arg;

    for (int i = 0; i < 5; ++i) {
        char* error_msg = get_error_string(error_code);
        printf("%s\n", error_msg);
        // A small sleep to increase the chance of context switching and showing the bug
        usleep(1000); 
    }
    return NULL;
}

int main() {
    const int num_threads = 5;
    pthread_t threads[num_threads];
    int thread_args[num_threads];

    // --- TODO: Create the pthread key ---
    if (pthread_key_create(&error_string_key, free_error_string) != 0) {
        perror("pthread_key_create failed");
        return 1;
    }

    for (int i = 0; i < num_threads; ++i) {
        thread_args[i] = 100 + i;
        pthread_create(&threads[i], NULL, thread_func, &thread_args[i]);
    }

    for (int i = 0; i < num_threads; ++i) {
        pthread_join(threads[i], NULL);
    }

    // --- TODO: Delete the pthread key when it's no longer needed ---
    pthread_key_delete(error_string_key);

    printf("All threads finished.\n");
    return 0;
}

Your Task:

  1. Observe the bug: Comment out the "Correct thread-safe implementation" and uncomment the "Original non-thread-safe implementation". Compile and run the code. You should see scrambled output where one thread's message appears incorrectly for another.
    gcc -Wall -Wextra -pthread your_file.c -o unsafe_errors
  2. Implement the fix: I have provided the solution in the TODO sections. Your task is to study it carefully. Make sure you understand how each piece works together:
    • The pthread_key_t variable.
    • The free_error_string destructor.
    • The pthread_key_create call in main.
    • The get/check/create/set logic inside get_error_string.
    • The pthread_key_delete at the end.
  3. Compile and run the fixed version:
    gcc -Wall -Wextra -pthread your_file.c -o safe_errors
    Verify that the output is now clean and correct, with each line showing the proper error code and matching thread ID.

Conclusion

You have now tackled one of the most common and important use cases for Thread-Local Storage: retrofitting non-reentrant functions to be thread-safe. This is a powerful technique because it allows you to fix concurrency bugs deep inside a system without breaking existing code that relies on a function's original signature.

Key Takeaways:

  • Functions that use static variables for buffers or state are often non-reentrant and unsafe for multithreaded use.
  • TLS provides an elegant solution by replacing a single shared static resource with a private, per-thread resource.
  • The pattern of using a global key, a destructor for cleanup, and the "get-or-create" logic within the function is the standard way to implement this fix.
  • This approach encapsulates the concurrency logic entirely within the function, leaving the calling code unchanged.

Preview of the Next Lesson:
We've focused on managing per-thread data. In the next lesson, we'll shift gears to building more complex, custom synchronization logic. We will implement a custom reader-writer lock with writer preference using only mutexes and condition variables. This will be a great exercise in combining our fundamental synchronization primitives to create a higher-level, more specialized lock.