RSS Amplifier

Pickles · Jun 24, 2026

How One Program Starts Another: fork(), exec(), and a 50-Year-Old Idiom

0
Sign in to vote or save

Pickles · Pickles

Every time you type a command and press Enter, your shell does something that sits at the very foundation of Unix — and is, when you first meet it, genuinely strange. To run ls, the shell doesn’t somehow “call” ls. It splits itself into two copies of itself, and then one of those copies turns into ls.

That two-step — fork() to make the copy, exec() to transform it — is how essentially every process on your machine came to exist. It’s elegant, it’s been with us since the 1970s, and it’s also a little wasteful and a little weird. Here’s what actually happens, why Unix is built this way, and what people are trying to replace it with.

fork(): the system call that returns twice

fork() creates a new process that is a near-exact copy of the one that called it — same code, same memory, same open files. The copy is the child; the original is the parent. (On Linux the underlying system call is actually clone(), but the classic behavior is the same.)

The strange part is the return value. A normal function returns once. fork() returns twice — once in each of the two processes that now exist — and it returns a different value in each, which is how a process figures out which one it is:

pid_t pid = fork();

if (pid == 0) {
    // We are the CHILD. fork() returned 0 to us.
    printf("I am the child\n");
} else if (pid > 0) {
    // We are the PARENT. fork() returned the child's PID.
    printf("I am the parent of child %d\n", pid);
} else {
    perror("fork");   // pid == -1: the fork failed
}

After fork(), two processes are running this exact code, at the exact same line. The only difference between them is what fork() handed back: 0 to the child, the child’s process ID to the parent. That single difference is how the same program takes two different paths.

exec(): become a different program

fork() only gives you a copy of yourself, which isn’t very useful on its own. The other half of the pair is exec() — on Linux, execve() — which does the transformation: it replaces the current process with a different program. Same process ID, same place in the process tree, but the memory, the code, everything, is thrown out and replaced by the new executable:

execlp("ls", "ls", "-l", NULL);
perror("execlp");   // ONLY runs if exec failed

Notice there’s no “after exec() succeeds” branch. On success, exec() never returns — there’s nothing for it to return to, because the program that called it no longer exists; it’s been overwritten by ls. The only reason execution continues past that line is if the exec failed (the file wasn’t found, you lacked permission). A successful exec() is a one-way door.

Putting them together: how your shell runs a command

Now the two halves click into place. To run an external command, a shell does exactly this:

pid_t pid = fork();          // 1. split into parent + child

if (pid == 0) {
    // 2. the child turns itself into the command
    execlp("ls", "ls", "-l", NULL);
    _exit(127);              //    only if exec failed
}

int status;
waitpid(pid, &status, 0);    // 3. the parent waits for it to finish

That’s it. That’s what bash does, in essence, every single time you run a non-built-in command: fork a child, have the child exec the program you asked for, and have the parent wait for it to finish before showing you the next prompt. Run strace -f on a shell and you’ll see this clone/execve/wait dance for every command. It’s not a metaphor — it’s the literal mechanism.

The gotcha: zombies and orphans

That waitpid() in the parent isn’t just politeness — it’s required, and forgetting it creates a classic Unix problem. When a child finishes, the kernel keeps a small record of it (its exit status) until the parent collects it with wait(). A finished child nobody has waited for is a zombie: it’s done running and owns no memory, but it lingers in the process table holding its exit code. Leak enough zombies and you exhaust the process table and can’t start anything new.

The mirror image is an orphan: a child whose parent exits first. Orphans don’t leak — the kernel reparents them to PID 1 (init/systemd), whose duties include reaping the orphans it inherits. So the rule is simple: if you fork(), you are responsible for wait()-ing on your children, or for arranging that something else will. A server that forks workers and never reaps them slowly fills with zombies until it can’t fork anymore.

Why it’s built as this odd two-step

A reasonable question: why split it into two calls? Why not one “run this program” call? The answer is the gap between them, and it’s the source of a huge amount of Unix’s power.

In the window after fork() but before exec(), the child is still running your code, with a clean copy of the parent’s world — and it can rearrange that world before it transforms into the new program. That’s where every familiar shell feature lives:

  • Redirection (ls > out.txt): the child reopens its standard output onto the file, then execs ls. ls knows nothing about files; it just writes to standard output, which the child quietly pointed at out.txt first.
  • Pipes (ls | grep foo): two children, with the first’s output wired to the second’s input via a pipe, each set up before its exec.
  • Dropping privileges, changing directory, closing file descriptors: all done by the child in that gap, so the new program starts in exactly the environment you want.

One call can’t do this, because there’d be no “you” left running between making the process and loading the program. The two-step exists precisely so that your code gets a moment to shape the child before it becomes something else. That is the elegance people mean when they praise the model.

The cost: copying everything just to throw it away

The elegance has a price, and it’s the reason this story isn’t finished. fork() has to produce a copy of the entire process — its memory, its page tables, its open files. That’s inherently expensive, and it gets worse: a fork() is very often immediately followed by an exec(), which discards all of that carefully copied memory and replaces it with the new program. You paid to clone a whole process and then threw the clone’s body away a microsecond later.

Modern kernels soften this with copy-on-write (COW). Right after fork(), parent and child don’t really get separate copies of memory — they share the same physical pages, marked read-only, and a page is only actually copied when one of them writes to it. Since a child that’s about to exec() barely writes anything before being replaced, COW makes the common case much cheaper than a literal full copy.

But COW isn’t free. The kernel still has to copy the parent’s page tables, which scales with how much memory the parent has mapped. A process using many gigabytes of RAM can be slow to fork() even though the child immediately execs — and worse, the fork can fail on a large process if the system’s memory accounting decides it can’t promise enough memory for the (mostly illusory) copy. “Just run this little helper program” shouldn’t require duplicating a huge process’s bookkeeping, but with fork() it does.

Don’t fork() a multithreaded program

Here’s a danger that bites people in real services. When a multithreaded process calls fork(), the child gets a copy of the memory — but only one thread: the one that called fork(). Every other thread simply doesn’t exist in the child.

That sounds harmless until you remember those vanished threads might have been holding locks. A mutex another thread had locked at the moment of the fork is now locked forever in the child, with no thread left to release it — so the first time the child touches that lock, it deadlocks. Worse, the locked thing might be deep inside the C library: the memory allocator has its own lock, so even calling malloc() in the child can hang.

The only thing you can safely do in the child of a multithreaded fork() is call exec() quickly, which replaces everything — locks and all — with the new program. This is a major reason to prefer posix_spawn() in threaded code: it’s built for exactly this situation.

The optimizations: vfork() and posix_spawn()

Because the fork-then-exec waste is so well known, alternatives have existed for decades.

vfork() is the old, sharp-edged optimization. It creates a child without copying the address space at all — the child borrows the parent’s memory directly, and the parent is suspended until the child either execs or exits. It’s fast, but it’s a footgun: the child is running in the parent’s memory, so almost anything it does beyond immediately calling exec() is undefined behavior. Useful in narrow cases, dangerous in general, and best avoided unless you know exactly why you need it.

posix_spawn() is the modern, recommended answer. It’s a single library call that bundles “make a process and run this program in it,” with structured ways to express the setup you’d otherwise do by hand between fork and exec — redirecting descriptors, closing files, setting attributes:

#include <spawn.h>
extern char **environ;

pid_t pid;
char *argv[] = { "ls", "-l", NULL };
posix_spawnp(&pid, "ls", NULL, NULL, argv, environ);
waitpid(pid, NULL, 0);

If your goal is simply “launch this program with these arguments and this environment,” reach for posix_spawn() rather than hand-rolling fork()/exec(). It expresses intent more clearly, and on systems where it’s implemented well it can sidestep the full fork cost entirely.

The spawn options at a glance

CallWhat it doesWhen to use it
fork() + exec()clone the process, then replace the child with a programwhen you need to configure the child (redirect, drop privs) between the two
vfork() + exec()like fork but shares the parent’s memory; parent suspendedalmost never — only if you know exactly why
posix_spawn()one call: make a process and run a program, with file actions“just launch this program” — the sensible default
raw clone()the Linux primitive under fork and threads, with fine-grained flagsbuilding threads or containers, not everyday process launching

What’s next: a cleaner primitive

The model is old enough that kernel developers are actively trying to do better, and the discussion is worth knowing about even if you’ll never touch the internals. As LWN’s Jonathan Corbet recently reported, one proposal aimed to cache setup work in a reusable “template” for programs that launch the same executable over and over — but reviewers pushed back that it optimized the wrong half, leaving the expensive fork() copy in place.

The direction that’s emerging instead is more ambitious: rather than cloning the current process and then overwriting it, build a new process from nothing — “creating a pristine process is the way to go,” as one kernel developer put it. The current sketch is to start an empty process and configure it through a series of calls (building on Linux’s pidfd abstraction), which would, among other things, let Linux finally offer a proper native posix_spawn() that isn’t secretly doing fork() and exec() underneath. Nothing is settled, but the goal is clear: keep the flexibility of the two-step model without paying to copy a process you’re about to discard.

The takeaway, in one line

Running a program is split-then-transform: fork() clones the process, exec() replaces the clone — and the gap between them is exactly where redirection and pipes get set up, while the cost of copying a process you’re about to discard is exactly why posix_spawn() exists.

Read the original on pickles.news

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.