Skip to content

The Anatomy of Process Creation: fork, exec, clone, and COW

A deep dive into how Linux creates processes. We will explore the mechanics of fork(), the magic of Copy-on-Write (COW), and how clone() powers modern containerization.

In the previous chapter of my Systems I/O series, we laid the groundwork for process abstraction. Now, we’re getting into the fun part: how Linux actually creates and manages processes. We’ll start with the classic fork() and exec() duo, unpack the cleverness of Copy-on-Write (COW), and finally build a mini-container using clone().

If you’ve ever wondered how Docker spins up a container so fast, you’re in the right place. Let’s dive in.

The Classic UNIX Way: fork() and exec()

UNIX-like operating systems separate the creation of a process from the loading of a new program. This is fundamentally different from Windows, which uses CreateProcess() to do both at once.

In Linux, process creation is a two-step dance:

  1. fork(): Creates an exact duplicate of the calling process (the parent). The duplicate is the “child”.
  2. exec(): (Technically the execve family) Replaces the child’s current memory space with a brand new program.

Why split them up?

It might seem inefficient at first glance. Why copy the entire parent if you’re just going to throw it away and load a new program immediately? The answer lies in state inheritance.

When a process calls fork(), the child inherits almost everything:

  • Open file descriptors (stdin, stdout, stderr, sockets, files)
  • Environment variables
  • Current working directory
  • Signal handlers

By splitting process creation and program execution, I can easily manipulate the child’s environment before exec() replaces the program. For example, if I want to redirect a program’s output to a file, I can fork(), change file descriptor 1 (stdout) in the child to point to my file, and then call exec().

The Magic of Copy-on-Write (COW)

You might be thinking: “Wait, if fork() copies the entire process, isn’t that insanely slow? What if a Node.js server using 2GB of RAM forks?”

Historically, yes, fork() used to blindly copy all physical memory. But modern operating systems are lazy—in a good way. They use a technique called Copy-on-Write (COW).

When you call fork(), the kernel does not duplicate the physical memory. Instead, it only duplicates the Page Tables. Page tables are data structures that map Virtual Memory Addresses to Physical Memory Frames.

Here is what happens during fork():

  1. The kernel copies the parent’s page tables to the child. (And since the page tables themselves are copied, this process isn’t O(1) — it takes time proportional to the address space size, which is why a 2GB Node process still takes measurable time to fork.)
  2. Both the parent and child page tables now point to the exact same physical memory frames.
  3. The kernel marks the shared frames read-only wherever the mapping is private and writable (text pages were already read-only, and MAP_SHARED mappings are deliberately not COW’d).

The Page Fault Trap

What happens if either the parent or the child tries to modify this shared memory? Because the memory is marked read-only in the page table, the CPU’s MMU (Memory Management Unit) triggers a hardware exception called a Page Fault.

The kernel catches this page fault, looks at what happened, and realizes, “Ah, this isn’t a segmentation fault, this is a COW violation!”

  1. The kernel allocates a brand new physical memory frame.
  2. It copies the contents of the shared frame into the new frame.
  3. It updates the page table of the process that tried to write, pointing it to the new frame and marking it Read/Write.
  4. It lets the process retry the write instruction. The process has no idea this just happened.

I built a small interactive lab below to visualize this. Try forking the process, and then write to a memory page from either the parent or the child to see COW in action.

Copy-on-Write (COW) Visualizer

Parent process running.

Parent Process (VM)

VP 0A→ PF 0
VP 1B→ PF 1
VP 2C→ PF 2
VP 3D→ PF 3

Physical Memory (RAM)

PF 0ARef: 1
PF 1BRef: 1
PF 2CRef: 1
PF 3DRef: 1

Child Process (VM)

Process not created yet

From fork() to clone()

While fork() is great, it’s very absolute. You either copy everything (subject to COW) or you don’t. But what if we want to share some things but not others? What if we want to share the memory space completely (like a thread)? Or what if we want to share nothing at all, not even the network stack or process ID tree?

Enter clone().

The clone() system call is the Swiss Army knife of Linux process creation. In fact, in modern Linux, the C library implementations of fork() and thread creation (pthread_create) issue clone or clone3 system calls, and inside the kernel, they all share a single implementation (kernel_clone()).

clone() takes a bitmask of flags that tell the kernel exactly what to share between the parent and child.

  • CLONE_VM: Share virtual memory (how threads are made).
  • CLONE_FILES: Share the open file descriptor table.
  • CLONE_NEWPID: Create a new PID namespace (the child becomes PID 1 in its new universe).
  • CLONE_NEWNET: Create a new Network namespace (isolated network stack).

Building a Mini-Container

Containers (like Docker or Podman) are not VMs. They are just regular Linux processes running with highly restrictive clone() flags (namespaces) and cgroups (resource limits).

Here is a C example of how we can use clone() to create an isolated “container” process with its own PID namespace.

clone_container.c c
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

// The function that the isolated process will run
int container_main(void *arg) {
  printf("Container - Inside the container, my PID is: %d
", getpid());
  fflush(stdout);

  // Note: Without CLONE_NEWNS, a new /proc mount would break the host.
  // Without a new /proc, 'ps' and 'top' inside here will still list host processes!

  // Spawn a shell inside our new namespace
  char *args[] = {"/bin/sh", NULL};
  execv(args[0], args);
  return 1;
}

int main() {
  printf("Host - Outside, my PID is: %d
", getpid());
  fflush(stdout);

  // Allocate stack for the child process using mmap
  // We use MAP_STACK and add a PROT_NONE guard page to catch stack overflows
  char *stack_alloc = mmap(NULL, STACK_SIZE + 4096, PROT_READ | PROT_WRITE,
                           MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
  if (stack_alloc == MAP_FAILED) {
      perror("mmap");
      exit(1);
  }
  if (mprotect(stack_alloc, 4096, PROT_NONE) == -1) {
      perror("mprotect");
      exit(EXIT_FAILURE);
  }
  char *child_stack = stack_alloc + 4096;

  // CLONE_NEWPID creates a new Process ID namespace
  // SIGCHLD tells the kernel to send a signal when the child dies
  int clone_flags = CLONE_NEWPID | SIGCHLD;

  // Clone!
  // Note: the stack grows downwards on most architectures,
  // so we pass a pointer to the top of the stack.
  pid_t child_pid = clone(container_main, child_stack + STACK_SIZE, clone_flags, NULL);

  if (child_pid == -1) {
      perror("clone failed");
      exit(1);
  }

  printf("Host - Successfully cloned. Child PID in host namespace is: %d
", child_pid);

  // Wait for the container to exit
  waitpid(child_pid, NULL, 0);
  printf("Host - Container exited.
");

  munmap(stack_alloc, STACK_SIZE + 4096);

  return 0;
}

If you compile and run this (this requires CAP_SYS_ADMIN in the current user namespace, which you can get by running as root or by adding CLONE_NEWUSER to the flags for unprivileged containers—exactly how rootless Podman works), you’ll see something fascinating: Inside container_main, getpid() will return 1. In its isolated view of the world, it is the init process! But back in the host, it’s just another process with a normal high PID.

Conclusion

Understanding process creation in Linux is crucial for systems programming. The elegant design of fork combined with the hardware-assisted magic of Copy-on-Write ensures efficiency. And as we push toward deeply isolated microservices, the granular control provided by clone() serves as the bedrock for modern containerization.

In the next chapter, we’ll cross the aisle to look at how Windows handles process creation and the fundamentally different design choices it made.

Coder Musings

A modern technical laboratory for systems programming. Master Assembly, Compilers, and Low-Level Engineering through curated paths and interactive visualizations.

© 2026 Coder Musings. All rights reserved. Built for the systems community.