Chapter 65: Intro to Threads: Processes vs. Threads
Chapter Objectives
Upon completing this chapter, you will be able to:
- Understand the fundamental differences between a process and a thread within the Linux operating system.
- Explain the concepts of resource sharing in multithreaded applications and identify the specific resources that threads share.
- Analyze the benefits and drawbacks of using a multithreaded architecture versus a multi-process architecture for embedded applications.
- Implement basic multithreaded C applications on a Raspberry Pi 5 using the POSIX Threads (pthreads) library.
- Identify potential concurrency issues such as race conditions and understand the need for synchronization mechanisms.
- Debug common problems in simple multithreaded programs, such as failing to join threads and incorrect data handling.
Introduction
In the landscape of modern embedded systems, the demand for performance, responsiveness, and efficiency has never been greater. A single, sequential flow of execution is no longer sufficient for devices that must manage network communications, user interfaces, and real-time sensor data simultaneously. This requirement for parallelism brings us to one of the most fundamental concepts in system programming: concurrency. The operating system provides two primary models for achieving concurrency: processes and threads.
This chapter delves into the core of concurrent programming by comparing these two models. We will begin by revisiting the concept of a process, the isolated, self-contained environment that the Linux kernel uses to run a program. We will then introduce the thread, a lighter-weight path of execution that exists within a process. Understanding the distinction between them is not merely an academic exercise; it is critical for making informed architectural decisions that profoundly impact an embedded system’s performance, resource consumption, and complexity. Choosing between a multi-process or multithreaded design can mean the difference between a responsive, efficient device and one that is sluggish and unreliable. Throughout this chapter, we will explore the intricate trade-offs involved, focusing on how resources are managed and the challenges that arise when multiple threads of execution share the same memory space.
Technical Background
The Process: An Isolated Universe
To truly appreciate the concept of a thread, we must first have a solid understanding of what a process is. In Linux, a process is the fundamental unit of execution and resource allocation. When you run a program, the kernel creates a process, which is an instance of that program in execution. The defining characteristic of a process is its isolation. The kernel goes to great lengths to create a protective bubble around each process, ensuring that one misbehaving process cannot easily interfere with another.
This isolation is achieved by giving each process its own virtual address space. This address space is a complete, private map of memory that includes several distinct segments. It contains the text segment, which holds the executable machine code of the program. It has the data segment for initialized global and static variables and the BSS segment for uninitialized ones. Crucially, it also includes a private heap for dynamic memory allocation (managed by malloc()
and free()
) and a private stack for local variables, function parameters, and return addresses. In addition to its own memory, each process is allocated its own set of resources by the kernel, including a unique Process ID (PID), its own set of file descriptors (for accessing files and devices), and its own security context (user and group IDs).
This model is incredibly robust and secure. If one process crashes due to a memory fault, like a segmentation fault, it does not bring down the entire system. Only that specific process is terminated by the kernel. However, this robustness comes at a significant cost. Creating a new process using the fork()
system call is a “heavyweight” operation. The kernel must duplicate the parent process’s entire address space, which can be time-consuming and memory-intensive.
Furthermore, communication between processes, known as Inter-Process Communication (IPC), is complex and relatively slow. Because their memory spaces are isolated, processes cannot simply share data by writing to a global variable. They must rely on kernel-mediated mechanisms like pipes, sockets, shared memory segments, or message queues. Each of these IPC mechanisms involves overhead as data must be copied from the address space of one process into a kernel buffer, and then copied again from the kernel buffer into the address space of the receiving process. This transition between user space and kernel space, known as a context switch, is computationally expensive.
While necessary, the overhead of process creation and communication can become a bottleneck in high-performance embedded systems that require frequent and rapid interaction between concurrent tasks.
The Thread: A Lighter Path of Execution
The limitations of the multi-process model led to the development of a more lightweight alternative: the thread. A thread, often called a lightweight process, is a basic unit of CPU utilization. It represents a single, sequential flow of control within a process. The key distinction is that a single process can contain multiple threads, all executing concurrently and sharing many of the process’s resources.
When a program starts, it begins with a single, primary thread. This thread can then create other threads. All threads within the same process share the same virtual address space. This means they share the same text segment (the code), the same data and BSS segments (global variables), and the same heap (dynamically allocated memory). They also share the same set of file descriptors, signal handlers, and other kernel resources.
What do threads not share? Each thread gets its own stack. This is essential because the stack is used to store local variables and manage function calls. If threads shared a stack, they would immediately overwrite each other’s local data, leading to chaos. Each thread also has its own set of CPU registers and a program counter, which keeps track of its current instruction. This allows each thread to execute independently.
This sharing model is the source of both the power and the peril of multithreading. The benefits are immediately apparent. Since threads share the same address space, creating a new thread is much faster and requires fewer resources than creating a new process. There is no need to duplicate the entire memory map.
Communication between threads is also dramatically simpler and faster. Threads can communicate directly through shared global variables and data structures on the heap. There is no need for kernel-mediated IPC; the data is already accessible to all threads in the process. This efficiency makes threads an ideal choice for tasks that need to share and manipulate common data sets, such as a web server handling multiple client requests or an embedded system processing data from multiple sensors.
The Dark Side of Sharing: Concurrency Problems
The greatest strength of multithreading—the shared address space—is also its greatest weakness. While it enables effortless communication, it also opens the door to a class of difficult and subtle bugs that are almost nonexistent in multi-process applications. These bugs arise from the challenge of coordinating access to shared resources.
The most common problem is a race condition. A race condition occurs when the outcome of a computation depends on the non-deterministic and unpredictable timing of operations from two or more threads. Imagine two threads trying to increment the same global counter variable. The operation counter++
might seem like a single, indivisible (or atomic) instruction. However, at the machine code level, it is typically three separate operations:
- Read the value of
counter
from memory into a CPU register. - Increment the value in the register.
- Write the new value from the register back to memory.
Now, consider what happens if two threads attempt this operation concurrently. Thread A reads the value of counter
(let’s say it’s 5). Before it can write the new value back, the kernel scheduler preempts it and lets Thread B run. Thread B also reads the value of counter
, which is still 5. Thread B increments it to 6 and writes 6 back to memory. Then, Thread A resumes execution. It has already read the value 5 and incremented it to 6 in its private register, so it now writes 6 back to memory. The counter has been incremented twice, but its final value is 6, not the expected 7. This is a classic race condition.
To prevent race conditions, we must enforce mutual exclusion. This means ensuring that only one thread can access a shared resource at a time. The section of code that accesses the shared resource is called a critical section. We protect critical sections using synchronization primitives, the most common of which is a mutex (short for mutual exclusion).
graph TD subgraph Thread Logic A[Thread approaches Critical Section] --> B{Try to acquire Mutex Lock}; style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff B --> C{Is Mutex locked?}; style C fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff C -- "No, it's available" --> D[Acquire Lock]; style D fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff D --> E["<b>Enter Critical Section</b><br><i>(e.g., counter++)</i><br>Safely access shared resource"]; style E fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff E --> F[Release Mutex Lock]; style F fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff F --> G[Continue execution]; style G fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff C -- "Yes, by another thread" --> H["<b>Wait (Block)</b><br>Thread is paused by the scheduler"]; style H fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff H --> B; end classDef default fill:#f8fafc,stroke:#374151,stroke-width:1px,color:#1f2937;
A mutex is like a lock. Before entering a critical section, a thread must acquire the lock. If another thread already holds the lock, the second thread will be forced to wait until the lock is released. Once the first thread is finished with the critical section, it releases the lock, allowing a waiting thread to proceed.
While mutexes solve the race condition problem, they introduce their own set of challenges, most notably deadlock. A deadlock is a situation where two or more threads are blocked forever, each waiting for a resource that is held by another thread in the group. The classic example is when Thread A locks Mutex 1 and then tries to lock Mutex 2, while Thread B has already locked Mutex 2 and is now trying to lock Mutex 1.
graph LR subgraph Legend direction LR Held["Resource Held"] -- "Solid Line" --> X( ); Wait["Resource Wanted"] -.->|Dashed Line| Y( ); end subgraph Deadlock Scenario ThreadA["<b>Thread A</b>"]:::thread; ThreadB["<b>Thread B</b>"]:::thread; Mutex1["Mutex 1"]:::mutex; Mutex2["Mutex 2"]:::mutex; ThreadA -- "1- Acquires Lock" --> Mutex1; Mutex1 -- "<b>HELD BY</b>" --> ThreadA; ThreadB -- "2- Acquires Lock" --> Mutex2; Mutex2 -- "<b>HELD BY</b>" --> ThreadB; ThreadA -.->|"3- Requests & WAITS for"| Mutex2; ThreadB -.->|"4- Requests & WAITS for"| Mutex1; end linkStyle 0,1 stroke-width:2px,stroke:green; linkStyle 2,3 stroke-width:2px,stroke:green; linkStyle 4 stroke-width:2px,stroke-dasharray: 5 5,stroke:red; linkStyle 5 stroke-width:2px,stroke-dasharray: 5 5,stroke:red; classDef thread fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff; classDef mutex fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff; classDef default fill:#f8fafc,stroke:#374151,stroke-width:1px,color:#1f2937;
Neither thread can proceed, and the program grinds to a halt. Avoiding deadlock requires careful design, such as always acquiring locks in the same order.
The complexity of managing shared state, avoiding race conditions, and preventing deadlocks is the primary drawback of multithreading. It requires a level of discipline and careful design that is not as critical in multi-process applications, where the kernel’s isolation provides a safety net.
Practical Examples
This section provides hands-on examples to demonstrate the concepts of processes and threads on the Raspberry Pi 5. You will need a Raspberry Pi 5 running Raspberry Pi OS (or a similar Debian-based Linux distribution) and access to its command line.
Example 1: Creating a Process with fork()
This example illustrates the “heavyweight” nature of process creation and the isolation between parent and child processes. The fork()
system call creates a new process by duplicating the calling process.
graph TD subgraph Process Execution Flow A[Start Program] --> B{"Call fork()"}; style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff B --> C{"pid = fork()"}; style C fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff C --> D{pid < 0?}; style D fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff D -- "Yes" --> E[Error: Fork Failed]; style E fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff E --> F_End[End Program]; style F_End fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff D -- "No" --> G{pid == 0?}; style G fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff G -- "Yes" --> H["<b>Child Process</b><br>Execute child's code<br><i>(Has new PID, shares parent's code but has own memory space)</i>"]; style H fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff G -- "No" --> I["<b>Parent Process</b><br>Execute parent's code<br><i>(Continues execution, pid variable holds child's PID)</i>"]; style I fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff H --> J{"Call exit()"}; style J fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff J --> K[Child Terminates]; style K fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff I --> L{"Call wait()"}; style L fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937 L -- "Waits for child to terminate" --> M[Parent Resumes]; style M fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff M --> N_End[End Program]; style N_End fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff end classDef default fill:#f8fafc,stroke:#374151,stroke-width:1px,color:#1f2937;
Code Snippet: process_example.c
The following C program uses fork()
to create a child process. Both the parent and child then modify a variable to show that their memory spaces are separate.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int shared_variable = 100;
pid_t pid;
printf("--- Process Creation Example ---\n");
printf("Parent Process (PID: %d): Initial value of shared_variable = %d\n", getpid(), shared_variable);
// Fork the process
pid = fork();
if (pid < 0) {
// Error occurred
fprintf(stderr, "Fork Failed\n");
return 1;
} else if (pid == 0) {
// This is the child process
printf("Child Process (PID: %d, Parent PID: %d): Executing...\n", getpid(), getppid());
sleep(1); // Give the parent time to run first
printf("Child Process: Before modification, shared_variable = %d\n", shared_variable);
shared_variable = 200; // Modify the variable
printf("Child Process: After modification, shared_variable = %d\n", shared_variable);
printf("Child Process: Exiting.\n");
exit(0);
} else {
// This is the parent process
printf("Parent Process (PID: %d): Created a child with PID: %d\n", getpid(), pid);
// The parent will wait for the child to complete
wait(NULL);
printf("Parent Process: Child has terminated.\n");
printf("Parent Process: The value of shared_variable is still %d\n", shared_variable);
printf("Parent Process: Exiting.\n");
}
return 0;
}
Build and Execution Steps
- Save the Code: Save the code above into a file named
process_example.c
on your Raspberry Pi. - Compile the Program: Open a terminal and compile the code using
gcc
.gcc process_example.c -o process_example
- Run the Executable:
./process_example
Expected Output and Explanation
The output will look similar to this (PIDs will vary):
--- Process Creation Example ---
Parent Process (PID: 2150): Initial value of shared_variable = 100
Parent Process (PID: 2150): Created a child with PID: 2151
Child Process (PID: 2151, Parent PID: 2150): Executing...
Child Process: Before modification, shared_variable = 100
Child Process: After modification, shared_variable = 200
Child Process: Exiting.
Parent Process: Child has terminated.
Parent Process: The value of shared_variable is still 100
Parent Process: Exiting.
This output clearly demonstrates process isolation. When the child process modified shared_variable
to 200, it was modifying its own private copy. The parent’s copy of the variable remained unchanged at 100. This confirms that fork()
created a separate address space for the child.
Example 2: Creating Threads with pthreads
Now, let’s contrast the previous example with a multithreaded approach using the POSIX Threads (pthreads) library, which is the standard for threading in Linux.
Tip: The
pthreads
library is not part of the standard C library, so we must explicitly link against it during compilation using the-pthread
flag.
Code Snippet: thread_example.c
sequenceDiagram participant MainThread as Main Thread participant NewThread as New Thread MainThread->>+MainThread: Start execution MainThread->>MainThread: Prepare data, initialize variables Note right of MainThread: TID: 1401... (Example) MainThread->>NewThread: pthread_create(thread_function) activate NewThread MainThread-->>NewThread: Execution begins in parallel NewThread->>NewThread: Run thread_function() NewThread->>NewThread: Access/modify shared data MainThread->>MainThread: Continues its own execution rect rgb(253, 230, 138) Note over MainThread,NewThread: Both threads can run concurrently.<br>The OS scheduler decides who runs when. end MainThread->>MainThread: Reaches pthread_join(NewThread) Note over MainThread: Main thread blocks and waits... NewThread->>NewThread: Finishes execution NewThread-->>MainThread: Thread terminates, returns value (or NULL) deactivate NewThread MainThread->>Main-Thread: pthread_join() returns Note over MainThread: ...resumes execution after New Thread terminates. MainThread->>+MainThread: Process shared data results MainThread->>MainThread: Program continues until exit deactivate MainThread
This program creates a single new thread that modifies a global variable. The main thread will wait for the new thread to finish and then print the final value, demonstrating shared memory.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
// This global variable is shared by all threads in the process
int shared_variable = 100;
// This is the function that the new thread will execute
void* thread_function(void* arg) {
printf("New Thread (TID: %lu): Executing...\n", pthread_self());
sleep(1);
printf("New Thread: Before modification, shared_variable = %d\n", shared_variable);
shared_variable = 500; // Modify the shared variable
printf("New Thread: After modification, shared_variable = %d\n", shared_variable);
printf("New Thread: Exiting.\n");
return NULL;
}
int main() {
pthread_t thread_id; // Variable to store the thread identifier
printf("--- Thread Creation Example ---\n");
printf("Main Thread (TID: %lu): Initial value of shared_variable = %d\n", pthread_self(), shared_variable);
// Create a new thread that will run 'thread_function'
// The first argument is a pointer to the thread_id
// The second argument specifies thread attributes (NULL for default)
// The third argument is the function the thread will execute
// The fourth argument is the argument to pass to the thread function (NULL for none)
if (pthread_create(&thread_id, NULL, thread_function, NULL) != 0) {
fprintf(stderr, "Error creating thread\n");
return 1;
}
printf("Main Thread: Created a new thread with TID: %lu\n", thread_id);
// Wait for the newly created thread to finish its execution
// This is crucial! Otherwise, main might exit before the thread runs.
if (pthread_join(thread_id, NULL) != 0) {
fprintf(stderr, "Error joining thread\n");
return 2;
}
printf("Main Thread: New thread has terminated.\n");
printf("Main Thread: The final value of shared_variable is now %d\n", shared_variable);
printf("Main Thread: Exiting.\n");
return 0;
}
Build and Execution Steps
- Save the Code: Save the code into a file named
thread_example.c
. - Compile the Program: Use
gcc
and remember to link the pthread library.gcc thread_example.c -o thread_example -pthread
- Run the Executable:
./thread_example
Expected Output and Explanation
The output will be similar to this (TIDs will vary):
--- Thread Creation Example ---
Main Thread (TID: 1401...): Initial value of shared_variable = 100
Main Thread: Created a new thread with TID: 1401...
New Thread (TID: 1401...): Executing...
New Thread: Before modification, shared_variable = 100
New Thread: After modification, shared_variable = 500
New Thread: Exiting.
Main Thread: New thread has terminated.
Main Thread: The final value of shared_variable is now 500
Main Thread: Exiting.
This result is fundamentally different from the process example. When the new thread modified shared_variable
, it was operating on the same piece of memory that the main thread could see. After the new thread finished, the main thread printed the updated value of 500, confirming that they truly share the data segment. This simple, direct sharing is what makes threads so efficient for cooperative tasks.
Example 3: Demonstrating a Race Condition
The previous example was safe because only one thread modified the data. Let’s create a scenario that exposes the danger of a race condition. We will create two threads that both try to increment a shared counter many times.
Code Snippet: race_condition_example.c
sequenceDiagram participant ThreadA as Thread A participant ThreadB as Thread B participant Memory as "Shared Memory<br>(counter = 5)" rect rgb(254, 226, 226) Note over ThreadA, Memory: The "counter++" operation is NOT atomic! end ThreadA->>Memory: 1. Read value of 'counter' (5) activate ThreadA Note left of ThreadA: Register = 5 ThreadA->>ThreadA: 2. Increment its private register (value becomes 6) Note left of ThreadA: Register = 6 rect rgb(253, 230, 138) Note over ThreadA, ThreadB: CONTEXT SWITCH!<br>Scheduler pauses Thread A before it can write. end ThreadB->>Memory: 3. Read value of 'counter' (still 5) activate ThreadB Note right of ThreadB: Register = 5 ThreadB->>ThreadB: 4. Increment its private register (value becomes 6) Note right of ThreadB: Register = 6 ThreadB->>Memory: 5. Write register value back to memory deactivate ThreadB Note over Memory: counter is now 6 rect rgb(253, 230, 138) Note over ThreadA, ThreadB: CONTEXT SWITCH!<br>Scheduler resumes Thread A. end ThreadA->>Memory: 6. Write its register value (6) back to memory deactivate ThreadA Note over Memory: counter is overwritten with 6 rect rgb(239, 68, 68) Note over ThreadA, Memory: FINAL VALUE: 6<br>EXPECTED VALUE: 7<br>An increment was lost! end
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define NUM_INCREMENTS 1000000
// Shared global counter
long long counter = 0;
// Thread function to increment the counter
void* incrementer(void* arg) {
for (int i = 0; i < NUM_INCREMENTS; i++) {
counter++; // This is the critical section!
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
printf("--- Race Condition Demonstration ---\n");
printf("Expected final counter value: %d\n", NUM_INCREMENTS * 2);
// Create two threads, both running the incrementer function
pthread_create(&thread1, NULL, incrementer, NULL);
pthread_create(&thread2, NULL, incrementer, NULL);
// Wait for both threads to complete
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Actual final counter value: %lld\n", counter);
if (counter < (long long)NUM_INCREMENTS * 2) {
printf("\nRace condition detected! The final value is less than expected.\n");
} else {
printf("\nNo race condition detected this time (you were lucky!).\n");
}
return 0;
}
Build and Execution Steps
- Save and Compile: Save the code as
race_condition_example.c
and compile it.gcc race_condition_example.c -o race_condition_example -pthread
- Run the Executable (Multiple Times):
./race_condition_example
Expected Output and Explanation
You will likely see a different result each time you run the program, and it will almost never be the expected value.
Run 1:
--- Race Condition Demonstration ---
Expected final counter value: 2000000
Actual final counter value: 1274532
Race condition detected! The final value is less than expected.
Run 2:
--- Race Condition Demonstration ---
Expected final counter value: 2000000
Actual final counter value: 1451987
Race condition detected! The final value is less than expected.
This unpredictable outcome is the classic symptom of a race condition. Both threads are reading, incrementing, and writing to counter
simultaneously. The non-atomic nature of counter++
means that many of the increments are lost, exactly as described in the technical background section. This example powerfully illustrates why synchronization is not optional but absolutely mandatory when threads share mutable data. The solution, which will be covered in a subsequent chapter, involves using a mutex to protect the counter++
operation.
Common Mistakes & Troubleshooting
When first working with threads, developers often encounter a set of common pitfalls. Understanding these ahead of time can save hours of frustrating debugging.
Exercises
These exercises are designed to reinforce the concepts of thread creation, data sharing, and the fundamental differences between threads and processes.
- Passing an Argument to a Thread:
- Objective: Modify the
thread_example.c
program to pass a value from the main thread to the new thread. - Steps:
- In
main
, declare an integer or a struct. - When calling
pthread_create()
, pass a pointer to this variable as the fourth argument. - In
thread_function
, cast thevoid*
argument back to its original pointer type and dereference it to access the value. - Print the value from within the new thread to confirm it was received correctly.
- In
- Verification: The output from the new thread should show the value that was set in
main
.
- Objective: Modify the
- Fixing the Race Condition:
- Objective: Use a mutex to fix the race condition in
race_condition_example.c
. - Steps:
- In
main
, before creating the threads, initialize apthread_mutex_t
variable usingpthread_mutex_init()
. - In the
incrementer
function, wrap thecounter++
line withpthread_mutex_lock()
andpthread_mutex_unlock()
. - After joining the threads in
main
, destroy the mutex usingpthread_mutex_destroy()
. - Compile and run the program several times.
- In
- Verification: The program should now consistently output the correct final value (2,000,000), regardless of how many times it is run.
- Objective: Use a mutex to fix the race condition in
- Comparing Creation Times:
- Objective: Write a program that empirically measures the performance difference between creating processes and creating threads.
- Steps:
- Write a program that creates 1,000 threads. Each thread should do nothing but immediately exit. Use the
clock_gettime()
function to measure the total time taken to create and join all threads. - Write a second program that creates 1,000 processes. The parent should
fork()
1,000 times andwait()
for each child. Each child should do nothing but immediately exit. Measure the total time taken.
- Write a program that creates 1,000 threads. Each thread should do nothing but immediately exit. Use the
- Verification: The time taken to create and clean up the threads should be significantly less than the time taken for the processes.
- Simple Producer-Consumer:
- Objective: Implement a basic producer-consumer scenario using a shared buffer.
- Steps:
- Create a global integer variable
buffer
and a flagis_ready
. - Create a “producer” thread that waits for a random amount of time (using
usleep()
), generates a number, places it inbuffer
, and setsis_ready
to 1. - Create a “consumer” thread that continuously checks the
is_ready
flag. When it sees the flag is 1, it consumes the number from thebuffer
, prints it, and resets the flag to 0. - Let this run for a few iterations in a loop.
- Create a global integer variable
- Verification: The consumer thread should print the numbers generated by the producer thread. This exercise highlights the need for more advanced synchronization (like condition variables) to avoid busy-waiting, a topic for a future chapter.
Summary
- Processes vs. Threads: A process is a heavyweight, isolated execution environment with its own private address space. A thread is a lightweight execution context that runs within a process and shares the process’s resources.
- Resource Sharing: Threads within the same process share the code (text segment), global data (data/BSS segments), and the heap. Each thread has its own private stack, program counter, and registers.
- Benefits of Multithreading: Threads are faster to create and have lower overhead than processes. Communication between threads is efficient as it can be done directly through shared memory, avoiding kernel-mediated IPC.
- Drawbacks of Multithreading: The shared memory model introduces complex concurrency problems, primarily race conditions, which occur when multiple threads access shared data without proper synchronization.
- Synchronization: To prevent race conditions, access to shared, mutable data must be protected within a critical section using synchronization primitives like mutexes. Failure to do so leads to unpredictable behavior and data corruption.
- Practical Implementation: In Linux, threads are typically managed using the pthreads library. Key functions include
pthread_create()
to start a new thread andpthread_join()
to wait for a thread to terminate.
Further Reading
- POSIX Threads Programming: A comprehensive tutorial from Lawrence Livermore National Laboratory. An excellent starting point for
pthreads
. https://hpc-tutorials.llnl.gov/posix/ - The Linux Programming Interface by Michael Kerrisk. Chapters 4-6 and 29-33 provide an authoritative and in-depth treatment of processes, threads, and thread synchronization in Linux.
pthreads(7)
— Linux manual page: The official man page that provides an overview of the POSIX threads API in Linux. Accessible viaman 7 pthreads
on a Linux system.- What Every Programmer Should Know About Memory by Ulrich Drepper. A deep dive into how memory works, which is invaluable for understanding the performance implications of process and thread memory layouts.
- Raspberry Pi Documentation – The C/C++ SDK: For platform-specific considerations when compiling for the Raspberry Pi. https://www.raspberrypi.com/documentation/microcontrollers/c_sdk.html
- “Futexes Are Tricky” by Ulrich Drepper. An advanced paper on the underlying mechanism (futex) that Linux uses to implement mutexes and other synchronization primitives.