콘텐츠로 이동

Understanding fork() System Call and Memory Management in Unix/Linux

When a Unix/Linux process creates a new process using the fork() system call, a complex but elegant memory management mechanism takes place. This explanation will demonstrate how parent and child processes interact with memory during and after a fork() operation.

Scope, assumptions, and completion criteria

  • Scope: This guide describes POSIX fork() with a Linux-style copy-on-write implementation. It does not describe Windows process creation, every Unix implementation, or all process attributes.
  • Assumptions: The diagrams show private anonymous pages in a single-threaded process. Shared mappings, open file descriptions, pending signals, locks, threads, and device state follow separate rules.
  • Evidence and environment: Confirm behavior against the target OS/kernel, libc, fork(2), and trace output. Equal pointer values are virtual addresses and do not prove equal physical pages.
  • Failure/retry: On fork() == -1, preserve errno and do not enter parent/child logic. Retry only selected transient resource failures with a bound; uncontrolled retry can worsen PID or memory pressure.
  • Completion evidence: The parent must account for or intentionally detach the child, collect its status where applicable, and record both exit paths. In a multithreaded child, call only async-signal-safe operations until exec() unless an explicitly supported mechanism is used.

The fork() System Call Explained

The fork() system call creates a child with a new process identity and a logically copied execution context. Many attributes are inherited or shared according to POSIX rather than being an exact duplicate. After a successful call, parent and child continue from the next instruction with different return values:

  • In the parent process: fork() returns the PID of the newly created child
  • In the child process: fork() returns 0
  • On failure: the caller receives -1, errno explains the failure, and no child is created

A key characteristic of fork() is that the child process does not start execution from the beginning of the program. Instead, both processes continue execution from the point immediately after the fork() call.

Memory Mechanism During fork()

The memory handling during fork() is what makes this system call particularly interesting. Let's visualize this process:

Before fork()

Parent Process Memory Space
+---------------------------+
|      Program Code         |
+---------------------------+
|      Global Variables     |
+---------------------------+
|         Heap              |
|                           |
+---------------------------+
|           |               |
|   Stack   | (grows down)  |
|           v               |
+---------------------------+
|    PC → at fork() call    |
+---------------------------+

The fork() Call

When fork() is called, the operating system creates a new process entry in its process table and duplicates the parent's memory space:

                fork()
+------------------+-------------------+
|                  |                   |
| Parent Process   |   Child Process   |
|                  |                   |
+------------------+-------------------+

After fork() - Logical View

After fork() completes, both processes have their own memory spaces with identical content:

Parent Process Memory Space      Child Process Memory Space
+---------------------------+    +---------------------------+
|      Program Code         |    |      Program Code         |
+---------------------------+    +---------------------------+
|      Global Variables     |    |      Global Variables     |
+---------------------------+    +---------------------------+
|         Heap              |    |         Heap              |
|                           |    |                           |
+---------------------------+    +---------------------------+
|           |               |    |           |               |
|   Stack   | (grows down)  |    |   Stack   | (grows down)  |
|           v               |    |           v               |
+---------------------------+    +---------------------------+
| PC → after fork() call    |    | PC → after fork() call    |
| fork() returns child PID  |    | fork() returns 0          |
+---------------------------+    +---------------------------+

Actual Memory Implementation (Copy-on-Write)

Linux and many Unix-like systems normally avoid eagerly copying all private writable pages. They establish copy-on-write mappings, while page tables and other kernel metadata still incur work and shared mappings remain shared:

                    Physical Memory Pages
                    +------------------+
                    |    Page 1        |
                    +------------------+
                    |    Page 2        |
                    +------------------+
                    |    Page 3        |
                    +------------------+
                    |    Page 4        |
                    +------------------+
                           ▲   ▲
                           │   │
                           │   │
                           │   │
                       ┌───┘   └───┐
                       │           │
                       │           │
    Parent Process     │           │     Child Process
    Page Table         │           │     Page Table
+------------------+   │           │   +------------------+
| Virtual → Physical|  │           │   | Virtual → Physical|
+------------------+   │           │   +------------------+
| Page 1 → Page 1   |──┘           └──| Page 1 → Page 1   |
+------------------+                  +------------------+
| Page 2 → Page 2   |─────────────────| Page 2 → Page 2   |
+------------------+                  +------------------+
| Page 3 → Page 3   |─────────────────| Page 3 → Page 3   |
+------------------+                  +------------------+
| Page 4 → Page 4   |─────────────────| Page 4 → Page 4   |
+------------------+                  +------------------+

When either process modifies a memory page, only then is a copy made:

                    Physical Memory Pages
                    +------------------+
                    |    Page 1        |
                    +------------------+
                    |    Page 2        |        +------------------+
                    +------------------+        |    Page 2'       | (Copy created)
                    |    Page 3        |        +------------------+
                    +------------------+
                    |    Page 4        |
                    +------------------+
                           ▲   ▲             ▲
                           │   │             │
                           │   │             │
                           │   │             │
                       ┌───┘   └───┐         │
                       │           │         │
                       │           │         │
    Parent Process     │           │     Child Process
    Page Table         │           │     Page Table
+------------------+   │           │   +------------------+
| Virtual → Physical|  │           │   | Virtual → Physical|
+------------------+   │           │   +------------------+
| Page 1 → Page 1   |──┘           └──| Page 1 → Page 1   |
+------------------+                  +------------------+
| Page 2 → Page 2   |─────────────────| Page 2 → Page 2'  |──┘
+------------------+                  +------------------+
| Page 3 → Page 3   |─────────────────| Page 3 → Page 3   |
+------------------+                  +------------------+
| Page 4 → Page 4   |─────────────────| Page 4 → Page 4   |
+------------------+                  +------------------+

Practical Example

To illustrate this further, consider this simple code example:

#include <stdio.h>
#include <unistd.h>

int main() {
    int x = 5;  // Variable in memory

    printf("Before fork: x = %d (address: %p)\n", x, (void *)&x);

    pid_t pid = fork();

    if (pid < 0) {
        // Fork failed
        fprintf(stderr, "Fork failed\n");
        return 1;
    } else if (pid == 0) {
        // Child process
        printf("Child: x = %d (address: %p)\n", x, (void *)&x);
        x = 10;  // Child modifies x
        printf("Child after change: x = %d (address: %p)\n", x, (void *)&x);
    } else {
        // Parent process
        printf("Parent: x = %d (address: %p)\n", x, (void *)&x);
        x = 20;  // Parent modifies x
        printf("Parent after change: x = %d (address: %p)\n", x, (void *)&x);
    }

    return 0;
}

In this example, parent and child initially see x == 5, and the pointer values normally look the same because each process has a separate virtual address space. Private pages may still reference the same physical page until one process writes; after a COW fault, the writer receives a private copy. Output order is nondeterministic, and the sample does not call waitpid(), so it is a memory illustration rather than complete child-lifecycle management.

Key Insights About fork() and Memory

  1. A successful fork() creates a distinct child while inheriting and sharing attributes according to POSIX
  2. Parent and child continue from the point after fork(), but scheduling order is unspecified
  3. Linux commonly uses COW for private writable pages; page-table copying and later write faults still cost time and memory
  4. Isolation applies to private address-space state, while explicitly shared mappings and open file descriptions can remain shared

This strategy can make fork()+exec efficient, but cost grows with address-space metadata, active threads, memory pressure and pages dirtied before exec(). Measure the target workload rather than assuming negligible overhead.


References

Books

  • Windows via C/C++ - Jeffrey Richter, Christophe Nasarre (Microsoft Press, 2011)
  • Modern Operating Systems - Herbert Bos, Andrew S. Tanenbaum
  • The Elements of Computing Systems - Noam Nisan, Shimon Schocken (MIT Press, 2021)
  • The Linux Programming Interface - Michael Kerrisk (No Starch Press, 2018)
  • RISC-V Reader - An open architecture standard

Online Resources