“There are no ’threads’ in the Linux kernel. There are only tasks that happen to share an address space and file descriptors, and tasks that do not. The distinction between a process and a thread is a user-space abstraction.”
— Linus Torvalds
In traditional operating system textbooks, a Process is taught as an isolated program execution unit, while a Thread is taught as a lightweight execution unit inside a process.
In the Linux kernel, this distinction does not exist. Both processes and threads are represented by the exact same internal C structure: struct task_struct.
Understanding how the Linux kernel manages tasks, executes context switches, clones execution contexts via Copy-On-Write, and enforces resource isolation is essential for systems engineering, database optimization, and cloud-native container runtimes.
1. The Core Data Structure: struct task_struct
Every execution context in Linux is tracked by a process descriptor: the task_struct (defined in include/linux/sched.h). It is one of the largest and most critical structures in the kernel, holding hundreds of fields:
struct task_struct {
/* 1. Identification */
pid_t pid; /* Thread ID (TID) in user space */
pid_t tgid; /* Thread Group ID (Process ID in user space) */
/* 2. Process State */
unsigned int __state; /* TASK_RUNNING, TASK_INTERRUPTIBLE, etc. */
int exit_state; /* EXIT_ZOMBIE, EXIT_DEAD */
int exit_code; /* Exit status returned to parent */
/* 3. Memory & Virtual Address Space */
struct mm_struct *mm; /* Memory descriptor (NULL for kernel threads!) */
struct mm_struct *active_mm; /* Borrowed mm for kernel threads (lazy TLB) */
/* 4. Filesystem & Descriptors */
struct fs_struct *fs; /* Filesystem info (root dir, cwd, umask) */
struct files_struct *files; /* File descriptor table (open fds) */
/* 5. Scheduling & Runqueues */
int prio; /* Dynamic priority */
int static_prio; /* Nice value (-20 to +19) */
struct sched_entity se; /* EEVDF / CFS scheduling entity (vruntime) */
/* 6. Process Relationships & Tree Hierarchy */
struct task_struct __rcu *real_parent; /* Real parent process */
struct list_head children; /* List of child processes */
struct list_head sibling; /* Linkage in parent's children list */
/* 7. Signal Handling */
struct signal_struct *signal; /* Shared signal handlers */
sigset_t blocked; /* Mask of currently blocked signals */
};
PID vs. TGID: The POSIX Illusion
In standard user space POSIX programming:
- When you invoke
getpid(), you expect your Process ID. - In multi-threaded programs (e.g., using
pthread_create), all threads share the same Process ID.
Under the hood:
- The Linux kernel assigns a completely unique
pidto every single thread and process. - To satisfy POSIX, the kernel introduces the Thread Group ID (
tgid). - For the main process thread,
pid == tgid. When secondary threads are spawned, they receive new, uniquepidvalues, but inherit the main process’stgid. - In Linux:
getpid()system call returnscurrent->tgid.gettid()system call returnscurrent->pid.
2. Process Creation: fork, clone, and Copy-On-Write (COW)
Linux does not create new processes from scratch; it clones existing ones using the clone() or clone3() system call.
Parent Process (PID: 1000)
|
| Calls fork() / clone()
v
Kernel duplicates task_struct
Duplicates Page Tables (Marks physical frames as READ-ONLY / COW)
|
+--------+--------+
| |
v v
Parent (PID 1000) Child (PID 1001)
returns child PID returns 0
| |
v v
[ Both read identical physical memory frames with ZERO copying ]
1. Copy-On-Write (COW) Optimization
Historically in Unix, fork() physically copied the entire memory space of the parent into the child. If a process consumed 10 Gigabytes of RAM, fork() would duplicate 10 Gigabytes, incurring catastrophic allocation latency.
Linux uses Copy-On-Write (COW):
- When
fork()is called, the kernel duplicates the parent’s page table entries, pointing the child’s virtual addresses to the exact same physical RAM frames as the parent. - The kernel marks all shared physical pages as Read-Only and flags them as COW in the MMU.
- Both parent and child continue executing, reading from shared memory at zero cost.
- The Write Fault: The moment either process attempts to write to a memory page (e.g., modifying a variable), the MMU hardware triggers a Page Fault (
#PF). - The kernel intercepts the fault in
do_wp_page():- It allocates a single, fresh 4KB physical page from the Buddy Allocator.
- It copies the 4KB data from the shared frame into the new frame.
- It updates the writing process’s page table entry with write permissions.
- It leaves all other memory pages untouched and shared.
2. The clone() Flags: How Threads are Born
When creating a task via clone(), flags control what is shared versus what is copied:
// Creating a lightweight Thread (shared address space and files):
clone(fn, stack, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, arg);
// Creating an isolated Process:
clone(fn, stack, SIGCHLD, arg);
// Creating an isolated Container Process:
clone(fn, stack, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWIPC | SIGCHLD, arg);
3. The execve() System Call: Binary Transformation
While fork() creates an identical duplicate of a process, execve() wipes away the existing program and replaces it with a completely new executable binary.
The Execution Pipeline:
- Validating the Binary: The kernel opens the executable file and inspects the first bytes for the ELF Magic Number (
0x7F 'E' 'L' 'F') or a script hashbang (#!/bin/bash). - Releasing Old Memory: The kernel destroys the process’s existing
mm_struct, unmapping all old code, heap, and libraries. - Setting Up Address Space: The kernel parses the ELF headers and creates new Virtual Memory Areas (VMAs):
.textsegment: Executable code (Read/Execute)..datasegment: Initialized global variables (Read/Write)..rodatasegment: Constants and string literals (Read-Only)..bsssegment: Zero-initialized variables (Anonymous pages).
- Setting Up User Stack: The kernel pushes command-line arguments (
argv), environment variables (envp), and the Auxiliary Vector (auxv) onto the new user stack. - Jumping to Entry Point: The kernel sets the instruction pointer (
%ripon x86-64) to the binary’s entry address (or to the dynamic linker/lib64/ld-linux-x86-64.so.2) and transitions into Ring 3.
4. Process State Machine & The Zombie Lifecycle
A Linux task traverses through well-defined lifecycle states:
+-------------------+
| TASK_NEW |
+-------------------+
|
v
+-------------> [TASK_RUNNING] <-------------+
| (Ready on Runqueue |
| or executing on CPU) |
| | |
Scheduler I/O Block / I/O Complete /
Preempt Wait Event Signal Received
| | |
| v |
| [TASK_INTERRUPTIBLE] ------------+
| |
| | (Uninterruptible Disk / Lock Sleep)
| v
| [TASK_UNINTERRUPTIBLE (D)]
| |
| v
| [TASK_STOPPED (T)] (via SIGSTOP / Debugger)
| |
+---------------------+
|
exit() called
v
[EXIT_ZOMBIE (Z)]
|
Parent calls waitpid()
v
[EXIT_DEAD (X)]
(task_struct freed)
The Truth About Zombie Processes (EXIT_ZOMBIE):
A common interview question: What is a zombie process, and how do you kill it?
- A zombie process is already dead. It consumes zero CPU, zero RAM, and holds no open file descriptors.
- Why does it exist? When a child calls
exit(), the kernel frees its memory and resources, but retains itstask_structin the process table to hold its exit code (e.g.,exit(0)vsexit(1)). - The parent process must read this exit code by calling
wait()orwaitpid(). Once the parent callswait(), the zombie is reaped and itstask_structis removed. - You cannot kill a zombie with
SIGKILL(kill -9) because a zombie is already dead and cannot execute a signal handler. To eliminate a zombie, you must either fix or terminate its parent process, which causes the zombie to be reparented to PID 1 (systemd), which immediately callswait()to reap it.
5. The Context Switch: What Happens on Silicon?
When the scheduler decides to stop Task A and run Task B, the kernel executes a Context Switch:
[ CPU Core: Running Task A in Ring 3 ]
|
| 1. Hardware Timer Interrupt (Local APIC) fires
v
[ CPU enters Ring 0: irq_enter() ]
|
| 2. scheduler_tick() updates task vruntime
| 3. Detects Task A has exhausted timeslice
| 4. Sets TIF_NEED_RESCHED flag on Task A
v
[ schedule() -> __schedule() ]
|
| 5. EEVDF scheduler selects Task B as next task
v
[ context_switch(prev=Task A, next=Task B) ]
|
+---> A. switch_mm() (Memory Space Switch)
| Loads Task B's page table physical address into CR3 register.
| (Flushes TLB unless PCID / ASID tags are supported).
| *Skipped if Task B is a kernel thread! (Lazy TLB)*
|
+---> B. switch_to() (Register & Stack Switch)
Saves Task A's CPU registers (rsp, rbp, rbx, r12-r15)
Restores Task B's saved registers from its kernel stack.
Switches CPU Stack Pointer (%rsp) to Task B's stack.
v
[ CPU resumes in Ring 3: Task B continues executing ]
6. Signals, Priorities & /proc/<PID>/ Superuser Anatomy
In How Linux Works, Brian Ward emphasizes that signals and the /proc filesystem are the two primary tools superusers possess to inspect and control processes.
1. Unix Signals: Asynchronous Process Interrupts
Signals notify a process that an event has occurred. The kernel checks for pending signals whenever a process transitions from kernel mode back to user mode.
| Signal | Number | Default Action | Catchable? | Purpose & Superuser Usage |
|---|---|---|---|---|
SIGHUP |
1 |
Terminate | Yes | Hangup detected on controlling terminal; standard convention for daemons to reload configuration without restarting. |
SIGINT |
2 |
Terminate | Yes | Interrupt from keyboard (Ctrl+C). |
SIGQUIT |
3 |
Core Dump | Yes | Quit from keyboard (Ctrl+\); dumps process memory to disk. |
SIGKILL |
9 |
Terminate | NO | Unconditional immediate termination by the kernel. Cannot be caught, blocked, or ignored. Always try SIGTERM first! |
SIGSEGV |
11 |
Core Dump | Yes | Invalid memory reference (Page fault outside VMA or write to read-only page). |
SIGPIPE |
13 |
Terminate | Yes | Writing to a pipe with no reader attached. |
SIGTERM |
15 |
Terminate | Yes | Polite termination request. Allows daemons to flush buffers, complete transactions, and close open files. |
SIGCHLD |
17 |
Ignore | Yes | Sent to parent process whenever a child process terminates, stops, or resumes. |
SIGSTOP |
19 |
Stop | NO | Freezes process execution immediately (e.g. Ctrl+Z). Cannot be caught or ignored. |
SIGCONT |
18 |
Continue | Yes | Resumes a previously stopped process. |
2. Process Priorities & Nice Values
Linux processes execute with priorities between 0 and 139:
- Real-Time Priorities (
0to99): Controlled bychrtusingSCHED_FIFOorSCHED_RR. - Standard Nice Values (
-20to+19): Maps to kernel static priorities100to139.- Default nice is
0. -20: Highest priority (least “nice” to other tasks; gets largest CPU slice).+19: Lowest priority (most “nice”; yields to everything else).- Regular users can only increase their nice value (reduce priority). Only
rootcan assign negative nice values.
- Default nice is
# Launch background compilation with low CPU priority
nice -n 19 make -j4
# Increase priority of a running production database (root required)
sudo renice -n -10 -p 1842
3. The /proc/<PID>/ Anatomy
The /proc filesystem directly exposes the kernel’s internal task_struct for every running PID:
/proc/<PID>/cmdline: Exact command-line arguments separated by null bytes (\0)./proc/<PID>/environ: Process environment variables./proc/<PID>/fd/: Directory containing symlinks for every open file descriptor (0=stdin,1=stdout,2=stderr, sockets, open disk files)./proc/<PID>/status: Human-readable process state:State:(e.g.,S (sleeping),D (disk sleep),Z (zombie)).VmPeak&VmSize: Total virtual memory mapped.VmRSS: Resident Set Size (actual physical RAM frames consumed).voluntary_ctxt_switches: Number of times the task slept waiting for I/O.
/proc/<PID>/limits: Soft and hard resource limits (RLIMIT_NOFILEmax open files,RLIMIT_NPROC)./proc/<PID>/wchan: The exact kernel function where the process is currently blocked waiting (invaluable for diagnosing stuck processes inDstate!).
7. Modern Container Primitives: Namespaces & cgroups
Containers (Docker, Podman, Kubernetes, containerd) are not hardware virtual machines. A container is simply a standard Linux process isolated by Namespaces and restricted by Control Groups (cgroups).
+------------------------------------------------------------------------+
| WHAT IS A CONTAINER? |
| |
| Standard Linux Process (task_struct) |
| + |
| Linux Namespaces (What the process can SEE: Isolated Views) |
| + |
| Linux Control Groups (What the process can USE: Resource Limits) |
+------------------------------------------------------------------------+
1. Linux Namespaces (Isolation)
Namespaces partition global system resources so that a group of processes sees an isolated instance:
| Namespace | Clone Flag | Isolated Resource |
|---|---|---|
| PID | CLONE_NEWPID |
Process IDs. A container process sees itself as PID 1 inside, while having a normal PID (e.g. 18249) on the host. |
| NET | CLONE_NEWNET |
Network devices, IP routing tables, firewall rules, port bindings. |
| MNT | CLONE_NEWNS |
Mount points and filesystem hierarchies (/, /proc, /sys). |
| UTS | CLONE_NEWUTS |
System hostname and domain name. |
| IPC | CLONE_NEWIPC |
System V IPC and POSIX message queues. |
| USER | CLONE_NEWUSER |
User and group IDs. UID 0 (root) inside the container can be mapped to an unprivileged UID 10000 on the host. |
| CGROUP | CLONE_NEWCGROUP |
View of the cgroup filesystem tree. |
2. Control Groups (cgroups v2: Resource Limiting)
While namespaces control visibility, cgroups govern resource consumption to prevent noisy neighbors from exhausting host capacity:
cpu.max: Controls CPU bandwidth quota via CFS/EEVDF period throttling (e.g.,200000 100000= 2 dedicated CPU cores).memory.max: Hard physical RAM ceiling. Exceeding this boundary triggers the cgroup OOM Killer.io.weight/io.max: Throttles read/write IOPS and bytes-per-second on block devices.pids.max: Limits maximum fork capacity to prevent fork-bomb denial of service attacks.
# Inspect the cgroups v2 tree of any active process on your machine:
cat /proc/self/cgroup
# Inspect active namespaces:
ls -l /proc/self/ns/
8. Hands-On Practical Labs
Lab 6.1: Creating, Inspecting, and Reaping Zombie Processes in C
A zombie process occurs when a child process terminates, but its parent has not yet read its exit status code via wait() or waitpid(). The kernel frees the child’s memory pages but retains its struct task_struct and PID slot.
Complete Source: zombie_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
}
if (pid == 0) {
/* Child Process: Terminates immediately */
printf("[Child: PID %d] Exiting now with code 42...\n", getpid());
exit(42);
} else {
/* Parent Process: Sleeps without calling wait() */
printf("[Parent: PID %d] Child spawned (PID %d).\n", getpid(), pid);
printf("[Parent] Sleeping 15 seconds WITHOUT reaping child. Inspect ps aux in another terminal!\n");
sleep(15);
printf("[Parent] Waking up. Now calling waitpid() to reap child...\n");
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("[Parent] Successfully reaped child! Exit code was: %d\n", WEXITSTATUS(status));
}
printf("[Parent] Sleeping 5 more seconds. Verify child is completely gone from ps!\n");
sleep(5);
}
return 0;
}
Verification Steps:
# 1. Compile and run
gcc -O2 zombie_demo.c -o zombie_demo
./zombie_demo &
# 2. In another terminal, inspect process state while parent sleeps:
ps aux | grep -E 'defunct|zombie_demo'
Output:
user 18400 0.0 0.0 2488 812 pts/0 S+ 12:00 0:00 ./zombie_demo
user 18401 0.0 0.0 0 0 pts/0 Z+ 12:00 0:00 [zombie_demo] <defunct>
Notice:
- The child PID has status
Z+(<defunct>) and 0 KB memory. - Running
kill -9 18401does nothing! The process is already dead; only its task descriptor remains until the parent callswaitpid()or dies.
Lab 6.2: Tracing clone() System Call Flags with strace
In this lab, we observe how the Linux kernel uses clone() under the hood when spawning POSIX threads.
1. Multithreaded Program: thread_test.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void *worker(void *arg) {
printf("[Worker] Thread active. TID: %ld\n", (long)gettid());
sleep(1);
return NULL;
}
int main(void) {
pthread_t t1;
printf("[Main] PID: %ld, TID: %ld\n", (long)getpid(), (long)gettid());
pthread_create(&t1, NULL, worker, NULL);
pthread_join(t1, NULL);
return 0;
}
2. Trace with strace:
gcc -pthread thread_test.c -o thread_test
strace -f -e clone,clone3 ./thread_test
Expected Kernel Output:
clone(child_stack=0x7f4b821fedf0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, ...)
What This Proves:
CLONE_VM: Shares virtual address space (memory).CLONE_FILES: Shares the open file descriptor table.CLONE_THREAD: Assigns the sametgid(Thread Group ID).
Lab 6.3: Handling Asynchronous Signals (sigaction) & Auditing /proc/<PID>/status
In How Linux Works, superusers learn that daemons should catch SIGHUP to reload config without restarting. Here, we build a daemon in C that handles SIGHUP and SIGTERM, and inspect its /proc state.
1. Source Code: signal_daemon.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
static volatile sig_atomic_t reload_requested = 0;
static volatile sig_atomic_t keep_running = 1;
void handle_signal(int sig) {
if (sig == SIGHUP) {
reload_requested = 1;
} else if (sig == SIGTERM || sig == SIGINT) {
keep_running = 0;
}
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = handle_signal;
sigemptyset(&sa.sa_mask);
sigaction(SIGHUP, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
printf("[Daemon] Started. PID: %d. Waiting for signals...\n", getpid());
int tick = 0;
while (keep_running) {
sleep(1);
tick++;
if (reload_requested) {
printf("[Daemon: PID %d] SIGHUP received! Reloading configuration dynamically...\n", getpid());
reload_requested = 0;
}
}
printf("[Daemon: PID %d] SIGTERM received! Flushed buffers, exiting cleanly.\n", getpid());
return 0;
}
2. Verification Steps:
# 1. Compile and run in background
gcc -O2 signal_daemon.c -o signal_daemon
./signal_daemon &
DPID=$!
# 2. Inspect caught signals mask in /proc
grep -E 'SigCgt|SigIgn|State' /proc/$DPID/status
# 3. Send SIGHUP to trigger configuration reload
kill -HUP $DPID
# 4. Gracefully terminate with SIGTERM
kill -TERM $DPID
wait $DPID 2>/dev/null
9. Summary Checklist
- No Threads Myth: Understood that both processes and threads are
task_structinstances. - PID vs TGID: Mastered how POSIX compatibility is maintained inside the kernel.
- Copy-On-Write: Traced page table duplication and write-fault traps.
- Signals Architecture: Mastered
SIGTERMvsSIGKILL, signal masks, and safesigactionhandling. - Process Priorities: Configured nice values (
-20to+19) and real-time scheduling. -
/proc/<PID>/Diagnostics: Audited memory RSS, file descriptors (/proc/PID/fd), and blocked functions (wchan). - Context Switching: Dissected
switch_mm(CR3 reload) andswitch_toregister swapping. - Hands-on Labs: Reaped zombie processes, traced
clone()flags, and handled POSIX signals in C.