A process is a (abstraction of a) program in execution, including its code, data, stack, registers, and resources.
Processes provide abstraction by encapsulating execution so multiple programs can run independently without interfering.
Associated with each process is its:
- Address Space (translated to a set of locations from 0 to some maximum) containing the program’s data and the stack. This is sometimes called the core image of the process.
- Registers
- List of open files (in the file descriptor table where file descriptors point to open file descriptions). See Process State.
- Outstanding alarms
Basically, everything that is needed to run the process is stored with the process. This is why the process can be viewed as the basic container in a operating system.
In many operating systems, all the information about each process is stored in an operating system table called the process table, which is an array of structures, one for each process currently in existence.
It acts as an array of Process Control Blocks (PCBs), where each entry corresponds to a single process, storing its PID, state, memory allocation, and CPU register data to facilitate scheduling and context switching.
Multiprogramming
Multiprogramming is the technique of running multiple programs on a single CPU by interleaving their execution. The OS keeps several jobs in memory and switches between them to maximize CPU utilization.
In early batch systems, the CPU often sat idle while waiting for I/O (like reading from disk). Multiprogramming ensures that while one job waits, another can use the CPU, improving throughput.
The easiest way to do multiprogramming is with a simple round-robin approach: load multiple programs into memory, and when one blocks on I/O, switch to another. This required manual partitioning of memory and primitive scheduling.
Today’s OSs use sophisticated scheduling algorithms (priority, fairness, real-time constraints) and virtual memory to allow many processes to coexist safely. Multiprogramming evolved into multitasking and time-sharing, where responsiveness for interactive users became key.
Working with processes
We do this using the Process API which is provided by the OS itself.
It consists of system calls (like fork(), exec(), wait(), exit()) that let user programs interact with the OS kernel. Most modern operating systems follow the POSIX standard, which defines a portable set of these APIs so that code written for one POSIX-compliant OS can run on another.
Typical process-related system calls include:
fork()→ Creates a new process (child) that is a duplication of the current process.exec()→ Replaces the current process image with a new program.wait()→ Makes a parent wait for its child to finish.exit()→ Terminates a process.kill()→ Sends signals to processes.
These calls allow the OS to manage process creation, execution, synchronization, and termination.
Fork
The fork() system call is one of the most fundamental process-related calls in Unix-like operating systems. Here is how it works:
- When a process calls
fork(), the operating system creates a child process that is almost identical to the parent. - Both parent and child continue execution from the point where
fork()was called. - The only difference:
fork()returns 0 in the child process, and the child’s PID (process ID) in the parent process. - This allows the program to distinguish between parent and child and run different code paths.
It’s the standard way to spawn new processes in Unix-like systems. Parent and child can run concurrently, performing different tasks. Typically, after fork(), the child calls exec() to replace its memory image with a new program (e.g., launching another executable).
Every time you run a command in a Unix shell, the shell forks a child process to execute that command.
Here is an example of how fork() might be used and the parent waits for the child to finish before exiting.
#include <iostream>
#include <unistd.h> // for fork(), getpid()
#include <sys/wait.h> // for wait()
int main() {
pid_t pid = fork(); // Create a new process
if (pid < 0) {
std::cerr << "Fork failed!" << std::endl;
return 1;
}
else if (pid == 0) { // Child process
std::cout << "Hello from child! PID = " << getpid() << std::endl;
}
else { // Parent process
std::cout << "Hello from parent! PID = " << getpid()
<< ", child PID = " << pid << std::endl;
wait(nullptr); // Wait for child to finish
}
return 0;
}Note: fork() only copies the calling thread, it can be problematic in multithreaded applications. Other threads in the parent do not exist in the child, which may lead to inconsistencies if the program relies on them. In short, try to not fork a multi-threaded process.
Exec
exec replaces the current process image with a new program.
- How it works: After a
fork(), the child often callsexec()to run a different program. The process ID stays the same, but its code, data, and stack are replaced. - Why used: It allows a process to launch another executable without creating a new PID.
- Example use case: A shell forks a child, then the child calls
exec("ls")to run thelscommand.
Wait
wait makes a parent process wait until one of its child processes finishes.
- How it works: The parent blocks until the child terminates, then retrieves the child’s exit status. Wait works because the OS keeps track of the parent child relationships in processes.
- Why used: Prevents zombie processes and ensures proper synchronization between parent and child.
- Example use case: A parent program waits for its child to complete before continuing.
Exit
exit terminates the calling process.
- How it works: Cleans up resources, closes file descriptors, and returns an exit status to the parent.
- Why used: Signals completion and allows the parent to collect the status via
wait. - Example use case: A child process finishes its work and calls
exit(0)to indicate success.
Here is en example of a very common pattern using the API commands we have looked at so far:
#include <iostream>
#include <unistd.h> // fork, exec, exit
#include <sys/wait.h> // wait
int main() {
pid_t pid = fork();
if (pid < 0) {
std::cerr << "Fork failed!" << std::endl;
return 1;
}
else if (pid == 0) {
// Child process: replace with "ls" program
execlp("ls", "ls", "-l", nullptr);
// If exec fails:
std::cerr << "Exec failed!" << std::endl;
exit(1);
}
else {
// Parent process: wait for child
int status;
wait(&status);
std::cout << "Child finished with status " << status << std::endl;
}
return 0;
}- Parent forks a child.
- Child calls
exec()to runls -l. Its process image is replaced. - Parent calls
wait()to block until the child finishes. - Child eventually calls
exit()(implicitly whenlsfinishes). - Parent collects the exit status and continues.
kill and signal
These are interesting because they are used to make processes communicate with each other.
A signal is a lightweight notification sent to a process to inform it of an event (e.g., termination request, segmentation fault, timer expiration).
Signals are identified by integers (e.g., SIGKILL, SIGTERM, SIGINT, SIGUSR1). Processes can handle signals by installing a custom handler function, or they can rely on default behavior (like termination upon SIGINT). For each signals, there is a default handler defined by the system.
The kill System Call sends a signal to a process (or group of processes).
- Prototype:
int kill(pid_t pid, int sig); - Usage:
kill(pid, SIGTERM)→ politely ask a process to terminate.kill(pid, SIGKILL)→ forcefully terminate (cannot be caught or ignored).
- Note: Despite its name,
killdoesn’t always terminate; it just delivers a signal.
The signal API installs a handler function for a given signal.
- Prototype:
void (*signal(int sig, void (*handler)(int)))(int); - Usage: Allows a process to define what happens when it receives a particular signal.
- Example: Catching
SIGINT(Ctrl+C) to clean up resources before exiting.
To work with signals, you often use:
sigactionstruct (preferred oversignal()for reliability):struct sigaction { void (*sa_handler)(int); // handler function void (*sa_sigaction)(int, siginfo_t*, void*); // advanced handler sigset_t sa_mask; // signals to block during handler int sa_flags; // options };sigset_t: Represents a set of signals (used for blocking/unblocking).siginfo_t: Provides detailed info about the signal (sender PID, reason, etc.).
Here is an example of how all of this comes together:
#include <iostream>
#include <csignal>
#include <unistd.h>
void handler(int sig) {
std::cout << "Caught signal " << sig << std::endl;
}
int main() {
// Install handler for SIGUSR1
signal(SIGUSR1, handler);
pid_t pid = fork();
if (pid == 0) {
// Child process: wait for signal
pause(); // wait until a signal arrives
std::cout << "Child exiting\n";
_exit(0);
} else {
// Parent process: send signal to child
sleep(1);
kill(pid, SIGUSR1); // send SIGUSR1
wait(nullptr);
std::cout << "Parent done\n";
}
return 0;
}clone
clone() is a more flexible alternative to fork(), introduced in Linux. It allows fine-grained control over what is shared between parent and child.
Unlike fork(), clone() can create either processes or threads depending on the flags passed. It allows the child to share specific resources with the parent, such as memory space, file descriptors, signal handlers, or even the same thread group.
- Thread creation: By using flags like
CLONE_VM(share memory space) andCLONE_THREAD(share thread group),clone()can create threads instead of independent processes. - Lightweight processes: It enables the creation of processes that share resources selectively, making it more efficient for certain applications.
The Process Model
A process was just a loaded program with a single execution context. Switching between processes required saving/restoring all CPU state manually.
All processes are started by processes. However, this raises the question of how the first process is created. This is covered under the topic of the boot process.
Improvements
OSs maintain process control blocks (PCBs) with metadata (PID, state, registers, memory mappings).
Context switching is optimized with hardware support. Processes can spawn child processes and communicate via IPC (Inter-Process Communication). For this, we have signals which are the software equivalent of interrupts. These allow processes to be notified of asynchronous events.
To allow processes to work effectively on systems with multiple uses, processes store the UID (User Identification) of the user that started the process. This allows access control to be built in.
Beyond Processes
Leads to scheduling, IPC mechanisms (pipes, sockets, shared memory), and resource allocation.