Lesson 26-Concurrent Programming

Multithreading Basics

pthread_create and pthread_join

POSIX threads (pthread) are the most commonly used multithreading API in Unix/Linux systems. The following are the basic methods for creating and managing threads.

Thread Creation

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

void* thread_function(void* arg) {
    int thread_num = *(int*)arg;
    printf("Thread %d is running\n", thread_num);
    pthread_exit(NULL);  // Thread exits normally
}

int main() {
    pthread_t thread1, thread2;
    int num1 = 1, num2 = 2;
    
    // Create thread 1
    if (pthread_create(&thread1, NULL, thread_function, &num1) != 0) {
        perror("Failed to create thread1");
        exit(EXIT_FAILURE);
    }
    
    // Create thread 2
    if (pthread_create(&thread2, NULL, thread_function, &num2) != 0) {
        perror("Failed to create thread2");
        exit(EXIT_FAILURE);
    }
    
    // Wait for threads to finish
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    
    printf("Both threads have finished\n");
    return 0;
}

Notes

  1. Parameter Passing: Parameters passed to the thread function must be addressable, typically using pointers
  2. Thread ID: pthread_t type may have different implementations on different systems
  3. Thread Detachment: Use pthread_detach() to allow the thread to automatically release resources upon termination

Thread Detachment Example

pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL);
pthread_detach(thread);  // Automatically release resources after thread ends

Thread Synchronization Mechanisms

Mutex (Mutual Exclusion Lock)

Mutex is the most basic thread synchronization mechanism used to protect shared resources.

Basic Usage

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void* increment_counter(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mutex);  // Lock
        shared_counter++;
        pthread_mutex_unlock(&mutex);  // Unlock
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    
    pthread_create(&thread1, NULL, increment_counter, NULL);
    pthread_create(&thread2, NULL, increment_counter, NULL);
    
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    
    printf("Final counter value: %d\n", shared_counter);
    return 0;
}

Mutex Attributes

pthread_mutex_t mutex;
pthread_mutexattr_t attr;

pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);  // Recursive lock
pthread_mutex_init(&mutex, &attr);
pthread_mutexattr_destroy(&attr);

Common lock types:

  • PTHREAD_MUTEX_NORMAL: Normal lock
  • PTHREAD_MUTEX_ERRORCHECK: Error-checking lock
  • PTHREAD_MUTEX_RECURSIVE: Recursive lock
  • PTHREAD_MUTEX_DEFAULT: Default type (usually normal lock)

Semaphore

Semaphores are used to control the number of threads accessing shared resources.

Binary Semaphore (Similar to Mutex)

#include <semaphore.h>
#include <stdio.h>

sem_t sem;
int shared_resource = 0;

void* access_resource(void* arg) {
    sem_wait(&sem);  // P operation (acquire semaphore)
    shared_resource++;
    printf("Resource accessed by thread %ld, value: %d\n", 
           pthread_self(), shared_resource);
    sem_post(&sem);  // V operation (release semaphore)
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    sem_init(&sem, 0, 1);  // Initial value 1 (binary semaphore)
    
    pthread_create(&thread1, NULL, access_resource, NULL);
    pthread_create(&thread2, NULL, access_resource, NULL);
    
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    
    sem_destroy(&sem);
    return 0;
}

Counting Semaphore

sem_t sem;
#define MAX_RESOURCES 3

void* use_resource(void* arg) {
    sem_wait(&sem);  // Acquire resource
    printf("Thread %ld acquired resource\n", pthread_self());
    // Use resource...
    sleep(1);
    sem_post(&sem);  // Release resource
    return NULL;
}

int main() {
    pthread_t threads[5];
    sem_init(&sem, 0, MAX_RESOURCES);  // Allow up to 3 threads to access simultaneously
    
    for (int i = 0; i < 5; i++) {
        pthread_create(&threads[i], NULL, use_resource, NULL);
    }
    
    for (int i = 0; i < 5; i++) {
        pthread_join(threads[i], NULL);
    }
    
    sem_destroy(&sem);
    return 0;
}

Condition Variable

Condition variables are used for wait/notify mechanisms between threads.

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;

void* producer(void* arg) {
    pthread_mutex_lock(&mutex);
    printf("Producer: producing data...\n");
    sleep(2);  // Simulate production time
    ready = 1;
    printf("Producer: data ready, signaling consumer\n");
    pthread_cond_signal(&cond);  // Notify consumer
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (!ready) {  // Must use while to prevent spurious wakeups
        printf("Consumer: waiting for data...\n");
        pthread_cond_wait(&cond, &mutex);  // Release lock and wait
    }
    printf("Consumer: consuming data\n");
    ready = 0;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t prod_thread, cons_thread;
    
    pthread_create(&prod_thread, NULL, producer, NULL);
    pthread_create(&cons_thread, NULL, consumer, NULL);
    
    pthread_join(prod_thread, NULL);
    pthread_join(cons_thread, NULL);
    
    return 0;
}

Thread Safety

Reentrant Functions

Reentrant functions are those that can be safely called from signal handlers or by multiple threads simultaneously.

Reentrant Function Characteristics

  1. Does not use static or global variables
  2. Does not call non-reentrant functions
  3. Uses local variables or passes data via parameters

Example: Non-reentrant vs Reentrant Function

// Non-reentrant function
int non_reentrant_func() {
    static int counter = 0;  // Static variable
    return ++counter;
}

// Reentrant version
int reentrant_func(int* counter) {
    return ++(*counter);
}

Common Non-reentrant Functions

  • strtok() → Use strtok_r() instead
  • localtime() → Use localtime_r()
  • rand() → Use rand_r()

Thread-Local Storage (TLS)

Thread-local storage allows each thread to have its own independent instance of a variable.

Using thread_local (C11 Standard)

#include <threads.h>
#include <stdio.h>

thread_local int thread_counter = 0;

void* thread_func(void* arg) {
    for (int i = 0; i < 5; i++) {
        thread_counter++;
        printf("Thread %ld: counter = %d\n", thrd_current(), thread_counter);
    }
    return NULL;
}

POSIX Implementation

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

__thread int thread_counter = 0;  // GCC extension (non-standard)

void* thread_func(void* arg) {
    for (int i = 0; i < 5; i++) {
        thread_counter++;
        printf("Thread %lu: counter = %d\n", pthread_self(), thread_counter);
    }
    return NULL;
}

Standard POSIX Method (pthread_key_create)

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

pthread_key_t key;

void destructor(void* value) {
    printf("Destructor called for value %d\n", *(int*)value);
    free(value);
}

void* thread_func(void* arg) {
    int* counter = malloc(sizeof(int));
    *counter = 0;
    pthread_setspecific(key, counter);
    
    for (int i = 0; i < 5; i++) {
        (*counter)++;
        printf("Thread %lu: counter = %d\n", pthread_self(), *counter);
    }
    
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_key_create(&key, destructor);  // Create thread-local storage key
    
    pthread_create(&thread1, NULL, thread_func, NULL);
    pthread_create(&thread2, NULL, thread_func, NULL);
    
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    
    pthread_key_delete(key);  // Destroy key
    return 0;
}

Signal Handling

Signal Basic Concepts

Signals are a form of inter-process communication used to notify a process that an event has occurred.

Common Signals

SignalNameDescription
SIGINT2Interrupt (Ctrl+C)
SIGTERM15Termination request
SIGKILL9Force termination (cannot be caught)
SIGSEGV11Segmentation fault
SIGALRM14Alarm clock

signal Function

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void signal_handler(int signum) {
    printf("Received signal %d\n", signum);
}

int main() {
    signal(SIGINT, signal_handler);  // Catch Ctrl+C
    
    while (1) {
        printf("Working...\n");
        sleep(1);
    }
    
    return 0;
}

Signal Handling Notes

  1. Unreliable Signals: Traditional signals (1–31) may be lost
  2. Reentrancy: Signal handlers should only call async-signal-safe functions
  3. Async-Signal-Safe Functions: write, _exit, _Exit, abort, signal, etc.
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void signal_handler(int signum, siginfo_t* info, void* context) {
    printf("Received signal %d from PID %d\n", signum, info->si_pid);
}

int main() {
    struct sigaction sa;
    sa.sa_flags = SA_SIGINFO;  // Use extended signal handling
    sa.sa_sigaction = signal_handler;
    sigemptyset(&sa.sa_mask);
    
    sigaction(SIGINT, &sa, NULL);
    
    while (1) {
        printf("Working...\n");
        sleep(1);
    }
    
    return 0;
}

sigaction Advantages

  1. More control options
  2. Can obtain more signal information
  3. Supports more reliable signal handling

Process Management

fork Function

fork() creates a new process, returning twice: the parent process returns the child’s PID, the child process returns 0.

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();
    
    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // Child process
        printf("Child process (PID=%d, PPID=%d)\n", getpid(), getppid());
    } else {
        // Parent process
        printf("Parent process (PID=%d, Child PID=%d)\n", getpid(), pid);
    }
    
    return 0;
}

fork Characteristics

  1. Child process gets a copy of the parent’s memory
  2. File descriptors are shared (but offsets are independent)
  3. Memory uses Copy-On-Write

exec Function Family

The exec family of functions replaces the current process image.

#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // Child process executes new program
        execl("/bin/ls", "ls", "-l", NULL);
        // If execl succeeds, code below won't execute
        perror("execl failed");
        return 1;
    } else {
        wait(NULL);  // Wait for child to finish
    }
    
    return 0;
}

Common exec Functions

  1. execl: Variable argument list
  2. execv: Argument array
  3. execle: With environment variables
  4. execve: With environment variables and argument array
  5. execlp: Search executable in PATH
  6. execvp: Search executable in PATH (argument array)

wait/waitpid

Wait for child process to terminate.

#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        sleep(2);
        printf("Child process exiting\n");
        return 42;  // Child exit status
    } else {
        int status;
        pid_t wpid = wait(&status);
        
        if (WIFEXITED(status)) {
            printf("Child exited with status %d\n", WEXITSTATUS(status));
        }
        
        if (wpid == -1) {
            perror("wait failed");
        } else {
            printf("Waited for PID %d\n", wpid);
        }
    }
    
    return 0;
}

Advanced waitpid Usage

#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid1 = fork();
    if (pid1 == 0) {
        sleep(2);
        return 10;
    }
    
    pid_t pid2 = fork();
    if (pid2 == 0) {
        sleep(1);
        return 20;
    }
    
    // Wait for specific child
    int status;
    pid_t wpid = waitpid(pid2, &status, 0);  // Wait for pid2
    
    if (WIFEXITED(status)) {
        printf("Child %d exited with status %d\n", wpid, WEXITSTATUS(status));
    }
    
    // Non-blocking wait
    wpid = waitpid(pid1, &status, WNOHANG);
    if (wpid == 0) {
        printf("Child %d not exited yet\n", pid1);
    }
    
    // Wait for any child
    wpid = wait(NULL);
    printf("Waited for any child: %d\n", wpid);
    
    return 0;
}

Comprehensive Example: Multithreaded Producer-Consumer Model

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

#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int in = 0, out = 0;
int count = 0;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
sem_t empty, full;

void* producer(void* arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&empty);  // Wait for empty slot
        
        pthread_mutex_lock(&mutex);
        
        buffer[in] = i;
        printf("Produced: %d at position %d\n", i, in);
        in = (in + 1) % BUFFER_SIZE;
        count++;
        
        pthread_mutex_unlock(&mutex);
        
        sem_post(&full);  // Increase full slot
        
        sleep(1);  // Simulate production time
    }
    return NULL;
}

void* consumer(void* arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&full);  // Wait for full slot
        
        pthread_mutex_lock(&mutex);
        
        int item = buffer[out];
        printf("Consumed: %d from position %d\n", item, out);
        out = (out + 1) % BUFFER_SIZE;
        count--;
        
        pthread_mutex_unlock(&mutex);
        
        sem_post(&empty);  // Increase empty slot
        
        sleep(2);  // Simulate consumption time
    }
    return NULL;
}

int main() {
    pthread_t prod_thread, cons_thread;
    
    sem_init(&empty, 0, BUFFER_SIZE);  // Initially BUFFER_SIZE empty slots
    sem_init(&full, 0, 0);             // Initially no full slots
    
    pthread_create(&prod_thread, NULL, producer, NULL);
    pthread_create(&cons_thread, NULL, consumer, NULL);
    
    pthread_join(prod_thread, NULL);
    pthread_join(cons_thread, NULL);
    
    sem_destroy(&empty);
    sem_destroy(&full);
    
    return 0;
}

Summary

This article provides a detailed introduction to multithreading and process management techniques in C, including:

  1. Multithreading Basics: Usage of pthread_create and pthread_join
  2. Thread Synchronization: Implementation and application of mutex, semaphore, and condition variables
  3. Thread Safety: Reentrant functions and thread-local storage (TLS) implementation
  4. Signal Handling: Usage and considerations of signal and sigaction
  5. Process Management: System calls fork, exec, and wait and their use cases

These techniques form the foundation for building efficient and stable concurrent programs. In actual development, appropriate synchronization mechanisms should be chosen based on specific needs, and special attention should be paid to thread safety and signal handling. For complex concurrent scenarios, it is recommended to combine multiple synchronization mechanisms and conduct thorough testing to ensure program correctness.

Share your love