Processes

Subpage of Operating Systems

Diving deep into the design and structure of Operating Systems.

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 calls exec() 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 the ls command.

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;
}
  1. Parent forks a child.
  2. Child calls exec() to run ls -l. Its process image is replaced.
  3. Parent calls wait() to block until the child finishes.
  4. Child eventually calls exit() (implicitly when ls finishes).
  5. 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, kill doesn’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:

  • sigaction struct (preferred over signal() 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) and CLONE_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.

/ Continue

Follow the technical trail.

Use the dense notes as the source material, then move through the guided route, writing, or project proof when you want a cleaner entry point.