Processes in Linux with C — theory with fork(), exec and wait in a restaurant kitchen (and the counter that stayed at 0)
While preparing the IC2 processes lab I made up a kitchen: a head chef (the parent process) who hires three cooks (three child processes), and every cook adds 1 to a global counter of ready dishes. Three cooks, three dishes. Easy, right? I compiled, ran it, and the chef told me this:
/* hook.c: the dish counter that stayed at 0 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int ready_dishes = 0; /* global counter: I thought it was shared */
int main(void) {
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
/* the cook (child) prepares the dish and adds 1 to the counter */
ready_dishes++;
printf("Cook %d: dish ready (my counter is %d)\n", i + 1, ready_dishes);
exit(0);
}
/* the chef (parent) waits for that cook before launching the next one */
wait(NULL);
}
printf("Chef: ready dishes = %d\n", ready_dishes);
return 0;
}
And the real output:
Cook 1: dish ready (my counter is 1) Cook 2: dish ready (my counter is 1) Cook 3: dish ready (my counter is 1) Chef: ready dishes = 0
Every cook says its counter is 1… and the chef says there are 0 ready dishes. I spent a good while hunting for the bug in the loop, in the ++, in the wait. The code was fine. What was wrong was my idea of what fork() does: it does not share memory, it copies it. Each cook added 1 to its own copy of the counter, and the chef’s copy was never touched.
Today we are going to understand that and everything around it: what a process is, how to create one with fork(), how it turns into another program with exec, how the parent waits and collects the result with wait() and exit(), and what the famous zombies are. We come from the C preprocessor, and when you finish you have the processes practice with the whole kitchen up and running.
Table of Contents
What a process is (and how it differs from a program)
A program is a file on disk: fork_basic.c compiled sits still in your folder as fork_basic. A process is that program running: it has its own memory (variables, stack, heap), its program counter (which line it is on), its open files and, above all, a number that identifies it, the PID (Process IDentifier).
The same program can be running several times at once, and each run is a different process with its own PID. If you open two terminals, you have two bash processes from the same program /bin/bash.
On top of that, every process is created by another process. The creator is called the parent and the new one, the child. That is why processes form a tree: your program is a child of the shell, the shell is a child of the terminal emulator… and at the very top is the process with PID 1 (init or systemd), the ancestor of them all. In the guided lab you see it with two commands:
| Command | What it shows |
|---|---|
ps | The processes of your terminal, with their PID |
ps -o pid,ppid,stat,comm | Also the parent’s PID (PPID) and the state |
pstree -p | The whole tree with the PIDs in brackets |
sleep 20 & | Launches a background process so you can see it in ps while it lives |
Your PID and your parent’s: getpid() and getppid()
The first program of the lab asks the system who it is and who its parent is. Both functions are in <unistd.h> and return a pid_t, which on Linux is an integer:
#include <stdio.h>
#include <unistd.h>
int main(void) {
/* getpid(): the number of the process running this line */
printf("Current process PID: %d\n", getpid());
/* getppid(): the number of its parent (in your terminal, the shell that launched it) */
printf("Parent PID: %d\n", getppid());
return 0;
}
My single run printed this:
Current process PID: 508 Parent PID: 387
Careful with the numbers: the system hands out PIDs as processes are born, so on your computer you will get different ones, and they will change every time you run it. The parent (387) in my case is the script I use to launch every program in the article; if you launch it yourself from the terminal, getppid() will give you the PID of your bash, and that one will repeat between runs (it is the same shell), while your program’s will keep changing.
With -std=c11 there is a catch: if you declare a pid_t variable and only include <unistd.h>, gcc may say unknown type name 'pid_t'. The type lives in <sys/types.h> (and <sys/wait.h> and other headers bring it too). That is why you will see <sys/types.h> or <sys/wait.h> in the programs that store a PID.
fork(): one call, two returns
fork() creates a new process by copying the one that calls it. The lab describes it very well: it is as if the system took a photo of the process at that instant (memory, variables, stack, program counter…) and started a new process from that photo. From then on there are two independent processes that keep running from the same line: right after the fork().
And how does each one know who it is, if they are copies? By what fork() returns:
| Return value | Who gets it | What it means |
|---|---|---|
0 | The child | “I am the child” (it gets its real PID with getpid()) |
| Greater than 0 | The parent | It is the PID of the child that has just been born |
-1 | The parent | The child could not be created (there is no second process) |
Let’s watch the head chef open the kitchen with one cook:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
printf("Chef (PID %d): opening the kitchen\n", getpid());
/* from here there are TWO processes, and both continue on the line below */
pid_t pid = fork();
if (pid < 0) {
/* -1: the system could not create the process (there is no child) */
perror("fork");
exit(1);
} else if (pid == 0) {
/* 0: only the child runs this branch */
printf("Cook: fork() returned %d. My PID is %d and my chef is %d\n",
pid, getpid(), getppid());
exit(0);
} else {
/* greater than 0: only the parent runs this branch; pid is the child's PID */
wait(NULL);
printf("Chef: fork() returned %d, my cook's PID\n", pid);
}
/* only the parent reaches this line: the child already left with exit(0) */
printf("Chef: closing the kitchen\n");
return 0;
}
Real output:
Chef (PID 514): opening the kitchen Cook: fork() returned 0. My PID is 515 and my chef is 514 Chef: fork() returned 515, my cook's PID Chef: closing the kitchen
Between the lines:
- “opening the kitchen” appears only once: it is printed before the
fork(), while there is still a single process. - The cook got
0fromfork(), but its PID is 515. The chef got 515: the same number. That is why the parent can keep that value and use it later to wait for that specific child. - The cook says its chef is 514, which is exactly the PID the chef printed when opening: the child’s
getppid()= the parent’sgetpid(). - “closing the kitchen” appears once because the child ended with
exit(0)inside its branch and never reached that line.
Look at the drawing: the arrow splits in two at the fork(), and the only thing that tells the two paths apart is the value of pid.
Thinking that fork() “runs the child function” or that the child starts from the beginning of main. No: the child is born in the middle of the program, on the line after the fork(), with every variable exactly as it was. If the child does not end with exit() inside its branch, it will go on running whatever comes next, just like the parent.
The if (pid < 0) is not decoration. If the system runs out of resources (too many processes), fork() returns -1 and there is no child. perror("fork") tells you why.
Without wait(): order is not guaranteed (and orphans)
In the previous program the chef called wait(NULL) before its final message, and that is why the order of the output was guaranteed. What happens if the parent does not wait? This cook takes a second to finish and the chef leaves without looking back:
#include <stdio.h>
#include <sys/types.h> /* pid_t: with -std=c11, unistd.h does not bring it */
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
printf("Cook %d: starting, my chef is %d\n", getpid(), getppid());
/* during this second the chef ends without waiting for me */
sleep(1);
printf("Cook %d: finishing, now my chef is %d\n", getpid(), getppid());
return 0;
}
/* the parent does not call wait(): it leaves as soon as it prints */
printf("Chef %d: leaving without waiting for anyone\n", getpid());
return 0;
}
Real output of my single run:
Chef 589: leaving without waiting for anyone Cook 590: starting, my chef is 589 Cook 590: finishing, now my chef is 1
Two things happen here that are worth understanding well:
- The order of the first two lines is not fixed. After the
fork(), parent and child compete for the CPU, and the system scheduler decides who goes first. In my run the chef spoke first, but in yours the cook may come out first. If order matters for what you want to explain or compute, **usewait()**. - The child has become an orphan. At first its chef is 589. During the
sleep(1)the parent ends, and when the cook asks again, its parent is 1: the system has found it an adoptive parent, the process with PID 1 (init/systemd). On some desktops it is not 1 but the PID of another process acting as “adopter” (a subreaper), but the idea is the same.
If when you run this in your terminal you see the prompt ($) appear and then, a second later, the cook’s line, it is not a bug: the shell was only waiting for the parent. When the parent ends, the shell asks you for commands again even though the child is still alive.
Memory is copied, not shared
Back to the mystery of the hook. This program is exercise 6.2 of the lab reduced to the essentials: a global variable that holds 1, the parent adds 1 and the child adds 5. To top it off, each one prints the address of its variable:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int counter = 1; /* global variable, like in exercise 6.2 of the lab */
int main(void) {
pid_t pid = fork();
if (pid == 0) {
/* the child changes ITS copy of counter */
counter += 5;
printf("Child: counter = %d, at address %p\n", counter, (void *)&counter);
exit(0);
}
/* the parent waits for the child to finish and then changes ITS copy */
wait(NULL);
counter += 1;
printf("Parent: counter = %d, at address %p\n", counter, (void *)&counter);
return 0;
}
Real output:
Child: counter = 6, at address 0x55def59cd010 Parent: counter = 2, at address 0x55def59cd010
The first part is normal: the child sees 6 (1 + 5) and the parent sees 2 (1 + 1). If the variable were shared, the parent would have seen 7 (1 + 5 + 1). But the second part is the puzzling one: the address is the same, 0x55def59cd010, in both. How can there be two different values at the same address?
Because that address is virtual. Each process has its own address space, and the operating system translates each virtual address to a different place in physical memory for each one. It is as if parent and child had two identical houses with the same room numbers: “room 10” is the same label, but they are two different rooms.
The trace of the variable, step by step:
| Moment | Parent’s counter | Child’s counter |
|---|---|---|
Before fork() | 1 | (does not exist) |
Right after fork() | 1 | 1 (copy) |
The child does counter += 5 | 1 | 6 |
The child prints and calls exit(0) | 1 | (ends) |
The parent, after wait(), does counter += 1 | 2 | — |
And this explains the hook: each cook added 1 to its copy (that is why they all said “my counter is 1”) and the chef’s counter stayed at 0.
Copying all the memory on every fork() would be very slow, so Linux cheats elegantly: at first parent and child share the memory pages marked as “read-only”, and only when one of them writes is that page copied. It is called copy-on-write. For your program the effect is the same: each one only sees its own changes.
Using a global variable so that the children “return” results to the parent. It does not work. To send information back there are communication mechanisms: the simplest, the exit() exit code you will see shortly (and later on, files and pipes).
exec(): becoming another program
fork() creates a process, but it is a copy of the same program. What if I want to run another program, like ls or date? That is what the exec family is for. The important part, and the hardest at first: **exec does not create a new process**. It replaces the code of the calling process with the code of the new program. The PID stays the same; everything else changes.
To prove it, this program prints its PID and then turns into ps, which lists the process with that same PID:
#include <stdio.h>
#include <unistd.h>
int main(void) {
char pid_txt[16];
/* we store the PID as text to pass it to ps as an argument */
snprintf(pid_txt, sizeof pid_txt, "%d", getpid());
printf("Before exec: I am process %s\n", pid_txt);
/* the process becomes ps, which lists itself */
execlp("ps", "ps", "-o", "pid,comm", "-p", pid_txt, NULL);
/* we only get here if exec has failed */
perror("execlp");
return 1;
}
Real output:
Before exec: I am process 530 PID COMMAND 530 ps
Before exec, process 530 was our exec program; afterwards, process 530 is called ps. Same number, different program. And notice what does not appear: no trace of the perror("execlp"). When exec works, it never returns: the code that came after it no longer exists, it has been replaced. That is why the line after an exec only runs if it failed.
Why so many names (execl, execlp, execv, execvp…)? There is only one real system call (execve); the C library offers several functions that differ in how you pass the arguments. The letters tell you:
| Letter | Means | Example |
|---|---|---|
l (list) | Arguments go one by one, ending in NULL | execlp("date", "date", NULL) |
v (vector) | Arguments go in an array of char * ending in NULL | execvp("ls", args) with char *args[] = {"ls", "-l", NULL} |
p (PATH) | Looks for the program in the folders of the PATH variable | With p, "ls" is enough; without p you need "/bin/ls" |
Forgetting that the first argument of the list is the program’s name. In execlp("date", "date", NULL) the first "date" is the program to look for and the second is argv[0] for that program. If you write execlp("ls", "-l", NULL), ls gets -l as its name and no options.
Not ending the list or the array with NULL. exec does not know how many arguments there are: it keeps reading until it finds a NULL. Without it, it reads garbage from memory.
fork + exec: how your shell launches programs
If exec replaces the process, how does the shell run ls without disappearing itself? With the pair that explains everything: first **fork() (creates a child) and then exec in the child** (the child becomes ls). The parent, the shell, stays alive and waits. We wrap it in a launch function:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
/* launches a command in a child, waits for it and returns its exit code */
int launch(char *argv[]) {
pid_t pid = fork();
if (pid == 0) {
/* the child becomes the command; if exec returns, it has failed */
execvp(argv[0], argv);
perror(argv[0]);
exit(127); /* 127: the code the shell uses for "command not found" */
}
int status;
waitpid(pid, &status, 0);
return WEXITSTATUS(status);
}
int main(void) {
char *command1[] = {"uname", "-s", NULL};
char *command2[] = {"coffeemaker", "--turn-on", NULL};
int c1 = launch(command1);
printf("uname finished with code %d\n", c1);
int c2 = launch(command2);
printf("coffeemaker finished with code %d\n", c2);
printf("The chef is still alive: PID %d\n", getpid());
return 0;
}
Real output:
Linux uname finished with code 0 coffeemaker: No such file or directory coffeemaker finished with code 127 The chef is still alive: PID 536
Step by step:
Linuxis written byuname -s, which is what the first child turned into. It ends fine and its exit code is 0.coffeemakerdoes not exist in any folder of thePATH, soexecvpfails and returns. That is whyperror(argv[0])runs, printingcoffeemaker: No such file or directory, and the child ends withexit(127), the same code bash uses when it cannot find a command.- The chef is still alive at the end, with its PID: no
exectouched it, because theexeccalls happened in the children.
Let’s look at the key function under the magnifying glass:
int launch(char *argv[]) {
pid_t pid = fork();
if (pid == 0) {
/* the child becomes the command; if exec returns, it has failed */
execvp(argv[0], argv);
perror(argv[0]);
exit(127); /* 127: the code the shell uses for "command not found" */
}
int status;
waitpid(pid, &status, 0);
return WEXITSTATUS(status);
}
If the exit(127) were missing, a child whose exec fails would keep running the rest of launch as if it were the parent: it would do its own waitpid (with no children to wait for) and go back to main to launch more commands. You would have two “chefs” at once. That exit after the exec is mandatory.
The full life cycle of a child looks like this:
wait(), exit() and the exit code
exit(n) ends the process and leaves a number, the exit code, for the parent to read. By convention, 0 means “all good” and any other value means “something went wrong” (or a small piece of data, as we will see in the practice). wait(&status) does two things: it blocks the parent until one of its children ends and it fills the integer status with information about how it ended.
And here is the trap: status is not the exit code. It is an integer with several pieces of data packed inside. Look:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
/* interprets the integer filled in by wait() */
void report(int status) {
if (WIFEXITED(status)) {
printf(" ended with exit, exit code %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf(" ended by signal %d\n", WTERMSIG(status));
}
}
int main(void) {
int status;
/* child 1: ends with exit(7) */
if (fork() == 0) {
exit(7);
}
wait(&status);
printf("Child 1: raw status = %d\n", status);
report(status);
/* child 2: abort() sends it the SIGABRT signal (number 6) */
if (fork() == 0) {
abort();
}
wait(&status);
printf("Child 2: raw status = %d\n", status);
report(status);
return 0;
}
Real output:
Child 1: raw status = 1792 ended with exit, exit code 7 Child 2: raw status = 6 ended by signal 6
Child 1 called exit(7) and the raw status is 1792. Where does that come from? 7 × 256 = 1792. On Linux, the exit code is stored in the second byte of the integer (shifted 8 bits), and the low byte is reserved for other data, such as the signal that killed the process. That is why you use macros to read it:
| Macro | What it tells you | In the example |
|---|---|---|
WIFEXITED(status) | Did it end with exit or a return from main? | Child 1: yes |
WEXITSTATUS(status) | The code passed to exit (only if WIFEXITED) | 7 |
WIFSIGNALED(status) | Was it ended by a signal? | Child 2: yes |
WTERMSIG(status) | The number of that signal | 6 (SIGABRT) |
Child 2 called abort(), which sends it the SIGABRT signal. It did not end with exit, so WIFEXITED is false and we look at WTERMSIG: 6. The raw status is 6 because the signal goes in the low byte. (If your system saves core dumps, you may get 134 = 128 + 6: an extra bit meaning “there was a core dump” is set. The macros still give you 6: that is what they are for.)
Printing status directly and believing it is the exit code. If you see numbers like 256, 512 or 1792 where you expected 1, 2 or 7, you are missing WEXITSTATUS.
The exit code is only one byte: from 0 to 255. If you call exit(300), the parent gets 300 − 256 = 44. Sending bigger data needs other mechanisms.
When there are several children, wait() returns the PID of the first one to finish, whichever it is. If you want to wait for a specific one, use waitpid:
| Call | Waits for… | Returns |
|---|---|---|
wait(&status) | Any child (the first to finish) | Its PID, or -1 if no children are left |
waitpid(pid, &status, 0) | The child with that PID | That PID, or -1 on error |
waitpid(-1, &status, 0) | Any child (same as wait) | Its PID |
wait(NULL) | Any child, without keeping the status | Its PID |
And three ways of ending, which the lab tells apart:
| Way | What it does |
|---|---|
return n; in main | Equivalent to exit(n) |
exit(n) (<stdlib.h>, standard C) | Cleans up: flushes the printf buffers and closes files, then ends |
_exit(n) (<unistd.h>, POSIX) | Goes back to the kernel immediately, without flushing buffers |
Zombies: children nobody has collected
A child that ends cannot just vanish: it has to hand its exit code to someone. Until the parent calls wait, the system keeps it in a special state: zombie. It no longer runs anything or uses the CPU, but it still takes up an entry in the process table. We can see it with ps from the program itself:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t child = fork();
if (child == 0) {
/* the child ends right away */
exit(0);
}
/* the parent takes a second to call wait(): meanwhile, the child is a zombie */
sleep(1);
char command[64];
snprintf(command, sizeof command, "ps -o pid,ppid,stat,comm -p %d,%d", getpid(), child);
printf("Before wait:\n");
fflush(stdout); /* we flush the buffer before ps writes */
system(command);
wait(NULL); /* we collect the child: it stops being a zombie */
printf("After wait:\n");
fflush(stdout);
system(command);
return 0;
}
Real output:
Before wait: PID PPID STAT COMMAND 552 387 S zombie 553 552 Z zombie After wait: PID PPID STAT COMMAND 552 387 S zombie
Before wait, child 553 shows state Z (zombie) and its parent is 552. The parent is in S (sleeping: waiting, which is what it does while system runs). After wait, 553 no longer exists: the parent has collected it. The fflush(stdout) calls make sure “Before wait:” comes out before the output of ps even if you redirect the program to a file.
And what if the parent never calls wait? As long as the parent lives, the zombie stays. When the parent ends, its children become children of init, which collects them automatically. The real problem is programs that never end (a server that creates a child per client): if they do not collect their children, they pile up zombies.
When do I use each call?
| I want to… | I use | Remember |
|---|---|---|
| Know who I am and who my parent is | getpid(), getppid() | The numbers change on every run |
| Do two things at once with the same program | fork() | Tell parent and child apart with if (pid == 0) |
| Run another program without losing mine | fork() and exec in the child | After the exec, perror + exit in case it fails |
| Turn into another program | exec (without fork) | It does not return if it works |
| Wait for any child to finish | wait(&status) | Returns the PID of the one that finished |
| Wait for a specific child | waitpid(pid, &status, 0) | Handy for collecting in a fixed order |
| Return a small number to the parent | exit(n) + WEXITSTATUS | Only from 0 to 255 |
Common mistakes
The child does not end with exit() and keeps running the parent’s code. Inside a loop, that multiplies the processes (you will see it in the exercises).
Expecting parent and child to share variables. Each one has its own copy from the fork() on.
Putting important code after an exec that works. It never runs.
Reading status without WEXITSTATUS and getting multiples of 256.
Assuming the order of the output between parent and child without any wait().
Watch it step by step in Python Tutor
Python Tutor runs code line by line and draws variables, structs and pointers (choose C in the language drop-down). It runs your program as a single process, so fork() cannot be seen there. What we can do is simulate what fork() does to memory: a struct that represents the process and a copy of that struct that plays the child.
#include <stdio.h>
/* a process model: Python Tutor runs a single process, so we simulate fork() */
struct process {
int pid;
int ppid;
int counter;
};
int main(void) {
struct process parent = {100, 1, 1};
/* fork() makes a copy of the process: the child is born with the same data */
struct process child = parent;
child.pid = 101; /* ...except its PID */
child.ppid = parent.pid; /* ...and its parent */
/* from here on, each one changes its own copy */
child.counter += 5;
parent.counter += 1;
printf("parent: pid=%d counter=%d\n", parent.pid, parent.counter);
printf("child: pid=%d ppid=%d counter=%d\n", child.pid, child.ppid, child.counter);
return 0;
}
When you run it:
parent: pid=100 counter=2 child: pid=101 ppid=100 counter=6
What to watch step by step:
- When
parentis created, look at its three fields:pid100,ppid1 andcounter1. - On the line
struct process child = parent;a second struct appears with exactly the same values. It is not an arrow pointing toparent: it is a copy, just like the child’s memory afterfork(). - When
child.pidandchild.ppidchange, only the struct below changes. That is the only thing the system changes in the real child: its PID and its parent. - With
child.counter += 5andparent.counter += 1you will see 6 and 2, each in its own box. It is the same 6 and 2 of the memory program, and the same reason why the chef in the hook saw 0.
Summary
| Concept | In one sentence |
|---|---|
| Process | A running program, with its PID, its memory and its parent |
getpid() / getppid() | My PID / my parent’s PID |
fork() | Copies the process; returns 0 to the child, the child’s PID to the parent and -1 on failure |
Memory after fork() | Copied, not shared: each one only sees its own changes |
exec | Replaces the program of the process; same PID; does not return if it works |
fork + exec | What the shell does to launch every command |
exit(n) | Ends and leaves a code from 0 to 255 |
wait / waitpid | Wait for a child and fill status (read it with WEXITSTATUS) |
| Zombie | A finished child the parent has not collected with wait yet |
| Orphan | A child whose parent ended first; init adopts it |
Three ideas to take away
- **
fork()returns twice**, once in each process, and theif (pid == 0)decides what each one does. The child ends withexit()inside its branch. - Copying is not sharing. What the child changes, the parent does not see; to return a small value there is
exit(n)+WEXITSTATUS. - **
execreplaces, it does not create.** To run another program without disappearing:fork(),execin the child andwaitin the parent.
Now it is time to put it to work in the processes in C practice, where the chef launches several cooks at once, collects their times with exit and sets up a pass that runs real commands. And in the next topic we will take the missing step: the system calls for working with files (open, read, write and file descriptors).
Did this article help you? Here is how you can support SergioLearns →

2 Comments