R0KernelPath
module 075 / 262

Process Management: task_struct, fork, exec

Dissect the task_struct — the kernel's complete dossier on every running thing — and trace how fork, exec, and exit create, transform, and destroy processes at the source-code level.

~18h focused study10 concepts

Overview

Every running program you have ever used — your browser, your shell, your text editor — is a process. But what is a process, at the level the kernel actually thinks about it? The answer, in the Linux kernel, is a single C struct called task_struct. If you can read that struct and understand its fields, you understand how Linux thinks about everything that runs on the machine.

This module dissects task_struct from the inside out: what it contains, how it is born (via fork and clone), how it transforms into a new program (via exec), how threads share the same address space while remaining distinct schedulable entities, and how a process finally dies via do_exit. Along the way we will see PID namespaces — the building block of containers — and kernel threads, which are the OS's own workers living entirely in kernel space.

The beautiful, slightly mind-bending truth you will internalize here is that in Linux there is no hard distinction between a "process" and a "thread" at the kernel level. Both are task_struct instances. The difference is what they share: threads in the same process share an mm_struct (the virtual address space), file descriptor table, and signal handlers. fork and clone are the same underlying system call (clone) — fork just uses a particular set of flags. Understanding this unification is the key insight that unlocks everything else in this module.

By the end you will be able to read the kernel source, trace a fork() call from userspace through the kernel and back, explain why copy-on-write makes fork fast, and know exactly what happens in the kernel when a process calls exit(1) or receives SIGKILL.

why this matters for the kernel

Process management is the beating heart of the kernel. Every other subsystem — the scheduler, the memory manager, the VFS, the network stack — interacts with tasks through task_struct. If you want to contribute a scheduler improvement, you need to understand how the kernel finds and tracks runnable tasks. If you want to write a filesystem, you need to know how file descriptor tables are attached to processes. If you want to understand containers (Docker, Kubernetes), you need to know PID namespaces — which live inside task_struct. You cannot go deep in any kernel subsystem without this foundation.

More concretely: process management is one of the most approachable areas for first-time kernel contributors. The task_struct, fork, and exit paths are heavily commented, relatively self-contained, and have clear, testable behavior. Writing a kernel module that walks the process tree (for_each_process), or tracing a fork with ftrace or bpftrace, are realistic, safe experiments you can do today on any Linux machine. The skills you build here — reading kernel data structures, following pointer chains, understanding reference counting — transfer directly to every other subsystem you will ever touch.

fork() + exec() kernel path through task_struct

rendering diagram…

The fork() path (left) calls copy_process() which duplicates or shares each resource group depending on clone flags, then wakes the new task. The child can then call execve() (right) which destroys the old address space, loads the ELF binary into a fresh mm_struct, and returns to userspace at the new program's entry point — all with the same PID throughout.

The concepts

01task_struct: The Process Descriptor

The kernel represents every schedulable entity — process, thread, kernel thread — as a task_struct (defined in include/linux/sched.h). This struct is enormous (~700 fields as of Linux 6.x), but most fields cluster into logical groups. Think of it as the kernel's "dossier" on a running entity: identity, state, CPU context, memory map, open files, signal handlers, cgroup membership, and much more.

Key field groups:

  • State: __state (TASK_RUNNING, TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE, TASK_ZOMBIE, TASK_STOPPED, etc.) — the scheduler reads this to decide whether to run the task.
  • Identity: pid_t pid (the kernel-internal thread ID), pid_t tgid (thread group ID — what userspace calls the PID), struct pid *thread_pid, uid_t, gid_t, credentials via struct cred *cred.
  • Memory: struct mm_struct *mm — pointer to the virtual address space. Kernel threads have mm == NULL (they run in kernel address space directly).
  • Files: struct files_struct *files — file descriptor table. Multiple threads in a process share the same pointer.
  • Signals: struct signal_struct *signal, struct sighand_struct *sighand.
  • Scheduling: int prio, struct sched_entity se (embedded CFS entity), struct sched_rt_entity rt, struct sched_dl_entity dl.
  • Parent/child relationships: struct task_struct *real_parent, *parent, struct list_head children, struct list_head sibling.
  • CPU context: On x86-64, struct thread_struct thread stores the saved register state (RSP, etc.) for context switches.

Every task_struct is allocated by the slab allocator via dup_task_struct(). The kernel stores a pointer to the current task in a per-CPU variable; on x86-64, current is typically accessed via this_cpu_read_stable(current_task) — effectively the value stored in gs segment register during kernel execution.

/* Simplified excerpt — real kernel struct is ~700 fields */
struct task_struct {
    unsigned int        __state;      /* TASK_RUNNING, TASK_INTERRUPTIBLE, ... */
    void               *stack;        /* kernel stack pointer */
    struct mm_struct   *mm;           /* virtual address space (NULL for kthreads) */
    struct mm_struct   *active_mm;    /* mm used when mm==NULL (borrowed) */
    pid_t               pid;          /* kernel thread ID */
    pid_t               tgid;         /* thread group ID == userspace PID */
    struct task_struct *real_parent;  /* biological parent */
    struct task_struct *parent;       /* ptrace parent, or real_parent */
    struct list_head    children;     /* list of child tasks */
    struct list_head    sibling;      /* linkage in parent->children */
    struct files_struct *files;       /* open file descriptor table */
    struct signal_struct *signal;     /* shared signal state for thread group */
    struct sighand_struct *sighand;   /* signal handlers */
    const struct cred  *cred;         /* effective credentials */
    char                comm[TASK_COMM_LEN]; /* short name, e.g. "bash" */
    struct sched_entity se;           /* CFS scheduler entity (embedded!) */
    /* ... hundreds more fields ... */
};

/* Walking all tasks (in a kernel module or bpftrace) */
struct task_struct *task;
rcu_read_lock();
for_each_process(task) {
    pr_info("pid=%d tgid=%d comm=%s\n",
            task->pid, task->tgid, task->comm);
}
rcu_read_unlock();

02The Process Tree and Task Lists

Linux organizes all tasks into two overlapping data structures:

  1. The process tree: Every task has a real_parent. The root is init (PID 1, or swapper/0 which is PID 0 for the idle task). This forms a true tree. children and sibling are list_head fields implementing a doubly-linked circular list — a fundamental kernel data structure.

  2. The task hash table / PID hash: For fast pid → task_struct lookups, the kernel maintains struct pid objects (in include/linux/pid.h) that hash-chain task structs. find_task_by_vpid(pid) does this lookup in the current PID namespace.

init_task: The very first task (init_task) is a statically allocated task_struct defined in init/init_task.c. All other tasks descend from it. When you run pstree in a shell, you are viewing this tree from userspace.

PID 0 (swapper): Each CPU has an idle task (PID 0). When no other task is runnable, the CPU runs the idle task, which executes the cpu_idle_loop() and eventually calls an architecture-specific halt instruction (hlt on x86) to save power.

thread_group list: Threads within the same process are linked via task_struct.thread_group (a list_head). The thread group leader is the task whose pid == tgid. You can iterate all threads in a process with while_each_thread(leader, t).

The kernel also maintains a global tasks doubly-linked list through all task_struct instances (via the tasks field), enabling for_each_process() to iterate every process leader.

One subtle but important point: when a process forks, the child's real_parent is set to the calling task. If the parent exits before the child, the child is reparented — either to a subreaper (set via prctl(PR_SET_CHILD_SUBREAPER, ...)) or to PID 1. This is why orphan processes always end up adopted by init.

/* Demonstrating the process tree from userspace */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
    printf("My PID:    %d\n", getpid());
    printf("My PPID:   %d\n", getppid());
    /* On Linux, getpid() returns tgid, getppid() returns parent's tgid */
    /* All threads in this process share the same getpid() value */
    return 0;
}

/* Shell: view the tree */
/* pstree -p           -- shows full tree with PIDs */
/* cat /proc/self/status | grep -E '^(Pid|PPid|Tgid)' */

03fork(): Creating a New Process

fork() is the only way a new process is born in Unix (except for the very first process, which the kernel conjures during boot). When you call fork(), the kernel:

  1. Allocates a new task_struct via dup_task_struct(), which also allocates a new kernel stack.
  2. Copies or shares every resource group (address space, files, signals, etc.) depending on the flags passed to the underlying clone() syscall.
  3. Sets up the child's return value: the parent returns the child's PID, the child returns 0. This is done by writing 0 into the child's saved rax register in its thread_struct before the child ever runs.
  4. Marks the address space copy-on-write (COW): instead of copying all memory pages immediately, the kernel marks all writable pages in both parent and child as read-only. On first write, a page fault fires, the kernel copies just that one page (the "copy-on-write" mechanism), and makes it writable only for the faulting task. This makes fork() very fast — you pay only for the pages you actually modify.
  5. Adds the child to the scheduler run queue.

The kernel path: fork() → sys_clone() → kernel_clone() → copy_process() → wake_up_new_task(). The function copy_process() (in kernel/fork.c) is the heart of fork — it calls dup_task_struct, then a series of copy_*() functions: copy_mm(), copy_files(), copy_sighand(), copy_signal(), copy_namespaces(), etc. Each either copies the resource or increments a reference count and shares it, depending on the clone flags.

Why fork + exec?: The Unix design separates creating a process from loading a new program. After fork(), you are still running the same program in the child. Then exec() replaces the program image. This separation is elegant: between fork and exec, you can set up file descriptors (pipes!), change credentials, set resource limits — and only then exec the target program. Shells exploit this constantly.

vfork(): A historical optimization where the child borrows the parent's address space (without COW) and the parent is suspended until the child calls exec or exit. Rarely needed today since COW fork is so cheap.

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        /* ---- CHILD ---- */
        printf("Child:  PID=%d PPID=%d\n", getpid(), getppid());
        return 42;   /* exit status */
    } else {
        /* ---- PARENT ---- */
        int status;
        printf("Parent: PID=%d child=%d\n", getpid(), pid);
        waitpid(pid, &status, 0);
        printf("Parent: child exited with %d\n", WEXITSTATUS(status));
    }
    return 0;
}
/* Kernel path reminder:
 *   fork() -> syscall -> kernel_clone(SIGCHLD, 0, 0, NULL, NULL, 0)
 *          -> copy_process() -> wake_up_new_task()
 */

04clone() and Threads: What fork() Really Is

At the kernel level, there is one system call that creates new tasks: clone() (and its more modern variants clone3() on newer kernels). fork() is just clone() called with the flag SIGCHLD and nothing else shared. pthread_create() is clone() called with a rich set of sharing flags.

The sharing flags (defined in include/uapi/linux/sched.h) control exactly what the child inherits vs. shares:

FlagMeaning
CLONE_VMShare virtual address space (mm_struct) — makes it a thread
CLONE_FILESShare file descriptor table
CLONE_FSShare filesystem root/cwd
CLONE_SIGHANDShare signal handlers
CLONE_THREADPlace in same thread group (same tgid)
CLONE_NEWPIDNew PID namespace (container isolation)
CLONE_NEWNETNew network namespace
CLONE_NEWUSERNew user namespace
CLONE_PARENT_SETTIDWrite child TID to parent's memory
CLONE_CHILD_CLEARTIDClear child TID on exit (used for pthread_join futex)

When CLONE_VM is set, copy_mm() does NOT copy the page tables — it just increments mm_struct->mm_users and shares the pointer. Both tasks have task->mm pointing to the same mm_struct. This is what makes threads fast to create and makes memory sharing implicit.

When CLONE_THREAD is set, the new task gets the same tgid as the creator, so from userspace they appear as one PID with multiple threads (visible as /proc/<pid>/task/<tid>).

POSIX threads: pthread_create → clone(CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD). The CLONE_CHILD_CLEARTID flag is how pthread_join works: when the thread exits, the kernel writes 0 to a futex word in the thread's stack, waking the joining thread.

This unified model has a deep implication: the scheduler does not know about processes, only tasks. It picks the highest-priority runnable task_struct regardless of whether it is a thread, process, or kernel thread. Fairness is enforced at the task level, not the process level (though cgroups can impose process-level policies on top).

/* What glibc's pthread_create does under the hood */
/* Roughly equivalent to: */
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>

#define STACK_SIZE (1024 * 1024)

static int thread_fn(void *arg) {
    printf("Thread TID=%d PID=%d\n", gettid(), getpid());
    /* gettid() returns task->pid (kernel thread ID) */
    /* getpid() returns task->tgid (shared thread-group ID) */
    return 0;
}

int main(void) {
    char *stack = malloc(STACK_SIZE);
    /* clone with VM+FILES+FS+SIGHAND+THREAD sharing */
    int flags = CLONE_VM | CLONE_FILES | CLONE_FS |
                CLONE_SIGHAND | CLONE_THREAD | SIGCHLD;
    clone(thread_fn, stack + STACK_SIZE, flags, NULL);
    /* In practice always use pthread_create — this is illustrative */
    printf("Parent getpid=%d\n", getpid());
    return 0;
}

05exec(): Replacing the Process Image

exec() (the execve system call) replaces the current process image with a new program. The PID does not change; the task_struct is reused. What changes is everything that defines "what program is running": the address space, the program counter, the stack, open file descriptors marked close-on-exec, signal handlers.

The kernel path for execve(path, argv, envp):

  1. do_execve() (in fs/exec.c) opens the file and calls exec_binprm().
  2. Binary format detection: The kernel iterates registered linux_binfmt handlers. For ELF files, fs/binfmt_elf.c handles loading. For #! scripts, fs/binfmt_script.c handles them. For java -jar support there's even a binfmt_misc module.
  3. load_elf_binary():
    • Reads ELF headers to find program headers (PT_LOAD segments).
    • Calls exec_mmap() which destroys the old address space (all old VMAs freed) and creates a fresh mm_struct.
    • Maps ELF segments into the new address space (text, data, BSS).
    • If the binary is dynamically linked, maps the ELF interpreter (ld.so) and sets the entry point to it.
    • Sets up the initial stack with argv, envp, and the ELF auxiliary vector (AT_PHDR, AT_ENTRY, AT_RANDOM, etc.).
  4. start_thread(): Sets the saved instruction pointer in the task's thread_struct to the ELF entry point (or interpreter entry point). When the syscall returns to userspace, execution begins there.

File descriptors and exec: FDs with FD_CLOEXEC set (or opened with O_CLOEXEC) are closed during exec. This is how shells ensure that the pipe ends they don't need are not leaked into child processes.

Signal handlers: All signal handlers are reset to their defaults after exec (since the old handler code no longer exists). Blocked signals are preserved; pending signals are cleared.

The curious case of execve + CLONE_VM: If a thread in a multi-threaded process calls exec, the kernel must kill all the other threads first (they would be accessing memory that is about to be destroyed). This is done by sending SIGKILL to all other threads in the thread group before the address space is torn down.

#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        /* Child: replace image with /bin/ls */
        char *argv[] = {"ls", "-la", "/proc/self", NULL};
        char *envp[] = {"HOME=/tmp", NULL};
        execve("/bin/ls", argv, envp);
        /* execve only returns on error */
        perror("execve");
        _exit(1);
    }
    /* Parent waits */
    int status;
    waitpid(pid, &status, 0);
    return 0;
}
/* Note: after execve() succeeds, the code below execve() in the child
 * is GONE — the entire text segment was replaced. This is why execve
 * "doesn't return" on success. */

06Copy-on-Write (COW) Memory After fork()

Copy-on-write is one of the most elegant optimizations in the kernel, and understanding it requires touching both process management and virtual memory.

When fork() calls copy_mm(), instead of physically duplicating all memory pages (which could be gigabytes!), the kernel:

  1. Duplicates the page tables (the tree of PTEs — Page Table Entries) for the child. Both parent and child now have page tables pointing to the same physical pages.
  2. Marks all writable PTEs as read-only in both parent and child. The writable bit in the PTE is cleared; a new software bit (_PAGE_COW) is set to remember that it should be writable.
  3. Increments the reference count (_mapcount) on each shared physical page.

Now when either parent or child tries to write to a COW page:

  • The CPU tries to write to a read-only page → page fault (protection fault, error code bit 1 set).
  • The kernel's page fault handler (handle_pte_fault() → do_cow_page()) recognizes the COW pattern.
  • If the page's reference count is 1 (only we map it), just make the PTE writable again — no copy needed!
  • If the reference count is > 1, allocate a new physical page, copy the content, update our PTE to point to the new page, decrement the old page's refcount.
  • Resume the faulting instruction — it now writes successfully.

Why this matters for performance: A typical shell like bash might have 50 MB of data. fork() takes microseconds because only the page table structures are copied, not the pages. If the child immediately calls exec(), the old pages are discarded and the cost of copying them was avoided entirely. This is the "fork-exec" fast path that every shell exploits.

The mmap analogy: COW is also used for mmap(MAP_PRIVATE) of files — multiple processes can map the same file pages, all read-only, and only diverge on first write. And memfd_create + userfaultfd are modern mechanisms built on the same foundation.

/* Demonstrating COW: parent and child share memory until write */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    int *data = malloc(4096 * 1000); /* ~4 MB */
    for (int i = 0; i < 4096 * 1000 / sizeof(int); i++)
        data[i] = i;

    printf("Before fork: data[0]=%d @ %p\n", data[0], (void*)data);

    pid_t pid = fork();
    if (pid == 0) {
        /* Child: read first — no page fault for COW */
        printf("Child reads data[0]=%d (shared page, no copy)\n", data[0]);
        /* Child: write — triggers COW page fault */
        data[0] = 9999;
        printf("Child wrote data[0]=%d (now on private copy)\n", data[0]);
        exit(0);
    }
    waitpid(pid, NULL, 0);
    /* Parent still sees original value — different physical page now */
    printf("Parent: data[0]=%d (original, unmodified)\n", data[0]);
    free(data);
    return 0;
}
/* On Linux, check /proc/<pid>/status 'VmRSS' before/after write
 * to see RSS grow as COW pages are faulted in. */

07PID Namespaces: The Foundation of Containers

A PID namespace is a virtualization layer that gives a group of processes their own view of PIDs. Each PID namespace has its own counter starting at 1. The process with PID 1 inside a namespace is the "init" of that namespace — if it dies, all other processes in the namespace are killed.

The key insight: A process has one PID per namespace it is visible in. A process created inside a nested PID namespace has:

  • PID 1 inside its own namespace.
  • Some larger PID in the parent namespace.
  • Yet another PID in the root namespace.

These multiple PIDs are stored in the struct pid object (in include/linux/pid.h), which contains an array of upid structures, one per namespace level.

struct upid {
    int nr;                  /* PID number in this namespace */
    struct pid_namespace *ns; /* which namespace */
};

struct pid {
    refcount_t count;
    unsigned int level;      /* number of namespace levels */
    spinlock_t lock;
    struct hlist_head tasks[PIDTYPE_MAX]; /* tasks with this PID */
    struct upid numbers[1];  /* variable length, one per level */
};

Creating a PID namespace: Pass CLONE_NEWPID to clone(). The first task in the new namespace gets PID 1 there. This is exactly what unshare(1), lxc-start, and Docker's container runtime do.

/proc and namespaces: /proc is namespace-aware. When mounted inside a PID namespace, it only shows processes visible in that namespace. Docker mounts a fresh /proc inside each container for exactly this reason.

Practical example: Run unshare --fork --pid --mount-proc /bin/bash in a shell. You now have a shell with PID 1 that cannot see any outside processes. This is the core of container isolation, achieved with a handful of clone flags.

Other namespaces (CLONE_NEWNET, CLONE_NEWUTS, CLONE_NEWNS, CLONE_NEWIPC, CLONE_NEWUSER, CLONE_NEWCGROUP, CLONE_NEWTIME) follow the same pattern — each isolates a different global resource.

/* Create a new PID namespace and run a shell inside it */
/* Run as root (or with CAP_SYS_ADMIN): */

/* Shell demonstration (no C needed): */
/*
   $ sudo unshare --fork --pid --mount-proc /bin/bash
   # Now inside new namespace:
   bash# echo $$
   1                  <-- we are PID 1!
   bash# ps aux
   USER  PID  ...  COMMAND
   root    1  ...  /bin/bash   <-- only our process tree visible
   bash# exit
   # Back in original namespace
   $
*/

/* C: minimal namespace creation */
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>

static int child_fn(void *arg) {
    printf("In new PID ns: getpid()=%d\n", getpid()); /* prints 1 */
    return 0;
}

int main(void) {
    char stack[65536];
    /* CLONE_NEWPID creates a new PID namespace for the child */
    clone(child_fn, stack + sizeof(stack),
          CLONE_NEWPID | SIGCHLD, NULL);
    sleep(1); /* wait for child */
    return 0;
}

08Kernel Threads (kthreads)

Not all tasks run userspace code. The kernel itself has many tasks called kernel threads (kthreads) that run entirely in kernel space. Examples: kworker/0:0 (work queues), ksoftirqd/0 (softirq processing), kcompactd (memory compaction), jbd2/sda1-8 (ext4 journaling), khugepaged (huge page collapsing), migration/0 (scheduler task migration).

How kthreads differ from userspace processes:

  1. task->mm == NULL — no userspace address space. When a kthread needs to access memory, it uses task->active_mm which is "borrowed" from whatever userspace process last ran on that CPU.
  2. They are created with kthread_create() / kthread_run() (in include/linux/kthread.h), not fork().
  3. They live in the kthreadd (PID 2) subtree. PID 2 is the kernel thread daemon started during boot.
  4. They cannot directly access userspace memory — there is no valid mm to map it through.
  5. They can be stopped cleanly via kthread_stop(), which sets kthread_should_stop() to true, waking the thread so it can exit gracefully.

kthread_create internals: A request is queued to kthreadd, which calls kernel_thread() → kernel_clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | SIGCHLD, ...) with the function to run. The new task starts in kthread() which calls the user-provided function.

Work queues (struct workqueue_struct) are the most common way to defer work to a kthread — you don't create a kthread manually; instead you schedule a work_struct onto a workqueue and a pool of kworker threads executes it. This is safer and avoids the overhead of creating/destroying threads.

/* Minimal kernel module that creates and stops a kthread */
#include <linux/module.h>
#include <linux/kthread.h>
#include <linux/delay.h>

static struct task_struct *my_thread;

static int my_kthread_fn(void *data)
{
    pr_info("kthread started: pid=%d mm=%p\n",
            current->pid, current->mm); /* mm is NULL! */
    while (!kthread_should_stop()) {
        pr_info("kthread: still running\n");
        msleep_interruptible(1000);
    }
    pr_info("kthread: exiting\n");
    return 0;
}

static int __init my_module_init(void)
{
    my_thread = kthread_run(my_kthread_fn, NULL, "my_kthread");
    if (IS_ERR(my_thread)) {
        pr_err("kthread_run failed\n");
        return PTR_ERR(my_thread);
    }
    return 0;
}

static void __exit my_module_exit(void)
{
    kthread_stop(my_thread); /* sets should_stop, waits for exit */
}

module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");

09do_exit(): The Death of a Task

When a process calls exit(), receives a fatal signal, or is killed with SIGKILL, the kernel runs do_exit() (in kernel/exit.c). This function methodically dismantles the task, releasing every resource it holds.

The do_exit() sequence (simplified):

  1. Set TASK_DEAD flag: Marks the task as exiting, preventing it from being rescheduled.
  2. exit_signals(): Flush pending signals, notify the thread group.
  3. exit_mm(): Drop the reference to mm_struct (mmput()). When the last thread using the mm exits, mmput() calls exit_mmap() which unmaps all VMAs, frees page tables, and drops all physical page references.
  4. exit_files(): put_files_struct() — decrement files_struct reference count. When it hits zero, all file descriptors are closed.
  5. exit_fs(): Release filesystem context (cwd, root).
  6. exit_task_namespaces(): Release namespace references.
  7. exit_task_work(): Run any pending task-local work.
  8. taskstats_exit(): Record accounting data.
  9. Set exit code: Store in task->exit_code.
  10. Notify parent: Send SIGCHLD to parent. If traced with ptrace, notify the tracer.
  11. schedule(): Call the scheduler. The task is now a zombie (TASK_ZOMBIE / EXIT_ZOMBIE). It is no longer runnable, but its task_struct is still in memory because the parent needs to wait() to collect the exit status.

Zombie processes: A zombie is a task_struct with no resources attached — no memory, no files — just the struct itself holding the exit code. It is reaped when the parent calls wait4() / waitpid(), which triggers release_task() and finally frees the task_struct.

What if the parent never calls wait()?: The zombie stays forever (or until the parent exits and the zombie is reparented to init, which immediately reaps it). This is why long-running server processes must call wait() for their children.

Thread exit vs. process exit: A single thread exiting calls do_exit() but only the last thread's exit tears down mm, files, etc. (because exit_mm() and exit_files() use reference counts). The last thread's exit also sets EXIT_ZOMBIE and sends SIGCHLD to the parent.

/* Demonstrating zombie state */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        printf("Child (PID=%d) exiting...\n", getpid());
        /* Child exits immediately. Parent does NOT call wait(). */
        return 42;
    }
    /* Parent: sleep, leaving child as zombie */
    printf("Parent: child PID=%d; sleeping 10s...\n", pid);
    printf("Run: ps aux | grep Z  (to see zombie)\n");
    printf("Also: cat /proc/%d/status | grep State  (should show Z)\n", pid);
    sleep(10);
    /* After sleep, parent exits -> zombie gets reparented to init
     * -> init calls wait() -> zombie reaped */
    return 0;
}
/* Kernel do_exit() key calls (from kernel/exit.c):
 *   do_exit(code)
 *     exit_signals(tsk)
 *     exit_mm()
 *     exit_files()
 *     exit_fs()
 *     exit_task_namespaces()
 *     tsk->exit_code = code
 *     do_notify_parent(tsk, SIGCHLD)
 *     tsk->__state = EXIT_ZOMBIE
 *     schedule()   <-- never returns
 */

10The Kernel Stack and task_struct Allocation

Every task has its own kernel stack — a small region of memory (typically 8 KB on 64-bit x86, 16 KB on ARM64) used whenever the task is executing in kernel mode (during a syscall or interrupt). The kernel stack is separate from the userspace stack.

Stack-task_struct relationship: Historically, task_struct was stored at the bottom of the kernel stack (so current could be found by masking the stack pointer). Modern kernels (since ~v4.9 with THREAD_INFO_IN_TASK=y) store thread_info as the first field of task_struct itself, and task_struct is allocated separately on the slab (task_struct cache). The kernel stack is a separate allocation, and a pointer to task_struct is stored at the top of the stack in task_struct *task field of thread_info.

current macro: Architecture-dependent. On x86-64, during kernel execution the gs register segment base points to per-CPU data, and current_task is a per-CPU variable holding the pointer to the currently running task_struct. The current macro expands to get_current() which reads this.

Kernel stack overflow: Since the kernel stack is small and fixed, deeply recursive kernel code can overflow it — a serious bug called kernel stack overflow. The kernel has several mitigations:

  • Stack canaries (CONFIG_STACKPROTECTOR): a magic value placed at the stack bottom; overflow detection at function return.
  • VMAP_STACK (CONFIG_VMAP_STACK): uses vmalloc to back the kernel stack with virtual memory, so an overflow hits an unmapped guard page and causes an immediate fault rather than silent corruption.
  • Stack depth warnings: CONFIG_FRAME_WARN causes the compiler to warn about large stack frames at compile time.

dup_task_struct() (called by copy_process()):

  1. Allocates a new task_struct from the slab cache.
  2. Allocates a new kernel stack via alloc_thread_stack_node().
  3. Copies the parent's task_struct fields into the new struct (*tsk = *orig).
  4. Sets up the thread_info inside the new task_struct.
  5. Initializes the stack canary, resets reference counts.
/* Reading current task info from a kernel module */
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/sched/task_stack.h>

static int __init stack_demo_init(void)
{
    struct task_struct *t = current;

    pr_info("current comm: %s\n", t->comm);
    pr_info("current pid:  %d\n", t->pid);
    pr_info("current tgid: %d\n", t->tgid);

    /* Kernel stack: end_of_stack(t) is the lowest valid address */
    pr_info("stack start (low):  %px\n", (void*)end_of_stack(t));
    pr_info("stack end (high):   %px\n", (void*)task_stack_page(t) + THREAD_SIZE);

    /* Approximate current stack usage */
    unsigned long sp;
    asm volatile("mov %%rsp, %0" : "=r"(sp));
    pr_info("approx stack used: %lu bytes\n",
            (task_stack_page(t) + THREAD_SIZE) - sp);
    return 0; /* insert module, it prints, then can be removed */
}

static void __exit stack_demo_exit(void) {}
module_init(stack_demo_init);
module_exit(stack_demo_exit);
MODULE_LICENSE("GPL");

Key insights

  • ✦In Linux, "process" and "thread" are the same kernel object (task_struct) — the difference is only in what clone() flags were used at creation time, determining what resources are shared.
  • ✦fork() is fast because of copy-on-write: the kernel duplicates page table entries and marks them read-only, paying the cost of copying a physical page only when that page is first written.
  • ✦exec() reuses the task_struct (same PID) but replaces everything that defines the program: address space, stack, instruction pointer, signal handlers. The identity persists, the program image is replaced.
  • ✦The task_struct field mm == NULL is the precise definition of a kernel thread: it has no userspace address space and cannot be swapped out.
  • ✦Zombie processes exist because the exit status must be stored somewhere until the parent calls wait(). The task_struct is the storage — and it is freed only by wait(), not by the dying process itself.
  • ✦PID namespaces are not magic: they are just an extra level of indirection in struct pid, so the same task_struct has different PID numbers depending on which namespace is looking.
  • ✦The scheduler is namespace-agnostic and process-agnostic: it picks the highest-priority runnable task_struct without caring whether it is a thread, process, or kernel thread.
  • ✦Understanding do_exit() and reference counting (mm_users, files_struct->count, etc.) is the mental model for all resource lifetimes in the kernel: structs persist as long as something holds a reference.

Common pitfalls

  • ⚠Confusing task->pid with task->tgid: task->pid is the kernel's per-thread ID (what gettid() returns); task->tgid is the thread-group ID that all threads in a process share (what getpid() returns). Reporting task->pid as 'the process PID' is wrong for multi-threaded programs.
  • ⚠Assuming fork() copies all memory: it does not. It copies page table metadata and marks pages COW. Physical page copying happens lazily on first write. Measuring RSS immediately after fork gives a misleading number.
  • ⚠Calling wait() incorrectly and leaking zombie processes: any process that forks must eventually call wait()/waitpid() on each child, or set SA_NOCLDWAIT / SIGCHLD to SIG_IGN, otherwise zombies accumulate.
  • ⚠Misusing vfork(): vfork() borrows the parent's address space; the child must call exec or _exit (never exit(), which would run atexit handlers and flush stdio on the parent's behalf). Modern code should prefer posix_spawn() instead.
  • ⚠Writing to the kernel stack carelessly: the 8KB kernel stack is tiny. Large local arrays in kernel code cause stack overflows that corrupt adjacent memory or trigger VMAP_STACK guard-page faults. Prefer kmalloc for large kernel-mode buffers.
  • ⚠Accessing task_struct fields without proper locking: many fields require RCU read lock, task_lock(), or tasklist_lock. Reading task->mm without rcu_read_lock is a data race even if it looks safe.
  • ⚠Assuming /proc/<pid> refers to a process when it actually refers to the thread group leader: /proc/<pid>/task/ contains one directory per thread (each with its own tid = kernel pid).

Practice

  1. 01
    easy

    Write a C program that forks 5 children, each printing its own PID and PPID. In the parent, call waitpid() for each child in the order they were created. Then run the program under strace to observe the clone() system call and the flags it uses.

  2. 02
    medium

    Write a kernel module that, when loaded (insmod), walks the entire process tree using for_each_process() and for each process also iterates its threads with while_each_thread(). Print: comm, pid, tgid, number of threads, and whether mm is NULL (i.e., is it a kthread). Verify by checking output in dmesg against what ps -eLf shows.

  3. 03
    medium

    Implement a minimal shell (100-200 lines of C) that supports running commands with pipes: e.g. ls | wc -l. Your shell must fork a child for each command, use dup2() to connect pipe file descriptors to stdin/stdout, and exec the command. Verify that all pipe FDs are closed correctly in the parent after the fork — use lsof or /proc/self/fd to confirm no FD leaks.

  4. 04
    medium

    Use bpftrace or ftrace to trace the exact sequence of kernel functions called during a fork(). Specifically: attach to copy_process(), copy_mm(), and wake_up_new_task(). Run a simple program that forks once and capture the trace. Explain what each function does based on what you see in kernel/fork.c.

  5. 05
    hard

    Create a PID namespace experiment: write a C program that uses clone(CLONE_NEWPID | SIGCHLD, ...) to create a child in a new PID namespace. Inside the child, print getpid() (should be 1). Then mount a new /proc inside the namespace and run ps. From the parent namespace, show the child's real PID using /proc/<host_pid>/status. Explain the discrepancy.

  6. 06
    easy

    Deliberately create a zombie process: fork a child that exits immediately, then sleep in the parent for 30 seconds without calling wait(). While the parent sleeps, use ps aux, /proc/<child_pid>/status, and ls /proc/<child_pid>/ to inspect the zombie. Note which /proc files exist (or don't). Then let the parent exit and observe the zombie disappear as init reaps it.

Resources

📄Linux Kernel Source: kernel/fork.c
docsfree

The canonical source for copy_process(), kernel_clone(), dup_task_struct(). Read this alongside this module — Bootlin's cross-reference makes it navigable.

📄Linux Kernel Source: kernel/exit.c
docsfree

The do_exit() function in its full glory. Read every comment — the kernel developers explain the ordering constraints.

📕Linux Kernel Development (3rd ed.) — Robert Love
bookpaidAddison-Wesley; widely available new and used. Chapter 3 covers processes exhaustively.

The most readable high-level tour of process management in the kernel; read Chapter 3 in parallel with this module.

📕Understanding the Linux Kernel (3rd ed.) — Bovet & Cesati
bookpaidO'Reilly, by Daniel Bovet and Marco Cesati.

More detailed than Love's book; covers task_struct field-by-field. Dense but accurate — use as a reference, not a cover-to-cover read.

✎LWN: The Linux process model
articlefree

LWN's classic article on how clone() unifies processes and threads. Short, precise, foundational.

✎LWN: Namespaces in operation (series)
articlefree

Michael Kerrisk's 8-part series on Linux namespaces, starting with mount namespaces and covering PID namespaces in part 3. The definitive free tutorial.

📄man7.org: clone(2) manual page
docsfree

The Linux man page for clone() by Michael Kerrisk — exhaustively documents every CLONE_* flag with behavior. Essential reference.

📄man7.org: namespaces(7)
docsfree

Overview of all Linux namespace types with links to per-namespace man pages. Start here for container internals.

📕Operating Systems: Three Easy Pieces — Process API chapter
bookfree

OSTEP's chapter on fork/exec/wait is the best introductory treatment. Free PDF. Read this first if you are new to Unix process creation.

✎kernelnewbies.org: Task Struct
articlefree

KernelNewbies is the go-to community site for first-time contributors; check their process/scheduler documentation and project ideas.

✎Linux Insides: Process — processes
articlefree

0xAX's Linux Insides book covers fork/exec with inline kernel source. Free, well-illustrated, follows a similar path to this module.

🎓Bootlin (Free Electrons) kernel training slides
coursefree

Bootlin's freely available kernel training slides. The process management section has excellent diagrams of task_struct relationships.

Checkpoints — you can now…

  • ✓You can locate the task_struct definition in the kernel source, identify the five most important field groups, and explain what each group is responsible for.
  • ✓You can trace a fork() call from userspace through the syscall boundary to copy_process() and explain what copy_mm(), copy_files(), and wake_up_new_task() each do.
  • ✓You can explain copy-on-write precisely: what happens at fork() time to the page tables, what triggers a COW page fault, and why this makes fork-exec cheap.
  • ✓You can distinguish task->pid from task->tgid and explain when they differ, and use ps -eLf or /proc/<pid>/task to observe per-thread TIDs in a multi-threaded process.
  • ✓You can write a C program that creates a child process, demonstrates zombie state, and properly reaps it with waitpid().
  • ✓You can use unshare --pid --fork --mount-proc or a short clone() C program to run a process as PID 1 in a new PID namespace and explain what struct pid's upid array stores.
  • ✓You can write a kernel module that creates a kthread with kthread_run(), does work in a loop checking kthread_should_stop(), and stops cleanly on module unload.
  • ✓You can name the key steps in do_exit() in order, explain why zombie state exists, and describe the reference-counting mechanism that defers mm and files cleanup to the last exiting thread.