Skip to main content

Process Management

A process is a program in execution — the fundamental unit of work in an operating system. Understanding process management is essential for senior engineering interviews, as it underlies everything from application behavior to container orchestration.
Interview Frequency: Very High (asked in 80%+ of OS interviews)
Key Topics: Process states, fork/exec, context switching, PCB
Time to Master: 8-10 hours

Process vs Program

Program

  • Static entity stored on disk
  • Contains code and static data
  • Passive — does nothing by itself
  • Example: /usr/bin/python3

Process

  • Dynamic instance in execution
  • Has runtime state (registers, heap, stack)
  • Active — consumes CPU, memory, I/O
  • Example: Running Python interpreter with PID 1234
Interview Insight: “A program becomes a process when loaded into memory and given system resources. Multiple processes can run the same program simultaneously.”

Process Memory Layout

Every process has a well-defined memory layout, typically divided into segments. In a 32-bit architecture, this totals 4GB of address space (2^32), usually split into User Space (low memory) and Kernel Space (high memory). Process Memory Layout

Memory Segment Details

SegmentDirectionContentsCharacteristics
Kernel SpaceTopKernel code/dataInaccessible to user mode. Contains PCB, page tables, kernel stack.
StackGrows Down ↓Function callsStores local variables, return addresses, stack frames. Auto-managed.
Mapping SegmentN/AShared libsMemory mapped files, shared libraries (e.g., libc.so).
HeapGrows Up ↑Dynamic allocationmalloc()/new. Manually managed. Fragmentation risk.
BSSFixedUninitialized globals”Block Started by Symbol”. Initialized to zero by OS loader.
DataFixedInitialized globalsint x = 10;. Read-write static data.
Text (Code)FixedMachine codeRead-only to prevent accidental modification. Sharable.
Stack vs Heap Collision: In legacy systems without ASLR or ample virtual memory, the Stack (growing down) could potentially collide with the Heap (growing up), leading to Stack Overflow or memory corruption. Modern OSs use ASLR (Address Space Layout Randomization) to randomize segment locations for security.

Process Control Block (PCB)

The PCB (or task_struct in Linux) is the kernel’s data structure representing a process:
// Simplified view of Linux task_struct
struct task_struct {
    // Process Identification
    pid_t pid;                    // Process ID
    pid_t tgid;                   // Thread Group ID
    
    // Process State
    volatile long state;          // RUNNING, SLEEPING, etc.
    
    // Scheduling Information
    int prio, static_prio;        // Priority values
    struct sched_entity se;       // Scheduler entity
    
    // Memory Management
    struct mm_struct *mm;         // Memory descriptor
    
    // File System
    struct files_struct *files;   // Open file table
    struct fs_struct *fs;         // Filesystem info
    
    // Credentials
    const struct cred *cred;      // Security credentials
    
    // Parent/Child Relationships
    struct task_struct *parent;   // Parent process
    struct list_head children;    // Child processes
    
    // CPU Context (saved on context switch)
    struct thread_struct thread;  // CPU-specific state
    
    // Signals
    struct signal_struct *signal; // Signal handlers
};

PCB Information Categories

  • PID: Unique process identifier
  • PPID: Parent process ID
  • UID/GID: User and group ownership
  • Session ID: For terminal sessions

Process States

A process transitions through various states during its lifetime: Process State Diagram

State Definitions

StateDescriptionLinux Representation
NewProcess being createdN/A (transient)
ReadyWaiting for CPUTASK_RUNNING (in run queue)
RunningExecuting on CPUTASK_RUNNING (current)
Blocked/WaitingWaiting for I/O or eventTASK_INTERRUPTIBLE / TASK_UNINTERRUPTIBLE
ZombieTerminated, waiting for parentTASK_ZOMBIE
TerminatedFully cleaned upN/A (removed)
TASK_INTERRUPTIBLE vs TASK_UNINTERRUPTIBLE:
  • Interruptible: Process can be woken by signals (common case)
  • Uninterruptible: Must complete I/O first (shows as ‘D’ in ps — often disk I/O)

Process Creation: fork() and exec()

The Unix process model is elegant: fork() creates a copy, exec() transforms it.

fork() — Creating a Child Process

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

int main() {
    int x = 100;
    
    pid_t pid = fork();  // Create child process
    
    if (pid < 0) {
        // Error occurred
        perror("fork failed");
        return 1;
    } 
    else if (pid == 0) {
        // Child process
        x += 50;
        printf("Child: x = %d, PID = %d, Parent PID = %d\n", 
               x, getpid(), getppid());
    } 
    else {
        // Parent process
        x -= 50;
        printf("Parent: x = %d, PID = %d, Child PID = %d\n", 
               x, getpid(), pid);
        wait(NULL);  // Wait for child to terminate
    }
    
    return 0;
}
Output:
Parent: x = 50, PID = 1000, Child PID = 1001
Child: x = 150, PID = 1001, Parent PID = 1000

What fork() Actually Does

Fork Exec Flow

Copy-on-Write (COW)

Modern systems don’t actually copy all memory immediately:
1

Initial State

After fork(), parent and child share the same physical pages marked read-only
2

Write Attempt

When either process tries to write, a page fault occurs
3

Copy Made

Kernel copies only that specific page for the writer
4

Continue

Process continues with its own private copy of that page
Why COW? Many processes fork() then immediately exec(), so copying all memory would be wasted work. COW makes fork() nearly O(1) in practice.

exec() Family — Replacing Process Image

The exec family of functions replaces the current process execution with a new program. The PID remains the same, but the machine code, data, heap, and stack are replaced.
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // Child: replace with ls command
        // Using execvp (Vector, Path search)
        char *args[] = {"ls", "-la", "/home", NULL};
        execvp("ls", args);
        
        // Only reached if exec fails
        perror("execvp failed");
        return 1;
    }
    
    wait(NULL);
    return 0;
}

Understanding the Variants

The exec function name tells you exactly what arguments it expects:
  • l (list): Arguments are passed as a list of strings (arg0, arg1, ..., NULL).
  • v (vector): Arguments are passed as an array of strings (argv[]).
  • p (path): Searches the $PATH environment variable for the executable.
  • e (environment): Accepts a custom environment variable array.

1. execl() & execv() — Full Path, Default Environment

Use when you have the full path to the binary.
// List version
execl("/bin/ls", "ls", "-l", NULL);

// Vector version
char *args[] = {"ls", "-l", NULL};
execv("/bin/ls", args);
Use when you want the OS to find the binary (like running a command in shell).
// Finds 'python3' in $PATH
execlp("python3", "python3", "script.py", NULL);

3. execle() & execve() — Custom Environment

Use when you need to run a process with specific environment variables (security, isolation). execve is the underlying system call on Linux; all others are library wrappers around it.
char *env[] = {"HOME=/usr/home", "LOGNAME=tarzan", NULL};
char *args[] = {"bash", "-c", "env", NULL};
execle("/bin/bash", "bash", "-c", "env", NULL, env);
FunctionPath LookupsArgs FormatEnvironmentUsage Scenario
execlNoListInheritedHardcoded args
execlpYesListInheritedShell-like commands
execleNoListExplicitSecurity/Custom Env
execvNoArrayInheritedDynamic args
execvpYesArrayInheritedShell implementation
execveNoArrayExplicitLow-level Syscall
Interview Tip: execve is the only true system call on Linux. execl, execlp, etc., are standard C library (libc) wrappers that eventually call execve.

Context Switching

A context switch is the process of saving one process’s state and restoring another’s.

What Gets Saved/Restored

Context Switch

Context Switch Overhead

Context Switch Overhead

Context switches are expensive! A simple switch might take 1-10 microseconds, but the indirect costs can degrade performance by orders of magnitude.

Register Save/Restore (0.1-0.5 μs)

When the kernel switches from Process A to Process B, it must save Process A’s CPU state and load Process B’s CPU state.

What Gets Saved

struct thread_struct {
    unsigned long sp;        // Stack pointer
    unsigned long ip;        // Instruction pointer (where we'll resume)
    unsigned long r0-r15;    // General purpose registers (x86-64 has 16)
    unsigned long flags;     // CPU flags (zero, carry, etc.)
    struct fpu_state fpu;    // Floating point registers (can be huge!)
}
On x86-64, that’s typically 30-40 registers worth of data. The kernel literally does:
; Save current process registers
mov [task_A + offset_r0], rax
mov [task_A + offset_r1], rbx
; ... repeat for all registers

; Restore next process registers  
mov rax, [task_B + offset_r0]
mov rbx, [task_B + offset_r1]
; ... repeat for all registers
Why it matters: These are just memory operations, but you’re moving 200-300 bytes. Fast, but not free.

TLB Flush (0.5-2 μs) - The Expensive One

The Translation Lookaside Buffer is a cache of virtual→physical address mappings. Each process has its own address space, so when you switch processes, these mappings become invalid.

The Problem

Process A: virtual address 0x1000 → physical RAM 0x5000
Process B: virtual address 0x1000 → physical RAM 0x8000
Same virtual address, different physical location! The TLB can’t be trusted.

Traditional Solution: Full Flush

// Invalidate ALL TLB entries
flush_tlb_all();  
Now every memory access after the switch will be slow until the TLB repopulates:
1st access: TLB miss → walk page tables (50-200 cycles)
2nd access: TLB miss → walk page tables again
3rd access: TLB miss → walk page tables again
...eventually TLB fills up and things get fast again
This is why the table says “0.5-2 μs” - you’re looking at hundreds of slow memory accesses.

Modern Solution: ASID (Address Space Identifiers)

Instead of flushing, tag each TLB entry with which process it belongs to:
TLB Entry:
  Virtual: 0x1000
  Physical: 0x5000
  ASID: 42  ← Process A's identifier

TLB Entry:
  Virtual: 0x1000
  Physical: 0x8000
  ASID: 57  ← Process B's identifier
Now both mappings coexist! When Process B runs, the CPU only uses entries tagged ASID=57. No flush needed, massive speedup.

Cache Effects (10-100+ μs) - The Silent Killer

This is about your L1/L2/L3 CPU caches going cold.

Before Context Switch (Process A running)

L1 Cache (32 KB): Full of Process A's hot data
L2 Cache (256 KB): More of Process A's working set
L3 Cache (8 MB): Even more Process A data
Every memory access hits L1 cache → 3-4 cycles latency.

After Context Switch (Process B starts)

L1 Cache: Still has Process A's data (useless!)
Process B accesses memory:
Access: 0x2000 → L1 miss (Process A's data here)
             → L2 miss (Process A's data here too)
             → L3 miss (yep, still Process A)
             → Main RAM: 200+ cycles latency
Process B gradually evicts Process A’s data from cache, replacing it with its own. But for those first microseconds (or milliseconds for big working sets), everything is slow.

Real Numbers

  • Cache hit: 3-4 cycles (~1 ns)
  • Cache miss to RAM: 200-300 cycles (~100 ns)
If your code does 1000 memory accesses and they all miss cache, you just burned 100 μs instead of 1 μs.

Scheduler Decision (0.1-1 μs)

The kernel must pick which process runs next. This involves:
// Simplified version of what Linux does
struct task_struct *pick_next_task(struct rq *runqueue) {
    // 1. Check priority queues
    for (int prio = 0; prio < 140; prio++) {
        if (!list_empty(&runqueue->tasks[prio])) {
            return list_first_entry(&runqueue->tasks[prio]);
        }
    }
    
    // 2. Check CFS (Completely Fair Scheduler) red-black tree
    struct task_struct *next = rb_first(&runqueue->cfs_tasks);
    
    // 3. Update statistics, handle real-time constraints
    update_curr(runqueue);
    
    return next;
}
Why it costs time: Walking data structures, comparing priorities, updating runtime statistics. On a system with 100+ runnable processes, this isn’t instant.

Mitigation Strategies Explained

1. CPU Pinning - Cache Locality

# Pin process to CPU 0
taskset -c 0 ./my_app
Why it helps: If your process always runs on CPU 0, that CPU’s cache stays warm with your data. No cold cache penalty on every switch. Trade-off: Less flexible load balancing.

2. Larger Time Slices - Amortize the Cost

Small slice (10ms):  1000 context switches/sec
Large slice (100ms):  100 context switches/sec
If each switch costs 20 μs total overhead:
  • Small: 1000 × 20 μs = 20 ms wasted/sec (2% overhead)
  • Large: 100 × 20 μs = 2 ms wasted/sec (0.2% overhead)
Trade-off: Higher latency for interactive tasks. Your mouse might feel sluggish.

3. User-Space Threading (Green Threads)

Languages like Go use goroutines that switch without kernel involvement:
// These don't trigger context switches!
go task1()  
go task2()
The Go runtime multiplexes thousands of goroutines onto a few OS threads. Switching between goroutines:
  • No TLB flush (same process)
  • No cache flush (same process)
  • No kernel involvement (no syscall overhead)
  • Just save/restore a tiny bit of state
A goroutine switch might be 50-100 ns vs 2-5 μs for a full context switch.

The Big Picture

Context switches aren’t slow because of one thing—it’s death by a thousand cuts:
Register save:    0.3 μs
TLB flush:        1.5 μs  (with ASID: 0 μs!)
Scheduler logic:  0.5 μs
Cache warmup:    50.0 μs  (the real killer)
─────────────────────────
Total:          ~52 μs per switch
At 1000 switches/second, you’re burning 5% of your CPU just on context switching overhead. This is why high-performance systems obsess over reducing context switches.

Zombie and Orphan Processes

Zombie Process

A zombie is a terminated process whose parent hasn’t yet called wait():
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // Child exits immediately
        printf("Child exiting\n");
        return 0;
    }
    
    // Parent doesn't call wait() - child becomes zombie
    printf("Parent sleeping... child is now a zombie\n");
    sleep(60);  // During this time, child is zombie
    
    return 0;
}
Check with ps:
$ ps aux | grep Z
user  1001  0.0  0.0  0  0  ?  Z  12:00  0:00 [a.out] <defunct>
Problem: Zombies consume PID entries. A system can run out of PIDs if too many zombies accumulate.Solution: Parent must call wait() or waitpid(), or use SIGCHLD handler.

Orphan Process

An orphan is a child whose parent terminated first:
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid > 0) {
        // Parent exits immediately
        printf("Parent exiting, child will be orphaned\n");
        return 0;
    }
    
    // Child continues running
    sleep(5);
    printf("Orphan child: my new parent is %d\n", getppid());
    
    return 0;
}
Output:
Parent exiting, child will be orphaned
Orphan child: my new parent is 1
Orphans are “adopted” by init (PID 1) or a subreaper process, which will properly reap them when they terminate.

Fork Variants

vfork()

A vfork() is optimized for the fork-then-exec pattern:
pid_t pid = vfork();

if (pid == 0) {
    // Child: MUST call exec() or _exit() immediately
    // Parent is SUSPENDED until child does so
    execl("/bin/ls", "ls", NULL);
    _exit(1);  // Not exit() — avoid flushing parent's buffers
}
Aspectfork()vfork()
Address spaceCopied (COW)Shared with parent
Parent executionContinuesSuspended until exec/_exit
SafetySafe for any useDangerous — child can corrupt parent
Use caseGeneralfork + immediate exec

clone() — Linux’s Swiss Army Knife

The clone() system call provides fine-grained control over resource sharing:
#include <sched.h>

// Create new thread (shares everything)
clone(fn, stack, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, arg);

// Create new process (like fork)
clone(fn, stack, SIGCHLD, arg);

// Create process with new namespace (containers)
clone(fn, stack, CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET, arg);
Common Clone Flags:
FlagEffect
CLONE_VMShare virtual memory
CLONE_FSShare filesystem info (cwd, root)
CLONE_FILESShare file descriptor table
CLONE_SIGHANDShare signal handlers
CLONE_THREADSame thread group (for pthreads)
CLONE_NEWPIDNew PID namespace (containers)
CLONE_NEWNSNew mount namespace

Interview Deep Dive Questions

Complete Answer:
  1. Shell (bash) reads input “ls” from stdin
  2. Shell parses the command and arguments
  3. Shell calls fork() to create child process
    • COW creates lightweight copy
  4. Child process calls execvp("ls", args)
    • Kernel loads /bin/ls executable
    • New code, data, heap, stack are set up
    • File descriptors 0,1,2 remain (inherited)
  5. Parent shell calls waitpid() and blocks
  6. ls process runs, writes to stdout (fd 1)
  7. ls calls exit(0), becomes zombie
  8. Parent’s waitpid() returns, zombie is reaped
  9. Shell displays next prompt
Answer:Even with COW, fork() still must:
  • Allocate new PID and PCB
  • Copy page table entries (not data, but metadata)
  • Copy file descriptor table
  • Copy signal handlers and other process state
  • Set up memory mappings
For a process with 10GB virtual memory, copying page table entries alone can be significant.Alternatives:
  • vfork(): Suspends parent, shares address space
  • posix_spawn(): Single call that does fork+exec atomically
  • Clone with minimal sharing for containers
Answer:No. A zombie is already dead — it’s not running any code. kill sends signals to running processes.A zombie exists only because:
  • Its exit status hasn’t been collected by parent
  • Its PCB entry and PID are retained for this purpose
To eliminate zombies:
  • Parent calls wait()/waitpid()
  • Kill the parent — orphaned zombies are adopted by init and reaped
  • Use SIGCHLD handler to auto-reap
// Auto-reap children
signal(SIGCHLD, SIG_IGN);
Answer:
AspectProcess SwitchThread Switch
Address spaceChangesSame
Page tableSwitched (TLB flush)Not changed
CPU registersSaved/restoredSaved/restored
Kernel overheadHigherLower
Cache effectsWorse (different memory)Better (shared data)
Typical cost1-10 μs + cache misses0.1-1 μs
Thread switches within the same process are much cheaper because:
  • No page table switch needed
  • Shared memory means cached data stays valid
  • Only thread-local state needs saving
Answer:Process-per-connection (not recommended):
  • 10,000 processes = massive memory overhead
  • Context switch overhead kills performance
Thread-per-connection:
  • Better but still problematic at 10K
  • Stack memory: 10K × 8MB = 80GB virtual memory
  • Thread switching overhead
Event-driven (epoll/io_uring):
  • Single thread handles many connections
  • Use epoll_wait() to multiplex I/O
  • Non-blocking I/O for all sockets
Hybrid:
  • Multiple worker processes (CPU count)
  • Each uses event loop for many connections
  • Examples: Nginx, Node.js cluster
// epoll example
int epfd = epoll_create1(0);
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        handle_event(events[i]);  // Non-blocking
    }
}

Practice Exercises

1

Fork Chain

Write a program that creates a chain of N processes (each child creates one grandchild). Print the process tree.
2

Zombie Factory

Create a program that generates zombies, then use ps to observe them. Implement proper cleanup.
3

Measure Context Switch

Use pipes between two processes to measure context switch time by rapidly passing a token back and forth.
4

Custom Shell

Implement a simple shell that can run commands, handle pipes, and manage background processes.

Key Takeaways

Process = Execution Context

PCB contains everything kernel needs: state, memory, files, credentials

Fork + Exec

Unix model: copy then transform. COW makes fork cheap.

Context Switch Cost

Direct cost + cache/TLB effects. Minimize switches for performance.

Zombie/Orphan Handling

Always reap children. Orphans adopted by init.

Next: Threads & Concurrency