Free lesson · Operating Systems
How A Process Is Created
The Two Steps of Process Creation
Every process on a Unix or Linux system (which is what FAANG infrastructure runs on) is created through a two-step mechanism: fork and exec. Understanding these two system calls is one of the most testable OS concepts in interviews.
Here is the big picture before we go into details. When a running process wants to create a new process, it calls fork(). This creates an almost-exact copy of the original process - same code, same data, same open files, same everything. The original process is called the parent. The copy is called the child. At this point, both parent and child are running the same program.
If the child wants to run a different program (which it usually does), it calls exec(). This replaces the child's code, data, and stack with the new program's code, data, and stack. The child's PID stays the same - it is still the same process - but now it is executing a completely different program.
This two-step design might seem odd at first. Why not have a single "create a process and run this program" call? The answer is that separating creation from program loading gives the parent a window to set things up between the fork and the exec - redirecting input/output, setting environment variables, changing the working directory, closing files the child should not inherit. This flexibility is why every Unix shell, every web server, and every container runtime uses fork-and-exec.
sequenceDiagram
participant Parent as Parent Process<br/>(PID 100)
participant OS as Operating System
participant Child as Child Process<br/>(PID 101)
Parent->>OS: fork()
OS->>OS: Copy parent's memory,<br/>registers, file descriptors,<br/>create new PID
OS-->>Parent: Returns child PID (101)
OS-->>Child: Returns 0
Note over Parent: Continues running<br/>original program
Note over Child: Running same program<br/>as parent (copy)
Child->>OS: exec("/bin/ls")
OS->>OS: Replace child's code,<br/>data, stack with<br/>the ls program
Note over Child: Now running /bin/ls<br/>(still PID 101)
Child-->>OS: exit(0)
OS-->>Parent: Child terminated<br/>(via wait())
This sequence diagram shows the fork-and-exec process creation mechanism. The parent process with PID 100 calls fork. The OS creates a copy of the parent with a new PID 101. Fork returns the child's PID (101) to the parent and returns 0 to the child. The child then calls exec with the path to the ls program. The OS replaces the child's memory contents with the ls program. The child is now running ls but still has PID 101. When ls finishes, the child exits, and the parent is notified via wait.
Fork: Creating a Copy of Yourself
The fork() system call is one of the most distinctive features of Unix operating systems. When a process calls fork(), the operating system creates a new process that is an almost-exact duplicate of the calling process. The new process gets a copy of the parent's memory (code, data, heap, stack), a copy of the parent's open file descriptors, the same environment variables, the same current working directory, and the same signal handlers.
The child is nearly identical to the parent, with a few critical differences. The child gets a new PID - it is a new, distinct process. The child's parent PID (getppid()) is set to the parent's PID. The child's resource usage counters (CPU time, etc.) start at zero. And certain things are not inherited - the child does not inherit the parent's memory locks, pending signals, or timers.
The Return Value Trick
Here is the cleverest part of fork(), and the part that confuses people the first time they see it: fork() is called once but returns twice. It returns once in the parent process and once in the child process. But it returns a different value in each:
- In the parent,
fork()returns the child's PID (a positive number). This tells the parent the PID of the child it just created, so the parent can track it, wait for it, or send signals to it. - In the child,
fork()returns 0. This tells the child "you are the child." The child can then callgetpid()to find out its own PID andgetppid()to find its parent's PID. - If
fork()fails (usually because the system has run out of memory or has hit the maximum process limit), it returns -1 in the parent, and no child is created.
This return value trick is how programs distinguish the parent path from the child path after forking.
import os
print(f"Before fork: I am process {os.getpid()}")
pid = os.fork()
if pid > 0:
# This code runs in the PARENT
print(f"Parent (PID {os.getpid()}): My child's PID is {pid}")
elif pid == 0:
# This code runs in the CHILD
print(f"Child (PID {os.getpid()}): My parent's PID is {os.getppid()}")
else:
# fork() failed
print("Fork failed!")
Run this code and look at the output carefully. You will see three print statements: the "Before fork" line from the original process, the parent's message, and the child's message. The "Before fork" line runs once. After the fork, the code from if pid > 0 onward runs in both processes - but each process takes a different branch based on the return value of fork().
This is the moment where the concept becomes concrete. Before the fork, there is one process. After the fork, there are two. Both continue from the exact same point in the code (the line after fork()), but each one sees a different return value and takes a different path.
Important: The "fork returns twice" behavior is a classic interview topic. Make sure you can explain it clearly: "fork() creates a copy of the calling process. It returns twice - once in the parent with the child's PID, and once in the child with 0. This allows the program to detect which process it is and behave differently in each."
Copy-on-Write: The Performance Optimization
You might be thinking: "Wait - copying the entire memory of a process sounds extremely expensive. What if the parent has 2 GB of memory? Does fork immediately allocate another 2 GB?"
No. Modern operating systems use an optimization called copy-on-write (COW). When fork is called, the OS does not actually copy the parent's memory. Instead, it sets up the child's page table to point to the same physical memory pages as the parent. Both parent and child share the same physical memory, but the OS marks all the pages as read-only.
If either process tries to write to a shared page, the OS intercepts the write (via a page fault), makes a copy of just that one page, gives the copy to the process that wrote to it, and lets the write proceed. Only the pages that are actually modified get copied. Pages that neither process changes - like the program's code in the text segment - remain shared forever.
graph TD
subgraph BEFORE["After fork() - Copy-on-Write"]
P1["Parent Process<br/>Page Table"] --> M1["Shared Page A<br/>(read-only)"]
P1 --> M2["Shared Page B<br/>(read-only)"]
P1 --> M3["Shared Page C<br/>(read-only)"]
C1["Child Process<br/>Page Table"] --> M1
C1 --> M2
C1 --> M3
end
subgraph AFTER["After Child Writes to Page B"]
P2["Parent Process<br/>Page Table"] --> M4["Shared Page A<br/>(read-only)"]
P2 --> M5["Page B<br/>(parent's copy)"]
P2 --> M6["Shared Page C<br/>(read-only)"]
C2["Child Process<br/>Page Table"] --> M4
C2 --> M7["Page B<br/>(child's copy)"]
C2 --> M6
end
This diagram shows copy-on-write in two stages. In the first stage, after fork is called, both the parent and child page tables point to the same three shared physical memory pages, all marked read-only. In the second stage, after the child writes to Page B, the OS has created a separate copy of Page B for the child. Pages A and C are still shared between both processes. Only the page that was actually modified was copied.
Copy-on-write makes fork very fast, even for processes with large memory footprints. The fork call itself does not copy memory - it just duplicates the page table and sets some flags. The actual memory copying happens lazily, only when needed.
Interview: Copy-on-write is a common follow-up question. When you mention fork in an interview, the interviewer may ask "what is the cost of fork?" or "does fork copy all the parent's memory?" The strong answer mentions copy-on-write: "Modern OSes use copy-on-write. Fork does not actually copy the parent's memory - it shares the pages and marks them read-only. Pages are only copied when one process writes to them. This makes fork fast even for large processes."
Exec: Replacing the Program
After fork creates a copy of the parent, the child process is running the same program as the parent. Usually, that is not what you want. You want the child to run a different program - maybe ls, or a web server, or a database.
The exec() family of system calls replaces the current process's program with a new program. When a process calls exec, the OS removes the process's current code, data, heap, and stack from memory, loads the new program's code from disk, sets up fresh data, heap, and stack for the new program, and starts executing the new program from its entry point (typically main()).
The critical detail: exec does not create a new process. The process keeps its PID, its parent, its open file descriptors (unless specifically closed), and its position in the process table. It just starts running different code. Think of it like an actor changing costumes mid-play - same actor, different role.
Here is exec in action. This C-style pseudocode shows the typical fork-exec pattern that every Unix shell uses:
pid_t pid = fork();
if (pid == 0) {
// Child process
// Replace this program with /bin/ls
execl("/bin/ls", "ls", "-la", NULL);
// If exec succeeds, this line NEVER runs.
// exec replaces the entire program.
// If we get here, exec failed.
perror("exec failed");
exit(1);
}
// Parent process continues here
// Wait for child to finish
waitpid(pid, &status, 0);
Notice the comment after execl(): "this line NEVER runs." That is because exec replaces the entire program. If exec succeeds, the process is now running /bin/ls, and the code after the exec call no longer exists in memory. The only way the code after exec runs is if exec fails (for example, if the program file does not exist).
The exec Family
There is not just one exec call - there is a family of them, each with slightly different arguments. The most common ones are:
| Function | Arguments | What It Does |
|---|---|---|
execl() |
path, arg0, arg1, ..., NULL | List of arguments, path to program |
execv() |
path, argv[] | Array of arguments, path to program |
execle() |
path, arg0, ..., NULL, envp[] | Like execl but with custom environment |
execvp() |
file, argv[] | Like execv but searches PATH for the program |
execlp() |
file, arg0, ..., NULL | Like execl but searches PATH for the program |
The naming convention is: l means arguments as a list, v means arguments as a vector (array), p means search the PATH, e means pass a custom environment. In practice, execvp() is the most commonly used because it takes an array of arguments and searches PATH, which is what most programs need.
How Your Shell Uses Fork and Exec
Every time you type a command in your terminal, your shell (bash, zsh, fish - whatever you use) runs the fork-exec pattern. Understanding this helps you answer the classic interview question: "What happens when you type ls in a terminal?"
sequenceDiagram
participant User as You
participant Shell as Shell Process<br/>(bash, PID 500)
participant OS as Operating System
participant LS as ls Process<br/>(PID 501)
User->>Shell: Type "ls -la" and press Enter
Shell->>Shell: Parse the command
Shell->>OS: fork()
OS-->>Shell: Returns 501 (child PID)
OS->>LS: Returns 0 (I am the child)
LS->>OS: exec("/bin/ls", ["ls", "-la"])
OS->>LS: Replace program with /bin/ls
Note over LS: ls reads the directory<br/>and prints the listing
LS-->>OS: exit(0)
Shell->>OS: wait() - blocks until child finishes
OS-->>Shell: Child 501 exited with status 0
Shell->>User: Display prompt again
This sequence diagram shows what happens when you type ls in a terminal. The shell process parses the command, calls fork to create a child process, then the child calls exec to replace itself with the ls program. The ls program reads the directory, prints the listing, and exits. The shell process, which was waiting for the child to finish, receives the exit status and displays the prompt again.
This sequence happens every single time you run a command. When you type python script.py, the shell forks, the child exec's Python with your script as an argument, Python runs, and when it finishes, the shell displays the prompt again. When you type gcc program.c, the shell forks, the child exec's the GCC compiler, GCC compiles your program and exits, and the shell is ready for your next command.
The elegance of this design is that the shell never stops running. It forks a copy of itself, and the copy becomes whatever program you asked for. The original shell process just waits until the child finishes. If the child crashes, the shell is unaffected - it was a separate process.
The Parent-Child Relationship
Fork creates a tree of processes. The first process the OS creates at boot time is called init (on modern Linux systems, this is usually systemd), and it has PID 1. Every other process on the system is a descendant of init. Your terminal shell was created by a login process, which was created by the terminal emulator, which was created by the desktop environment, which was created by a session manager, which was created by init.
You can see this tree on Linux using the pstree command:
pstree -p
This prints the entire process tree with PIDs. You will see init (PID 1) at the top, branching into system services, login managers, desktop environments, and eventually your terminal and the programs you are running.
The parent-child relationship is important because the parent is responsible for cleaning up after its children. When a child process finishes, the OS keeps a small record of it (its exit status) until the parent calls wait() to collect that status. If the parent does not call wait(), the child's record stays in the process table - this is called a zombie process, and we will cover it in Topic 5.
If the parent exits before the child, the child becomes an orphan process. The OS reassigns orphan processes to init (PID 1), which periodically calls wait() to clean them up. We will cover orphans in Topic 5 as well.
Tip: For the interview, remember this hierarchy: init is the ancestor of all processes. Fork creates a parent-child relationship. The parent must wait for the child to clean up its exit status. Failure to wait creates zombies. Parent death creates orphans. Init adopts orphans.
Why Not Just Have a "Create Process" Call?
Other operating systems do have a single call that creates a new process and immediately runs a specified program. Windows has CreateProcess(), which takes the program to run as an argument and creates a new process running that program in one step.
Unix chose a different design - separate fork and exec - for a reason. The separation gives the parent a window between creating the child and loading the new program. In that window, the parent (or child) can set up the child's environment. This is how Unix implements I/O redirection and other features.
For example, when you type ls > output.txt in a shell, the shell forks a child, the child opens output.txt and redirects its standard output to that file (between fork and exec), and then the child exec's ls. The ls program has no idea that its output is going to a file - it just writes to standard output as usual, and the OS sends that output to the file. This works because the child inherited the parent's file descriptors (via fork) and then modified them (between fork and exec) before replacing the program (via exec).
Similarly, Unix pipes (ls | grep foo) work by the shell forking two children, connecting the first child's standard output to the second child's standard input via a pipe (between fork and exec in each child), and then exec'ing ls in the first child and grep in the second.
This fork-exec separation is the foundation of Unix's composability - the ability to combine small programs into powerful pipelines. Every time you use a pipe, a redirect, or a subshell, you are relying on the window between fork and exec.
Exercise: Starter: Write a Python program that calls
os.fork()and has the parent print "I am the parent" and the child print "I am the child." Verify that both messages appear when you run it. Check that the parent and child have different PIDs.Intermediate: Modify the program from the Starter exercise so that the parent waits for the child to finish before printing its message. Use
os.waitpid(). Observe the order of the output - the child's message should always appear before the parent's message now.Advanced: In a terminal, type
strace -f -e trace=clone,execve bash -c "ls -la"on a Linux system. This traces the system calls that bash makes when runningls. You should see aclonecall (the modern Linux equivalent offork) followed by anexecvecall. Match what you see in the trace to the fork-exec sequence described in this topic.