Detailed Explanation of Thread Programming: From Basics to Embedded Practice

1. Introduction: Why Do We Need Threads?

In the previous two documents, we explored the concept of processes, address space, and inter-process communication mechanisms. While processes provide the foundation for multitasking programming, in certain scenarios, the process model can be too heavyweight. Imagine if an application needs to handle multiple concurrent tasks simultaneously; creating a separate process for each task would incur significant overhead.

Threads are the lightweight units of multitasking designed to solve this problem. Threads are sometimes referred to as “lightweight processes”; they share the address space of the process while being independently scheduled for execution, balancing concurrency performance and resource efficiency.

Why Choose Threads?

Consider an embedded smart home gateway that needs to handle:

  • Sensor data collection
  • Local user interface response
  • Network data transmission
  • Device control logic

If a separate process is created for each function, the system will face:

  • Significant memory overhead (each process has its own address space)
  • Complex inter-process communication
  • High context-switching costs

By using threads, these functions can be implemented as multiple threads within the same process, sharing memory space, simplifying communication, and enabling efficient switching.

2. Threads vs. Processes: Key Differences and Relationships

Essential Differences

Feature Process Thread
Address Space Each process has its own independent address space Shares the address space of the owning process
Resource Ownership Has independent system resources (file descriptors, signal handling, etc.) Shares process resources, owning only a small amount of private resources
Context Switching High overhead (requires switching address space, page tables, etc.) Low overhead (only switches registers and stack, etc.)
Communication Method Requires explicit IPC mechanisms (pipes, message queues, etc.) Communicates directly through shared memory
Independence High; a process crash usually does not affect other processes Low; a thread crash may cause the entire process to crash
Creation and Destruction Slower; requires allocation of address space and other resources Fast; only requires allocation of a small stack space
Scheduling Unit Early OS used processes as the scheduling unit Modern OS uses threads as the basic scheduling unit

Advantages of Threads

  1. Resource Efficiency: Threads are more lightweight than processes, allowing more threads to fit in the same memory
  2. Response Speed: Thread creation and switching are much faster than processes
  3. Convenient Communication: Direct communication through shared memory without complex IPC mechanisms
  4. Parallel Performance: Multithreading can fully utilize multi-core processors
  5. Programming Model: Suitable for applications that can be decomposed into multiple independent tasks

Challenges of Threads

  1. Thread Safety: Multiple threads sharing data can lead to data races and inconsistencies
  2. Complex Synchronization: Requires careful design of synchronization mechanisms to avoid deadlocks and race conditions
  3. Difficult Debugging: Concurrency bugs are hard to reproduce and locate
  4. Global State: Shared state makes program behavior more complex
  5. Resource Limits: Too many threads can lead to memory exhaustion and increased scheduling overhead

3. Thread Management: Creation and Control

3.1 Thread Lifecycle

A thread goes through several states from creation to termination:

  • New (New): The thread has just been created and has not yet started executing
  • Ready: The thread is ready to execute and waiting for CPU scheduling
  • Running: The thread is currently executing on the CPU
  • Blocked: The thread is paused waiting for an event (e.g., I/O, lock)
  • Terminated: The thread has completed execution or has been canceled

3.2 POSIX Threads (Pthreads) Programming Interface

Most Unix-like systems (including Linux) adopt the POSIX thread standard, providing a unified thread programming interface.

Thread Creation

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

// Thread function must return void* and accept void* parameter
void *thread_function(void *arg) {
    int thread_num = *(int *)arg;
    free(arg); // Free the passed argument memory
    
    printf("Hello from thread %d!\n", thread_num);
    return (void *)(thread_num * 10); // Thread return value
}

int main() {
    pthread_t thread_id;
    int *thread_arg = malloc(sizeof(int));
    *thread_arg = 1;
    
    // Create thread
    int ret = pthread_create(&thread_id, NULL, thread_function, thread_arg);
    if (ret != 0) {
        perror("pthread_create failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Main thread: Thread created with ID %lu\n", (unsigned long)thread_id);
    
    // Wait for thread to finish and get return value
    void *thread_result;
    ret = pthread_join(thread_id, &thread_result);
    if (ret != 0) {
        perror("pthread_join failed");
        exit(EXIT_FAILURE);
    }
    
    printf("Main thread: Thread returned %ld\n", (long)thread_result);
    return 0;
}

Thread Attributes

Thread attributes can be set using <span>pthread_attr_t</span>:

pthread_attr_t attr;
pthread_attr_init(&attr);

// Set thread detach state (detached threads do not need pthread_join)
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

// Set thread stack size
size_t stack_size = 1024 * 1024; // 1MB
pthread_attr_setstacksize(&attr, stack_size);

// Create thread using attributes
pthread_create(&thread_id, &attr, thread_function, NULL);
pthread_attr_destroy(&attr);

Thread Termination and Cancellation

Threads can terminate in the following ways:

  1. The thread function returns
  2. Calling <span>pthread_exit()</span>
  3. Being canceled by another thread (<span>pthread_cancel()</span><code><span>)</span>
  4. The process ends (all threads terminate)
// Normal thread exit
void *thread_function(void *arg) {
    // Perform task...
    pthread_exit((void *)0); // Explicit exit and return value
}

// Cancel thread
pthread_cancel(thread_id);

3.3 Thread Identification and Specific Data

Each thread has a unique identifier, which can be obtained using <span>pthread_self()</span><span>:</span>

pthread_t tid = pthread_self();

Thread-Specific Data (TSD) allows each thread to have a private instance of a variable:

// Create TSD key
pthread_key_t key;
pthread_key_create(&key, free); // free is the destructor function

// Set TSD in thread
void *data = malloc(sizeof(int));
pthread_setspecific(key, data);

// Get TSD in thread
void *data = pthread_getspecific(key);

4. Thread Synchronization: Avoiding Data Races

4.1 Data Races and Critical Sections

When multiple threads access shared data simultaneously, and at least one thread modifies the data, a data race may occur, leading to unpredictable results.

Critical sections are code segments that access shared resources and require mutual exclusion— only one thread can execute critical section code at a time.

Data Race Example

// Shared counter
int counter = 0;

// Thread function: increment counter
void *increment(void *arg) {
    for (int i = 0; i < 10000; i++) {
        counter++; // This is not an atomic operation!
    }
    return NULL;
}

// Create two threads to execute increment concurrently
// Expected result: 20000, actual result is usually less than 20000

<span>counter++</span> actually consists of three operations: read, increment, and write. Two threads may interleave these steps, leading to lost counts.

4.2 Mutex

Mutex is the most basic thread synchronization mechanism, ensuring mutual execution of critical sections.

Basic Mutex Operations

pthread_mutex_t mutex;

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

// Lock (blocking wait)
pthread_mutex_lock(&mutex);

// Critical section: access shared resource
shared_data++;

// Unlock
pthread_mutex_unlock(&mutex);

// Destroy mutex
pthread_mutex_destroy(&mutex);

Avoiding Deadlocks

Deadlock occurs when two or more threads are permanently blocked, waiting for resources held by each other.

The four necessary conditions for deadlock are:

  1. Mutual Exclusion: Resources can only be held by one thread at a time
  2. Hold and Wait: Threads hold resources while waiting for others
  3. No Preemption: Resources cannot be forcibly taken away
  4. Circular Wait: A cycle of resource waiting forms among threads

Practical methods to avoid deadlocks include:

  • Acquire locks in a fixed order
  • Limit wait time for locks (<span>pthread_mutex_trylock()</span>)
  • Minimize lock hold time
  • Use hierarchical locking mechanisms
// Acquire locks in a fixed order to avoid deadlock
void thread_function() {
    // Always acquire lower-numbered lock first, then higher-numbered lock
    pthread_mutex_lock(&mutex1); // Lower number
    pthread_mutex_lock(&mutex2); // Higher number
    
    // Operate on shared resources...
    
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
}

4.3 Condition Variables

Mutex solves the mutual exclusion problem of critical sections, but sometimes threads need to wait for a condition to be met before continuing, which requires condition variables.

Basic Condition Variable Operations

pthread_mutex_t mutex;
pthread_cond_t cond;
int shared_condition = 0;

// Initialize
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);

// Wait for condition to be met
pthread_mutex_lock(&mutex);
while (shared_condition == 0) { // Loop to check condition to avoid spurious wakeups
    pthread_cond_wait(&cond, &mutex); // Release lock and wait for signal
}
// Condition met, operate on shared resource...
pthread_mutex_unlock(&mutex);

// Notify that condition has been met
pthread_mutex_lock(&mutex);
shared_condition = 1;
pthread_cond_signal(&cond); // Wake one waiting thread
// pthread_cond_broadcast(&cond); // Wake all waiting threads
pthread_mutex_unlock(&mutex);

// Destroy
pthread_cond_destroy(&cond);
pthread_mutex_destroy(&mutex);

Producer-Consumer Problem

Condition variables are well-suited for solving the producer-consumer problem:

#define BUFFER_SIZE 10
int buffer[BUFFER_SIZE];
int in = 0, out = 0;

pthread_mutex_t mutex;
pthread_cond_t not_full, not_empty;

// Producer thread
void *producer(void *arg) {
    for (int i = 0; i < 20; i++) {
        pthread_mutex_lock(&mutex);
        
        // Wait for buffer not to be full
        while ((in + 1) % BUFFER_SIZE == out) {
            pthread_cond_wait(&not_full, &mutex);
        }
        
        // Produce data
        buffer[in] = i;
in = (in + 1) % BUFFER_SIZE;
        printf("Produced: %d\n", i);
        
        pthread_cond_signal(&not_empty); // Notify consumer
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

// Consumer thread
void *consumer(void *arg) {
    for (int i = 0; i < 20; i++) {
        pthread_mutex_lock(&mutex);
        
        // Wait for buffer not to be empty
        while (in == out) {
            pthread_cond_wait(&not_empty, &mutex);
        }
        
        // Consume data
        int data = buffer[out];
out = (out + 1) % BUFFER_SIZE;
        printf("Consumed: %d\n", data);
        
        pthread_cond_signal(&not_full); // Notify producer
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

4.4 Other Synchronization Mechanisms

Read-Write Locks

Read-Write Locks allow multiple readers to read simultaneously, but writers need exclusive access:

pthread_rwlock_t rwlock;
pthread_rwlock_init(&rwlock, NULL);

// Reader lock
pthread_rwlock_rdlock(&rwlock);
// Read shared data...
pthread_rwlock_unlock(&rwlock);

// Writer lock
pthread_rwlock_wrlock(&rwlock);
// Modify shared data...
pthread_rwlock_unlock(&rwlock);

pthread_rwlock_destroy(&rwlock);

Barriers

Barriers coordinate multiple threads, making them wait for each other to reach a certain execution point:

pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, 3); // 3 threads waiting

// Wait at barrier in thread
pthread_barrier_wait(&barrier); // Continue after all 3 threads reach

5. Thread Safety and Concurrent Programming Practices

5.1 Definition of Thread Safety

Thread Safety refers to functions or data structures that can be called/accessed by multiple threads simultaneously without causing data races or inconsistencies.

Strategies for achieving thread safety include:

  1. Stateless: Functions do not use any static/global data
  2. Mutual Access: Use synchronization mechanisms to protect shared data
  3. Thread-Specific Data: Each thread has a private copy of data
  4. Immutable Data: Data cannot be modified after creation, inherently thread-safe

5.2 Common Thread-Unsafe Situations

  1. Unprotected Shared Variables: Multiple threads read and write the same global variable
  2. State-Dependent Operations: Non-atomic “check-then-act” sequences
  3. Function Side Effects: Functions that modify external state
  4. Unsafe Library Functions: Such as <span>strtok()</span>, <span>asctime()</span>, etc.

5.3 Best Practices for Thread-Safe Programming

Minimize Shared Data

// Bad: Shared global variable
int global_counter;

// Good: Thread-local variable
__thread int thread_counter; // GCC extension

Maintain Moderate Lock Granularity

// Bad: Too large lock granularity
pthread_mutex_lock(&big_mutex);
read_data();       // Needs protection
process_data();    // Does not need protection but is locked
write_result();    // Needs protection
pthread_mutex_unlock(&big_mutex);

// Good: Fine lock granularity
pthread_mutex_lock(&read_mutex);
read_data();
pthread_mutex_unlock(&read_mutex);

process_data();    // No lock

pthread_mutex_lock(&write_mutex);
write_result();
pthread_mutex_unlock(&write_mutex);

Use Atomic Operations

For simple counters, atomic operations can replace mutexes:

// GCC atomic operation extension
_Atomic int counter = 0;

void increment() {
    __atomic_add_fetch(&counter, 1, __ATOMIC_SEQ_CST);
}

int get_value() {
    return __atomic_load_n(&counter, __ATOMIC_SEQ_CST);
}

Avoid Nested Locks and Lock Passing

Write Reentrant Functions

Reentrant Functions can be called simultaneously by multiple threads or safely called in interrupts:

// Reentrant function: does not use static variables, all state passed through parameters
int add(int a, int b) {
    return a + b;
}

// Non-reentrant function: uses static variables
int next_id() {
    static int id = 0;
    return id++; // Non-atomic operation and uses static variable
}

6. Threads in Embedded Systems: RTOS Tasks

In embedded systems, threads are often referred to as tasks; real-time operating systems (RTOS) provide task management capabilities.

6.1 FreeRTOS Task Model

FreeRTOS is a popular embedded RTOS, and its task model is essentially kernel-level threads:

  • Each task has its own stack space
  • Shares the entire process (system) address space
  • Preemptive scheduling by the kernel scheduler
  • Tasks synchronize through mechanisms provided by the RTOS

6.2 FreeRTOS Task Creation and Management

// FreeRTOS task function
void vTask1(void *pvParameters) {
    const char *pcTaskName = "Task 1";
    
    for (;;) { // Tasks are usually implemented as infinite loops
        // Task 1 work
        vPrintString(pcTaskName);
        
        // Delay 100ms to yield CPU
        vTaskDelay(pdMS_TO_TICKS(100));
    }
    
    // Tasks should not exit; if they need to exit
    vTaskDelete(NULL);
}

void vTask2(void *pvParameters) {
    const char *pcTaskName = "Task 2";
    
    for (;;) {
        // Task 2 work
        vPrintString(pcTaskName);
        
        // Delay 200ms
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

int main(void) {
    // Create tasks
    xTaskCreate(
        vTask1,          // Task function
        "Task 1",        // Task name (for debugging)
        1000,            // Stack size (words)
        NULL,            // Parameters passed to the task
        1,               // Task priority
        NULL             // Task handle (optional)
    );
    
    xTaskCreate(vTask2, "Task 2", 1000, NULL, 2, NULL);
    
    // Start the scheduler, begin task scheduling
    vTaskStartScheduler();
    
    // If all goes well, the program will not execute here
    for (;;);
    return 0;
}

6.3 RTOS Task Synchronization Mechanisms

FreeRTOS provides rich task synchronization mechanisms:

Semaphores and Mutexes

// Create mutex
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

// Get mutex in task
if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
    // Access shared resource...
    xSemaphoreGive(xMutex); // Release mutex
}

Task Notifications

FreeRTOS task notifications are an efficient way of inter-task communication, lighter than semaphores:

// Send task notification
xTaskNotifyGive(xTaskHandle); // Increment notification count

// Receive task notification
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // Wait for notification

Queues

Queues are used for data transfer between tasks:

// Create queue (can store 5 int type elements)
QueueHandle_t xQueue = xQueueCreate(5, sizeof(int));

// Send data to queue
int value = 10;
xQueueSend(xQueue, &value, portMAX_DELAY);

// Receive data from queue
int received_value;
xQueueReceive(xQueue, &received_value, portMAX_DELAY);

7. Choosing Between Threads and Processes

7.1 Selection Criteria

When designing a multitasking system, choose between threads or processes based on the following factors:

  1. Resource Overhead: Threads are lightweight, suitable for a large number of concurrent tasks
  2. Isolation Requirements: Choose processes when critical tasks need isolation
  3. Communication Frequency: Frequent communication favors threads, as shared memory is more efficient
  4. Reliability Requirements: High-reliability systems tend to use processes to isolate faults
  5. Programming Complexity: Thread synchronization is complex; simple tasks can use processes
  6. System Support: Some embedded systems may only support tasks (threads)

7.2 Typical Application Scenarios

Scenarios Suitable for Using Threads

  • Server Applications: Concurrently handle multiple client requests
  • GUI Applications: Separate UI response from background processing
  • Data Processing: Parallel processing of multiple data streams
  • Real-Time Systems: Embedded applications requiring quick responses

Scenarios Suitable for Using Processes

  • Modular Systems: Components developed and deployed independently
  • High Security Requirements: Isolate untrusted code
  • Resource-Intensive: Each task requires significant resources
  • Cross-Platform Compatibility: Different components require different runtime environments

7.3 Hybrid Usage Strategy

Many complex systems use a mix of processes and threads:

  • The main process manages multiple child processes (providing isolation)
  • Each child process internally uses multithreading (increasing concurrency)

For example:

  • Web browsers: each tab is a process, with multiple threads inside each process
  • Embedded gateways: network processing process, UI process, control process, each with multiple threads

8. Summary and Advanced Learning

Core Concepts of Thread Programming Review

  • Threads are lightweight execution units that share the process address space
  • Threads are more efficient than processes but have lower isolation
  • Thread synchronization is a key challenge, requiring mutexes, condition variables, and other mechanisms
  • Thread safety requires careful design to avoid data races
  • Tasks in embedded RTOS are essentially kernel-level threads

Integration of Multitasking Programming Knowledge System

This document, along with the previous two, forms a complete knowledge system for multitasking programming:

  1. Process Scheduling: The foundation of multitasking systems, the unit of resource allocation
  2. Process Communication and Memory: Inter-process cooperation and isolation mechanisms
  3. Thread Programming: Lightweight concurrency, shared memory cooperation

Thread programming is a core skill in modern embedded system development. Mastering thread concepts and synchronization mechanisms enables the design of efficient and reliable multitasking systems. Although concurrent programming has a certain complexity, by understanding the basic principles and accumulating practical experience, thread programming will become a powerful tool in your development toolbox.

Remember, threads are not difficult; they just require us to design programs with a new way of thinking—a concurrent rather than sequential mindset. As multi-core processors become more prevalent in embedded systems, the ability to program with threads will become increasingly important.

Leave a Comment