Thread

C Programming Tutorial: Threads

Welcome to the Codes With Pankaj "Threads in C Programming" tutorial! This tutorial will guide you through the usage of threads in C for concurrent programming.

Table of Contents


1. Introduction to Threads

Threads in C provide a way to execute multiple tasks concurrently within a single process. They enable parallelism and can improve performance by utilizing multiple CPU cores.

2. Creating Threads

Threads are created using the pthread_create function from the POSIX Threads (pthread) library. Each thread executes a specified function concurrently with other threads.

C programming using the POSIX Threads (pthread) library :

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

#define NUM_THREADS 5

// Function to be executed by each thread
void *threadFunction(void *threadId) {
    long tid = (long)threadId;
    printf("Thread %ld: Hello, World!\n", tid);
    pthread_exit(NULL);
}

int main() {
    pthread_t threads[NUM_THREADS];
    int rc;
    long t;

    // Create multiple threads
    for (t = 0; t < NUM_THREADS; t++) {
        printf("Creating thread %ld\n", t);
        rc = pthread_create(&threads[t], NULL, threadFunction, (void *)t);
        if (rc) {
            printf("Error: Unable to create thread, %d\n", rc);
            exit(-1);
        }
    }

    // Wait for all threads to finish
    for (t = 0; t < NUM_THREADS; t++) {
        pthread_join(threads[t], NULL);
    }

    printf("All threads have completed execution.\n");

    pthread_exit(NULL);
}

In this example:

  • We define a function threadFunction that each thread will execute. This function takes a void * argument, which we cast to a long to represent the thread ID.

  • In the main function, we create an array of pthreads threads to hold the thread identifiers.

  • We then use a loop to create multiple threads, passing each thread a unique ID.

  • Inside the loop, we call pthread_create to create each thread, passing it the thread identifier, attributes (NULL for default), the function to execute (threadFunction), and the argument to pass to the function (the thread ID).

  • After creating all threads, we wait for each thread to finish using pthread_join.

  • Finally, we print a message indicating that all threads have completed execution.

If you're compiling on a Unix-like system (such as Linux), pthreads are typically part of the standard library and you shouldn't encounter this issue. However, if you're compiling on a system where pthreads are not available by default, you may need to install the pthread library or provide the necessary compiler flags to link against it.

Here's how you can install the pthread library on some common platforms:

Ubuntu/Debian:

You can install the pthread library by running the following command in the terminal:

sudo apt-get install libpthread-stubs0-dev

CentOS/RHEL:

You can install the pthread library by running the following command in the terminal:

sudo yum install glibc-devel

macOS:

On macOS, pthreads are part of the standard library, so you shouldn't encounter this issue unless your compiler environment is misconfigured.

Windows:

If you're using Windows, pthreads are not natively supported. Instead, you can use a library like pthread-win32 or mingw-w64 to provide pthread functionality on Windows.

Once you've installed the pthread library or resolved any compiler configuration issues, you should be able to compile your code without encountering the "pthread.h: No such file or directory" error.

3. Thread Synchronization

Thread synchronization is essential for coordinating the execution of multiple threads to prevent data races and ensure correct program behavior. Techniques like mutexes, locks, and condition variables are used for synchronization.

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

#define NUM_THREADS 5
#define NUM_ITERATIONS 1000000

int sharedCounter = 0;
pthread_mutex_t mutex;

// Function to be executed by each thread
void *threadFunction(void *threadId) {
    long tid = (long)threadId;
    int i;

    for (i = 0; i < NUM_ITERATIONS; i++) {
        // Lock the mutex before accessing sharedCounter
        pthread_mutex_lock(&mutex);
        
        // Increment sharedCounter
        sharedCounter++;
        
        // Unlock the mutex after accessing sharedCounter
        pthread_mutex_unlock(&mutex);
    }
    
    printf("Thread %ld: Finished. Shared counter: %d\n", tid, sharedCounter);
    pthread_exit(NULL);
}

int main() {
    pthread_t threads[NUM_THREADS];
    int rc;
    long t;

    // Initialize the mutex
    pthread_mutex_init(&mutex, NULL);

    // Create multiple threads
    for (t = 0; t < NUM_THREADS; t++) {
        printf("Creating thread %ld\n", t);
        rc = pthread_create(&threads[t], NULL, threadFunction, (void *)t);
        if (rc) {
            printf("Error: Unable to create thread, %d\n", rc);
            exit(-1);
        }
    }

    // Wait for all threads to finish
    for (t = 0; t < NUM_THREADS; t++) {
        pthread_join(threads[t], NULL);
    }

    // Destroy the mutex
    pthread_mutex_destroy(&mutex);

    printf("All threads have completed execution. Final shared counter value: %d\n", sharedCounter);

    pthread_exit(NULL);
}

4. Thread Termination

Threads can terminate either by returning from their entry function or by calling pthread_exit. Proper thread termination is crucial to avoid resource leaks and ensure clean program shutdown.

Thread termination in C can be achieved in several ways, including returning from the thread function, calling pthread_exit(), or using cancellation. Below, I'll explain each method and provide an example for thread termination using pthread_exit():

1. Returning from the Thread Function:

When a thread's entry function returns, the thread is automatically terminated. This method is suitable when the thread's task is complete, and there's no need for explicit termination.

Example:

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

void *threadFunction(void *arg) {
    // Thread task
    printf("Thread is terminating\n");
    return NULL; // Thread terminates when the function returns
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, threadFunction, NULL);
    pthread_join(tid, NULL);
    printf("Thread joined successfully\n");
    return 0;
}

2. Calling pthread_exit():

The pthread_exit() function is used to explicitly terminate a thread. It allows the thread to exit at any point within its execution.

Example:

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

void *threadFunction(void *arg) {
    // Thread task
    printf("Thread is terminating\n");
    pthread_exit(NULL); // Thread terminates explicitly
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, threadFunction, NULL);
    pthread_join(tid, NULL);
    printf("Thread joined successfully\n");
    return 0;
}

3. Using Thread Cancellation:

Thread cancellation allows one thread to terminate another thread. This method should be used with caution, as it can lead to resource leaks if not handled properly.

Example:

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

void *threadFunction(void *arg) {
    // Thread task
    printf("Thread is terminating\n");
    pthread_cancel(pthread_self()); // Terminate itself
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, threadFunction, NULL);
    pthread_join(tid, NULL);
    printf("Thread joined successfully\n");
    return 0;
}

5. Mutexes and Locks

Mutexes (short for "mutual exclusion") and locks are synchronization primitives used in multithreaded programming to protect critical sections of code from concurrent access by multiple threads. They ensure that only one thread can access a shared resource at any given time, preventing data corruption and ensuring consistency. Here's an explanation of mutexes and locks in more detail:

Mutexes:

  • A mutex is a synchronization object that allows threads to coordinate access to shared resources.

  • It provides two main operations: locking (acquiring the mutex) and unlocking (releasing the mutex).

  • When a thread locks a mutex, it gains exclusive access to the resource protected by the mutex. If another thread tries to lock the same mutex while it's already locked, it will block until the mutex is unlocked.

  • Mutexes are commonly used to protect critical sections of code or shared data structures from concurrent access.

Locks:

  • Locks are synonymous with mutexes and are often used interchangeably in multithreaded programming.

  • A lock typically refers to the act of acquiring and releasing a mutex to control access to a shared resource.

  • Locking a mutex is equivalent to acquiring a lock, and unlocking a mutex is equivalent to releasing a lock.

  • Locks are essential for ensuring thread safety and preventing race conditions in concurrent programs.

Example Using Mutexes:

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

#define NUM_THREADS 4

int sharedData = 0;
pthread_mutex_t mutex;

void *threadFunction(void *arg) {
    pthread_mutex_lock(&mutex); // Lock the mutex before accessing sharedData
    sharedData++; // Access the sharedData
    printf("Thread %ld: Shared data incremented to %d\n", (long)arg, sharedData);
    pthread_mutex_unlock(&mutex); // Unlock the mutex after accessing sharedData
    pthread_exit(NULL);
}

int main() {
    pthread_t threads[NUM_THREADS];
    pthread_mutex_init(&mutex, NULL); // Initialize the mutex

    // Create multiple threads
    for (long t = 0; t < NUM_THREADS; t++) {
        pthread_create(&threads[t], NULL, threadFunction, (void *)t);
    }

    // Wait for all threads to finish
    for (long t = 0; t < NUM_THREADS; t++) {
        pthread_join(threads[t], NULL);
    }

    pthread_mutex_destroy(&mutex); // Destroy the mutex
    printf("All threads have completed execution.\n");

    return 0;
}

In this example:

  • Multiple threads increment a shared variable sharedData in the threadFunction.

  • The pthread_mutex_lock() function is called before accessing sharedData to acquire the mutex lock, ensuring exclusive access to the variable.

  • After modifying sharedData, the pthread_mutex_unlock() function is called to release the mutex lock, allowing other threads to access sharedData safely.

  • This use of mutexes prevents data corruption and ensures the integrity of sharedData in a multithreaded environment.

Using mutexes and locks effectively is essential for writing robust and thread-safe concurrent programs. They help prevent race conditions and ensure that shared resources are accessed safely by multiple threads.

6. Condition Variables

Condition variables are synchronization primitives used in multithreaded programming to enable threads to wait for a particular condition to become true before proceeding with execution. They are typically used in conjunction with mutexes to implement complex thread synchronization patterns. Here's an explanation of condition variables in more detail:

Condition Variables:

  • A condition variable allows one or more threads to wait until a shared condition becomes true.

  • Threads that are waiting on a condition variable are blocked until another thread signals or broadcasts the condition to wake them up.

  • Condition variables are associated with a mutex to protect access to the shared state that the condition depends on. This mutex is locked and unlocked by threads that use the condition variable.

  • Condition variables are useful for implementing synchronization patterns like producer-consumer, reader-writer, and other complex thread interactions.

Functions for Condition Variables:

  • pthread_cond_init(cond, attr): Initializes a condition variable.

  • pthread_cond_wait(cond, mutex): Atomically unlocks the mutex and waits on the condition variable. The mutex is re-locked before returning to the calling thread.

  • pthread_cond_signal(cond): Wakes up one waiting thread that is blocked on the condition variable.

  • pthread_cond_broadcast(cond): Wakes up all waiting threads that are blocked on the condition variable.

  • pthread_cond_destroy(cond): Destroys a condition variable.

Example Using Condition Variables:

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

#define BUFFER_SIZE 10

int buffer[BUFFER_SIZE];
int count = 0;
pthread_mutex_t mutex;
pthread_cond_t condition;

void *producer(void *arg) {
    for (int i = 0; i < 20; i++) {
        pthread_mutex_lock(&mutex);
        while (count == BUFFER_SIZE) {
            pthread_cond_wait(&condition, &mutex);
        }
        buffer[count++] = i;
        printf("Produced: %d\n", i);
        pthread_cond_signal(&condition);
        pthread_mutex_unlock(&mutex);
    }
    pthread_exit(NULL);
}

void *consumer(void *arg) {
    for (int i = 0; i < 20; i++) {
        pthread_mutex_lock(&mutex);
        while (count == 0) {
            pthread_cond_wait(&condition, &mutex);
        }
        int data = buffer[--count];
        printf("Consumed: %d\n", data);
        pthread_cond_signal(&condition);
        pthread_mutex_unlock(&mutex);
    }
    pthread_exit(NULL);
}

int main() {
    pthread_t producerThread, consumerThread;
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&condition, NULL);

    pthread_create(&producerThread, NULL, producer, NULL);
    pthread_create(&consumerThread, NULL, consumer, NULL);

    pthread_join(producerThread, NULL);
    pthread_join(consumerThread, NULL);

    pthread_cond_destroy(&condition);
    pthread_mutex_destroy(&mutex);

    return 0;
}

In this example:

  • We have a shared buffer buffer that can hold a maximum of BUFFER_SIZE elements.

  • The producer thread produces integers from 0 to 19 and inserts them into the buffer.

  • The consumer thread consumes integers from the buffer.

  • We use a mutex mutex to protect access to the shared buffer and a condition variable condition to signal when the buffer is not full (for the producer) or not empty (for the consumer).

  • The producer waits if the buffer is full, and the consumer waits if the buffer is empty. They are signaled to proceed when there is space in the buffer (for the producer) or data in the buffer (for the consumer).

7. Thread Safety

Thread safety refers to the property of a piece of code or a data structure that ensures it can be safely accessed and manipulated by multiple threads concurrently without causing data corruption or unexpected behavior. In other words, thread-safe code guarantees correct operation even when accessed by multiple threads simultaneously.

Here are some key aspects of thread safety and techniques for achieving it:

1. Avoiding Race Conditions:

  • Race conditions occur when the outcome of a program depends on the relative timing of operations performed by multiple threads.

  • To avoid race conditions, shared resources (e.g., variables, data structures) must be protected by synchronization mechanisms like mutexes or locks.

2. Synchronization Mechanisms:

  • Mutexes (mutual exclusion) and locks are synchronization primitives used to protect critical sections of code from concurrent access by multiple threads.

  • Mutexes ensure that only one thread can access a shared resource at a time, preventing data corruption.

  • Locks are acquired before accessing shared resources and released afterward to ensure exclusive access.

3. Atomic Operations:

  • Atomic operations are indivisible operations that cannot be interrupted by other threads.

  • Atomicity guarantees that operations are either fully completed or not executed at all, preventing race conditions.

  • In C programming, atomic operations can be achieved using compiler-specific extensions or atomic library functions.

4. Reentrant Code:

  • Reentrant code can be safely interrupted and resumed even if multiple instances of the code are executed concurrently.

  • Thread-safe functions and data structures should be reentrant to avoid data corruption and ensure correct behavior in multithreaded environments.

5. Avoiding Deadlocks and Livelocks:

  • Deadlocks occur when two or more threads are waiting for each other to release resources, resulting in a deadlock state where no progress can be made.

  • Livelocks occur when threads continuously change their state in response to the actions of other threads, but no progress is made.

  • To avoid deadlocks and livelocks, use proper locking protocols, avoid circular dependencies, and implement timeout mechanisms.

6. Testing and Debugging:

  • Thorough testing and debugging are essential for identifying and fixing thread safety issues in multithreaded code.

  • Techniques like stress testing, code reviews, and static analysis tools can help uncover potential concurrency bugs.

8. Thread Pools

Thread pools are a common concurrency pattern used to manage a group of reusable threads for executing tasks asynchronously. They improve performance by reducing thread creation overhead.

Thread pools are a concurrency design pattern used in multithreaded programming to manage and reuse a group of threads for executing tasks asynchronously. Thread pools offer several benefits, including improved performance, resource management, and scalability. Here's an explanation of thread pools in more detail:

Key Components of a Thread Pool:

  1. Worker Threads: These are a fixed or dynamically sized group of threads managed by the thread pool. Worker threads are responsible for executing tasks submitted to the thread pool.

  2. Task Queue: This is a queue data structure used to store tasks awaiting execution by the worker threads. Tasks can be added to the queue by clients of the thread pool.

  3. Task Submission Interface: This interface provides methods for clients to submit tasks to the thread pool for execution. Tasks can be submitted asynchronously, and clients may receive notifications or results upon task completion.

Advantages of Thread Pools:

  • Improved Performance: Thread pools reduce the overhead of thread creation and destruction by reusing existing threads, leading to faster task execution.

  • Resource Management: Thread pools limit the number of concurrent threads, preventing resource exhaustion and excessive context switching.

  • Scalability: Thread pools can dynamically adjust the number of worker threads based on workload or system conditions, allowing for efficient resource utilization.

Implementation Considerations:

  • Thread Pool Size: Determining the optimal number of threads in a thread pool depends on factors like CPU cores, task complexity, and workload characteristics.

  • Task Granularity: Breaking tasks into smaller, independent units can improve load balancing and parallelism in the thread pool.

  • Task Prioritization: Support for task prioritization ensures that high-priority tasks are executed promptly, maintaining responsiveness and meeting service-level agreements.

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

#define NUM_THREADS 4
#define TASK_QUEUE_SIZE 100

// Task structure representing a task to be executed by a thread
typedef struct {
    // Define task properties here
    int taskId;
} Task;

// Global variables for the thread pool and task queue
Task taskQueue[TASK_QUEUE_SIZE];
int taskCount = 0;
int taskIndex = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;
int running = 1;

// Worker thread function
void *workerThread(void *arg) {
    long threadId = (long)arg;
    printf("Thread %ld started\n", threadId);
    
    while (running) {
        Task task;
        
        // Lock mutex before accessing the task queue
        pthread_mutex_lock(&mutex);
        
        // Wait for tasks if the task queue is empty
        while (taskCount == 0 && running) {
            pthread_cond_wait(&condition, &mutex);
        }
        
        // Check if the thread should terminate
        if (!running) {
            pthread_mutex_unlock(&mutex);
            break;
        }
        
        // Dequeue a task from the task queue
        task = taskQueue[taskIndex];
        taskIndex = (taskIndex + 1) % TASK_QUEUE_SIZE;
        taskCount--;
        
        // Unlock mutex before executing the task
        pthread_mutex_unlock(&mutex);
        
        // Execute the task
        printf("Thread %ld executing task %d\n", threadId, task.taskId);
        // Simulate task execution by sleeping for a short time
        usleep(100000); // 100 ms
    }
    
    printf("Thread %ld exiting\n", threadId);
    pthread_exit(NULL);
}

// Function to submit a task to the thread pool
void submitTask(Task task) {
    // Lock mutex before accessing the task queue
    pthread_mutex_lock(&mutex);
    
    // Enqueue the task into the task queue
    taskQueue[(taskIndex + taskCount) % TASK_QUEUE_SIZE] = task;
    taskCount++;
    
    // Signal one waiting thread that a task is available
    pthread_cond_signal(&condition);
    
    // Unlock mutex after adding the task to the queue
    pthread_mutex_unlock(&mutex);
}

// Function to initialize the thread pool
void initializeThreadPool() {
    pthread_t threads[NUM_THREADS];
    
    // Create worker threads
    for (long i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, workerThread, (void *)i);
    }
}

// Function to shutdown the thread pool
void shutdownThreadPool() {
    // Set the running flag to false to signal threads to terminate
    running = 0;
    
    // Signal all waiting threads to wake up and check the running flag
    pthread_cond_broadcast(&condition);
}

int main() {
    // Initialize the thread pool
    initializeThreadPool();
    
    // Submit tasks to the thread pool
    for (int i = 0; i < 20; i++) {
        Task task = { .taskId = i };
        submitTask(task);
    }
    
    // Wait for a while to allow tasks to be executed
    sleep(2);
    
    // Shutdown the thread pool
    shutdownThreadPool();
    
    // Wait for all threads to join
    pthread_cond_broadcast(&condition);
    
    return 0;
}

9. Best Practices

  • Minimize Shared State: Reduce the use of shared data to minimize the need for synchronization.

  • Avoid Deadlocks: Use proper locking protocols and avoid circular dependencies to prevent deadlocks.

  • Error Handling: Check return values of thread-related functions for errors and handle them appropriately.

  • Resource Management: Properly manage resources like memory and file descriptors to prevent leaks and ensure efficient resource usage.

10. Exercises

Try these exercises to practice threading in C:

  1. Exercise 1: Write a program to create multiple threads and perform a parallel task (e.g., calculating the sum of elements in an array).

  2. Exercise 2: Implement a program to demonstrate thread synchronization using mutexes and locks (e.g., accessing a shared counter).

  3. Exercise 3: Create a program to illustrate the use of condition variables for thread synchronization (e.g., producer-consumer problem).

  4. Exercise 4: Write a program to implement a simple thread pool for executing tasks asynchronously.

  5. Exercise 5: Implement a program to demonstrate thread safety in a multi-threaded environment (e.g., updating shared data structures).


We hope this tutorial has helped you understand threads in C programming. Practice with the exercises provided to reinforce your understanding. Happy coding!

For more tutorials, visit www.codeswithpankaj.com.

Last updated