Throughout module 1 we used the word process as if it were self-evident: ingestor, aggregator and meteo-api showed up in the output of ps, crossed the boundary into kernel mode and consumed resources. But we never opened that box. In this lesson we open it all the way. You are going to see what exactly a process is in memory, what data structure represents it inside the Linux kernel, why it moves through some states and not others, how it is born — with the strangest and most elegant mechanism in UNIX, fork() — and what it really costs for the CPU to stop running one and start running another.
By the end you will be able to read /proc/<pid>/ the way a doctor reads a chart, you will understand why a zombie process cannot be killed with kill -9, and you will be able to write a supervisor in C that launches and watches over the aggregator. It is the most fundamental lesson in the course: almost everything that follows — scheduling, memory, concurrency, containers — rests on what you see here.
Contents
- Program and process: the difference that changes everything
- The memory image of a process
- The process control block:
task_structfield by field - Process states and their transitions
- The real state codes reported by
ps - Process creation:
fork()and copy-on-write execve(): replacing the program without changing processwait(), exit codes, zombies and orphans- A complete example: a supervisor for the
aggregator - The process hierarchy and
init/systemd - The context switch: what gets saved and what it costs
- Hands-on inspection:
/proc,ps -eo,pstree
Program and process: the difference that changes everything
A program is a file on disk. It is passive, it does nothing, it is a sequence of bytes in a specific format (on Linux, ELF) describing what code to load and where.
A process is a program in execution: it is active, it has state, it has resources assigned to it and it has a lifetime.
On meteo-01 you can see this literally:
$ ls -l /opt/meteora/bin/aggregator -rwxr-xr-x 1 root root 87264 Aug 12 10:04 /opt/meteora/bin/aggregator $ ps -eo pid,comm | grep aggregator 1877 aggregator 1902 aggregator
A single 87 KB file, two processes. Each has its own PID, its own working memory, its own open files and its own point of execution. They share the code (the kernel maps it into RAM just once and references it from both), but nothing else.
The program→process relationship is 1 to N. And not only that: a single process can execute several programs one after another over its lifetime, as we will see with execve().
| Program | Process | |
|---|---|---|
| Nature | Passive | Active |
| Where it lives | Disk | Memory + kernel structures |
| Lifetime | Permanent | From creation until termination |
| Identity | File path | PID |
| How many | One | Many of the same program at once |
The memory image of a process
When the kernel starts a program running, it builds a memory image in the process's address space with several clearly distinct regions. From low addresses to high ones:
High addresses (0x7fff...) ┌──────────────────────────────┐ │ Arguments and environment │ argv[], environ ├──────────────────────────────┤ │ Stack │ grows downward ↓ │ ↓ │ call frames, local variables │ │ │ (gap) │ │ │ │ ↑ │ │ Shared libraries │ libc.so, libm.so (mapped) │ │ │ Heap │ grows upward ↑ ├──────────────────────────────┤ malloc(), brk/mmap │ BSS │ uninitialized globals (zeroed) ├──────────────────────────────┤ │ Data (.data) │ initialized global variables ├──────────────────────────────┤ │ Code (.text) │ instructions, read-only + execute └──────────────────────────────┘ Low addresses (0x400000)
Each region has a different purpose and different permissions, and this is not decoration: it is real protection enforced by the MMU.
| Region | Contents | Permissions | Size | Who manages it |
|---|---|---|---|---|
.text |
Machine code | r-x | Fixed, from the ELF | The loader |
.data |
Initialized globals (int n = 5;) |
rw- | Fixed, from the ELF | The loader |
.bss |
Zeroed globals (static char buf[4096];) |
rw- | Fixed, takes no disk space | The loader |
| Heap | Dynamic memory (malloc) |
rw- | Variable | The program, via libc |
| Stack | Call frames, locals | rw- | Variable, with a limit | Automatic (CPU) |
| Mappings | Libraries, files via mmap |
varies | Variable | mmap() |
Two subtleties that are almost always overlooked:
.bsstakes up no space in the executable file. If you declarestatic struct Reading cache[100000];(2.4 MB), the ELF does not grow by 2.4 MB: it merely records "reserve 2,400,000 zeroed bytes". The kernel materializes them when the process starts. That is why.bsshistorically stands for block started by symbol, and why there are 90 KB executables that occupy 50 MB of RAM..textis read-only and shared. The twoaggregatorprocesses above have their code pages pointing at the same physical RAM frames. There is only one copy of the code in memory no matter how many processes run it. This saves memory and is the reason why running 200 instances of a service does not cost 200 × the size of the binary.
The process control block: task_struct field by field
That whole memory image is the process seen from outside. Inside the kernel, each process is represented by a data structure: the process control block (PCB). On Linux it is called task_struct and it lives in include/linux/sched.h. It is one of the largest structures in the kernel: around 7 KB on a typical x86-64, with more than 200 fields.
You do not need to know them all, but you do need the groups and the reason for each one:
| Group | Representative fields | What it is for |
|---|---|---|
| Identity | pid, tgid, real_parent, parent, children, sibling |
Who it is and its place in the hierarchy |
| State | __state, exit_state, exit_code |
What situation it is in and how it ended |
| Scheduling | prio, static_prio, normal_prio, se (CFS entity), policy, cpus_mask |
How much CPU it deserves and where it may run (lesson 02-02) |
| CPU context | thread (saved registers), stack (kernel stack) |
What to restore when it runs again |
| Memory | mm (address space descriptor), active_mm |
What memory it sees (lessons 02-03 and 02-04) |
| Files | files (descriptor table), fs (current directory, root) |
What it has open and where from |
| Signals | signal, sighand, blocked, pending |
What signals it can receive and how it handles them |
| Credentials | cred (uid, gid, euid, capabilities) |
What it is allowed to do (module 5) |
| Accounting | utime, stime, start_time, nvcsw, nivcsw |
Time consumed and context switches |
| Namespaces | nsproxy |
What "view" of the system it has (module 6) |
An important detail that connects to what you already know: the mm field is a pointer. That means two different task_structs can point to the same address space. When that happens, what you have is not two processes but two threads of the same process. We will look at it in depth in Threads and Processes, but you can already sense Linux's central idea: there are not two structures, one for processes and one for threads; there is a single one, task_struct, and what changes is how much they share.
You can see many of these fields from user space:
$ sudo cat /proc/1877/status | head -20 Name: aggregator Umask: 0022 State: S (sleeping) Tgid: 1877 Ngid: 0 Pid: 1877 PPid: 1 TracerPid: 0 Uid: 998 998 998 998 Gid: 998 998 998 998 FDSize: 64 Groups: 998 NStgid: 1877 NSpid: 1877 VmPeak: 412308 kB VmSize: 408212 kB VmRSS: 31456 kB Threads: 3 voluntary_ctxt_switches: 18422 nonvoluntary_ctxt_switches: 291
Field by field:
State: S: sleeping, waiting for something (we will cover this in the next section).Tgid: 1877equal toPid: 1877: it is the main thread of the group. If it were a secondary thread,Pidwould differ fromTgid.PPid: 1: its parent is PID 1, systemd. It is a system service; no shell launched it.Uid: 998: it runs as themeteorauser, not as root. Exactly what we want.VmSizeof 408 MB againstVmRSSof 31 MB: it reserves a lot of address space but only 31 MB are actually resident in RAM. That difference is the essence of virtual memory and we will develop it in Virtual Memory and Paging.voluntary_ctxt_switches: 18422: it has stepped off the CPU voluntarily 18,422 times (because it blocked waiting for I/O). Against only 291 involuntary ones (the CPU was taken away from it). This process is clearly I/O-bound, a fact we will reuse in the next lesson.
Process states and their transitions
A process is not always running. On a four-core machine, at most four processes are on the CPU at any given instant; the other 300 are in some other state. The classic model has five:
stateDiagram-v2
[*] --> New: fork()
New --> Ready: admitted
Ready --> Running: the scheduler picks it
Running --> Ready: quantum expires (preemption)
Running --> Blocked: waits for I/O or an event
Blocked --> Ready: data arrives / the event happens
Running --> Terminated: exit()
Terminated --> [*]: the parent calls wait()
Running --> Stopped: SIGSTOP
Stopped --> Ready: SIGCONT
What matters is understanding why each transition exists:
- New → Ready: the kernel has finished building the
task_structand setting up the address space. It is now a valid candidate for the CPU. - Ready → Running: decided by the scheduler, and that is the entire subject of the next lesson.
- Running → Ready (preemption): the process asked for nothing; its turn simply ran out or a higher-priority one arrived. This arrow is what distinguishes a preemptive multitasking system from a cooperative one, as we saw in 01-03.
- Running → Blocked: the process asks for something that is not available right now — a
read()on a socket with no data, for example. It would be absurd to leave it occupying the CPU while it waits. The kernel sets it aside and picks another. - Blocked → Ready: the data arrives, typically via a device interrupt (lesson 02-07). Note carefully: it does not go straight to Running. It goes to Ready and competes again.
- Running → Terminated: it calls
exit()or receives a fatal signal. - Terminated → gone: only when its parent collects the exit code. This is where zombies appear.
The transition that confuses beginners most is Blocked → Ready rather than Blocked → Running. The reason is simple: when the data ingestor was waiting for arrives, there may be ten other ready processes and a single free CPU. A process ceasing to be blocked does not entitle it to run immediately; it only gives it back the right to compete.
The real state codes reported by ps
Linux refines the theoretical model. These are the codes you will actually see:
| Code | Kernel name | Meaning | Interruptible? |
|---|---|---|---|
R |
TASK_RUNNING |
Running or ready to run | — |
S |
TASK_INTERRUPTIBLE |
Sleeping, waiting for an event | Yes, signals wake it |
D |
TASK_UNINTERRUPTIBLE |
Sleeping in disk I/O | No, not even with kill -9 |
T |
TASK_STOPPED |
Stopped by SIGSTOP or by the debugger |
With SIGCONT |
Z |
EXIT_ZOMBIE |
Terminated, waiting for the parent to reap it | Not runnable |
I |
TASK_IDLE |
Idle kernel thread | — |
Two things deserve special attention:
R means "runnable", not "running". Linux does not distinguish between Ready and Running in its state representation; both are TASK_RUNNING. If ps shows you 30 processes in R on a four-core machine, there is no contradiction: 4 are on the CPU and 26 are in the run queues waiting their turn. When this happens persistently, you have a saturated CPU, and that is how you detect it.
D is the state that produces the worst production incidents. A process in D is inside the kernel, in the middle of a disk operation, at a point where the code cannot be aborted without corrupting structures. That is why it accepts no signals: kill -9 stays pending until the process leaves D. If the aggregator gets stuck in D because the disk holding /var/lib/meteora has errors or an NFS mount is not responding, you will not be able to kill it, and you will see the load average shoot up even though the CPU is at 0% (on Linux the load average counts processes in D too, not just those in R).
Modifiers you will see next to the state:
$ ps -eo pid,ppid,stat,comm --sort=-pcpu | head -8
PID PPID STAT COMMAND
1877 1 Ssl aggregator
1842 1 Ssl ingestor
1901 1 Ss meteo-api
2214 1901 S meteo-api
3487 3401 R+ pss: it is a session leader.l: it is multithreaded (it has severaltask_structs with the samemm).+: it is in the foreground on a terminal.</N: high / low priority (you will see this in 02-02).
Process creation: fork() and copy-on-write
Here comes the part that strikes everybody as odd the first time. In UNIX, the only way to create a process is to duplicate an existing one with fork().
#include <unistd.h>
#include <stdio.h>
int main(void) {
printf("Before: PID = %d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
printf("CHILD: PID = %d, PPID = %d\n", getpid(), getppid());
} else {
printf("PARENT: PID = %d, the child is %d\n", getpid(), pid);
}
return 0;
}$ ./fork_example Before: PID = 4102 PARENT: PID = 4102, the child is 4103 CHILD: PID = 4103, PPID = 4102
What happens, and why it is disconcerting:
fork()is called once and returns twice. It returns in the parent (with the child's PID) and it returns in the child (with0). There is no magic: the kernel creates a secondtask_structthat is a copy of the first, with the return register set to 0. When the scheduler runs that new process, it will continue right after thefork, exactly where the parent is.- The child inherits almost everything: the address space (a logical copy), the open file descriptors, the working directory, the
umask, the credentials, the resource limits. - It does not inherit: the PID (it is new), the PPID (it now points at the parent), the accumulated CPU times (they start at zero), pending alarms and pending signals.
- The order of the lines is not guaranteed. In the output above the parent printed first, but it could have been the other way round. Who runs first is up to the scheduler. Any code that depends on that order is wrong.
Copy-on-write: why fork() is not expensive
The obvious question: if meteo-api has 400 MB of address space, does fork() copy 400 MB? That would be a disaster, especially because in 95% of cases the next thing the child does is call execve() and throw that whole copy away.
Modern UNIX systems use copy-on-write (COW):
fork()copies only thetask_structand the page tables, not the data pages.- All the data pages are marked read-only in both processes, and noted as shared.
- As long as both only read, they physically share the same RAM. Cost: zero copies.
- When one of the two writes to a page, the MMU raises a protection fault. The kernel intercepts it, copies that single 4 KB page, gives it exclusively to the writer and marks it read/write.
The result: fork() on a 400 MB process copies a few hundred KB of tables and costs on the order of 0.5 ms instead of hundreds of milliseconds. And if the child calls execve() immediately, hardly any page ever gets copied at all.
This mechanism rests directly on the MMU and the page fault, which we will develop in Virtual Memory and Paging. For now, keep the idea: share until somebody writes is one of the most profitable patterns in all of operating system design.
execve(): replacing the program without changing process
fork() duplicates. execve() replaces: it keeps the task_struct (same PID, same parent, same open descriptors) but throws away the entire memory image and loads a new program in its place.
#include <unistd.h>
#include <stdio.h>
int main(void) {
char *argv[] = { "/opt/meteora/bin/aggregator", "--interval", "3600", NULL };
char *envp[] = { "METEORA_CONF=/etc/meteora/meteora.conf", NULL };
printf("I am PID %d and I am about to become the aggregator\n", getpid());
execve(argv[0], argv, envp);
/* If we get here, execve has failed */
perror("execve");
return 1;
}Key points:
execve()does not return if it succeeds. There is no "afterwards". The code that called it no longer exists in memory: it has been replaced. That is why theperrorbelow only runs on error, and why it must always be there.- The PID does not change. It is the same process running a different program. This has enormous practical consequences: systemd can launch a process, apply limits to it and then let it turn into the real service without losing track of it.
- File descriptors survive by default. That is exactly what makes shell redirections possible: the shell does a
fork, in the child it redirects descriptor 1 to a file and then does anexec. The new program finds its output already redirected without knowing anything about it. envpreplaces the entire environment. In the example, theaggregatorwill only seeMETEORA_CONF. If you want to inherit the current environment, you use theexecvvariant with the globalenvironvariable.
The fork + exec pattern is the foundation of everything in UNIX. When you type ls in the shell, this is what happens:
sequenceDiagram
participant U as User
participant S as bash (PID 3401)
participant H as child (PID 3488)
participant K as Kernel
U->>S: ls -l
S->>K: fork()
K-->>S: returns 3488
K-->>H: returns 0
S->>K: wait(3488) — blocks
H->>K: execve("/bin/ls", ...)
K-->>H: memory image replaced
H->>H: runs ls
H->>K: exit(0)
K-->>S: wakes wait() with status 0
S->>U: shows the prompt
wait(), exit codes, zombies and orphans
When a process terminates, it calls _exit(code) (directly or through return in main). The kernel then:
- Frees its address space, its file descriptors and almost all of its resources.
- Keeps the
task_structwith the exit code and the usage statistics. - Sends the
SIGCHLDsignal to the parent. - Marks the process as
EXIT_ZOMBIE.
The process is dead but its record is still there. That is a zombie: it consumes no CPU and no memory (beyond a few KB of structure), but it occupies an entry in the process table and a PID.
Why do zombies exist? Because the exit code is information that belongs to the parent. If the kernel deleted the record immediately, a parent that came late to ask "how did my child do?" would have nobody to ask. The zombie is the note the child leaves stuck on the fridge. The parent collects it with wait() or waitpid(), and then — and only then — it disappears.
int status;
pid_t child = waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Exited normally with code %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("Killed by signal %d\n", WTERMSIG(status));
}The macros are necessary because status is an integer with the fields packed into it: it is not directly the exit code.
| Situation | What status holds |
Macro to read it |
|---|---|---|
exit(0) |
Clean exit | WIFEXITED → WEXITSTATUS = 0 |
exit(1) |
Application error | WEXITSTATUS = 1 |
Killed by SIGKILL |
Signal 9 | WIFSIGNALED → WTERMSIG = 9 |
Killed by SIGSEGV |
Signal 11 | WTERMSIG = 11 |
Stopped with SIGSTOP |
Stopped, not dead | WIFSTOPPED |
By universal convention, 0 means success and any other value between 1 and 255 means a specific error. The shell exposes the last one in $?, and that is what makes command_a && command_b work.
Zombies and orphans
| Zombie | Orphan | |
|---|---|---|
| Who has died | The child | The parent |
| Who is still alive | The parent (but it does not call wait) |
The child |
| Problem | PID leak in the process table | Nobody will collect its exit code |
| Solution | The parent must call wait; if the parent dies, they clean themselves up |
init/systemd adopts it automatically |
Fixed with kill -9 |
No (it is already dead) | Not applicable |
Detecting them:
That Z with PPID 1877 says exactly what needs fixing: the aggregator (1877) is creating children and not reaping them. And notice the key nuance: killing the zombie achieves nothing, you have to fix the parent. If you restart process 1877, all its zombies become orphans, systemd adopts them, systemd does call wait(), and they vanish instantly.
An orphan process, by contrast, is harmless: the kernel reassigns PID 1 as its parent (or the nearest subreaper, in the case of services under systemd), and that adoptive parent has a permanent loop calling wait(). This is, in fact, the primordial function of init since 1970.
A complete example: a supervisor for the aggregator
Let us put it all together into something that could really run on meteo-01: a supervisor that launches the aggregator, waits for it to finish and restarts it if it crashes, with a retry limit.
/* supervisor.c — compile: gcc -Wall -o supervisor supervisor.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
#define MAX_RETRIES 5
#define AGGREGATOR_PATH "/opt/meteora/bin/aggregator"
static void timestamp(void) {
time_t now = time(NULL);
char buf[32];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", localtime(&now));
printf("[%s] ", buf);
}
static pid_t launch_aggregator(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return -1;
}
if (pid == 0) {
/* --- CHILD --- */
char *argv[] = { AGGREGATOR_PATH, "--interval", "3600", NULL };
char *envp[] = { "METEORA_CONF=/etc/meteora/meteora.conf", NULL };
execve(AGGREGATOR_PATH, argv, envp);
perror("execve");
_exit(127); /* convention: 127 = could not execute */
}
/* --- PARENT --- */
return pid;
}
int main(void) {
int retries = 0;
while (retries < MAX_RETRIES) {
pid_t pid = launch_aggregator();
if (pid == -1) return 1;
timestamp();
printf("aggregator launched with PID %d\n", pid);
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return 1;
}
timestamp();
if (WIFEXITED(status)) {
int code = WEXITSTATUS(status);
if (code == 0) {
printf("aggregator finished successfully. Done.\n");
return 0;
}
printf("aggregator exited with code %d\n", code);
} else if (WIFSIGNALED(status)) {
printf("aggregator killed by signal %d\n", WTERMSIG(status));
}
retries++;
timestamp();
printf("retry %d of %d in 5 seconds\n", retries, MAX_RETRIES);
sleep(5);
}
timestamp();
printf("retries exhausted. The supervisor gives up.\n");
return 1;
}Output from a run in which the aggregator ran out of memory and was killed:
[2026-08-31 04:00:01] aggregator launched with PID 1877 [2026-08-31 04:12:33] aggregator killed by signal 9 [2026-08-31 04:12:33] retry 1 of 5 in 5 seconds [2026-08-31 04:12:38] aggregator launched with PID 1993 [2026-08-31 05:00:04] aggregator finished successfully. Done.
An analysis of the design decisions, which are what separate this code from a toy example:
_exit(127)rather thanexit(127)after a failedexecve.exit()runs the handlers registered withatexitand flushes thestdiobuffers, which the child inherited from the parent through COW. That would duplicate output the parent has already printed._exit()terminates without further ceremony, which is the right thing in a child that never managed to become another program.- Code 127 is not arbitrary: it is the shell convention for "command not found". A real supervisor would distinguish this case (do not retry: the binary does not exist, retrying is pointless) from an execution failure (where retrying makes sense).
waitpid(pid, ...)rather thanwait(NULL).wait()reaps any child;waitpid()waits for exactly the one we care about. In a supervisor with several children,wait()would lead to subtle bugs.- The five-second wait avoids the frantic restart loop: if the binary fails at startup, without that pause you would do thousands of
forks per second and saturate the machine. systemd has the same mechanism (RestartSec), and for the same reason. - The retry limit avoids restarting something broken forever. systemd has that too (
StartLimitBurst), and you will see it in Services, Boot and systemd.
This program is, in miniature, what systemd does for you with every service. Writing it once saves you years of treating service managers as black boxes.
The process hierarchy and init/systemd
Since every process is born from another, all the processes on a system form a tree whose root is PID 1. On Linux, PID 1 is created by the kernel during boot and runs /sbin/init, which on modern distributions is systemd.
$ pstree -p 1 | head -12
systemd(1)─┬─aggregator(1877)─┬─{aggregator}(1878)
│ └─{aggregator}(1879)
├─ingestor(1842)───{ingestor}(1843)
├─meteo-api(1901)─┬─meteo-api(2214)
│ ├─meteo-api(2215)
│ └─meteo-api(2216)
├─sshd(892)───sshd(3399)───bash(3401)───pstree(3502)
└─systemd-journald(410)How to read it:
- The names in braces,
{aggregator}(1878), are threads, not processes: they share 1877'smm. That is howpstreedistinguishes them. meteo-apihas three children that are not in braces: they are real processes, a classic preforking model (one master process and N workers).- The chain
sshd → sshd → bash → pstreeis the complete trace of your session: the SSH daemon, the process for your connection, your shell and the command you just launched.
PID 1 is special in three ways:
- It is the universal adoptive parent: it inherits every orphan and reaps them with
wait(). - It cannot die. If PID 1 terminates, the kernel panics: there is nobody left to manage the system.
- Default signal actions do not affect it. The kernel ignores signals without an explicit handler that are aimed at PID 1, precisely so that an accidental
kill -9 1does not bring the machine down.
The context switch: what gets saved and what it costs
When the kernel decides that the CPU should stop running ingestor and start running the aggregator, a context switch happens. It is the operation that makes multitasking possible, and also one of the most expensive.
What has to be saved and restored:
| What | Where it goes | Approximate cost |
|---|---|---|
| General-purpose registers (16 on x86-64) | task_struct->thread |
~50 ns |
Instruction and stack pointers (rip, rsp) |
Kernel stack | included |
| Floating-point and SIMD registers (up to 2.5 KB with AVX-512) | FPU area | ~100-300 ns, and lazily |
Page table pointer (cr3) |
Only if the mm changes |
~100 ns + indirect cost |
| Kernel stack pointer (TSS) | Per-CPU structure | ~20 ns |
Added up, the direct cost is around 1-3 microseconds. But that is not the real cost. The bulk of it is indirect:
- TLB flush. When
cr3changes, the cached address translations become useless. The new process starts out faulting on every access until it repopulates the TLB. (Process-context identifiers, PCID, mitigate this on modern CPUs.) - Data cache pollution. The L1 and L2 cache lines are full of the previous process's data. The new one starts cold, and a miss all the way to main memory costs ~100 ns, as we saw in the memory hierarchy in 01-01.
All told, the effective cost of a context switch is between 3 and 10 microseconds. Let us do the number that matters:
Typical Linux quantum (CFS, average load): ~4 ms = 4,000 µs Context switch cost: ~5 µs Overhead: 5 / 4,005 = 0.12% If the quantum were 100 µs: Overhead: 5 / 105 = 4.8%
From this comes a rule that will reappear in the next lesson: the quantum must be much larger than the cost of a context switch, or the system spends more time switching than working. A factor of 100 to 1000 is the reasonable range.
One distinction worth nailing down: a context switch is not the same as a system call. In 01-06 we saw that a system call changes mode (user→kernel) but it is still the same process: cr3 is not touched and the task_struct does not change. It costs 50-500 ns. A context switch changes process, and costs an order of magnitude more.
You can measure the real context switches on your system:
$ vmstat 1 3 procs -----------memory---------- ---system-- ------cpu----- r b swpd free buff cache in cs us sy id wa st 2 0 0 1240132 91224 3810244 4211 8877 12 4 83 1 0 1 1 0 1239876 91224 3810988 6902 14203 18 7 71 4 0 3 0 0 1238004 91232 3811520 5108 10944 15 5 79 1 0
The cs column is context switches per second. Between 8,000 and 14,000 on a machine with an I/O workload is perfectly normal. If you saw 300,000, you would have a serious contention problem to investigate. The in column is interrupts per second, and we will come back to it in 02-07.
Hands-on inspection: /proc, ps -eo, pstree
/proc is a virtual file system: it takes up no disk space, and its files are generated on the fly by reading kernel structures. Every process has its directory at /proc/<pid>/.
$ sudo ls -l /proc/1842/ dr-x------ 2 meteora meteora 0 Aug 31 09:14 fd -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 cmdline -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 environ lrwxrwxrwx 1 meteora meteora 0 Aug 31 09:14 exe -> /opt/meteora/bin/ingestor lrwxrwxrwx 1 meteora meteora 0 Aug 31 09:14 cwd -> /var/lib/meteora -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 maps -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 stat -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 status -r--r--r-- 1 meteora meteora 0 Aug 31 09:14 limits
The most useful ones day to day:
| File | What it contains | When you use it |
|---|---|---|
status |
State, PPID, memory, threads, context switches | Always the first stop |
cmdline |
The exact command line (\0-separated) |
To know what parameters it started with |
exe |
Link to the real binary | To detect whether the binary has been replaced while running |
cwd |
Working directory | To make sense of relative paths |
fd/ |
Open descriptors | To see what files and sockets it holds |
maps |
Complete memory map | Lesson 02-03 |
limits |
Resource limits | To diagnose "too many open files" |
stack |
The process's kernel stack | To find out why it is in state D |
A complete practical case: ingestor is not saving readings and you want to know what it is doing.
$ sudo tr '\0' ' ' < /proc/1842/cmdline; echo /opt/meteora/bin/ingestor --port 9010 --conf /etc/meteora/meteora.conf $ sudo ls -l /proc/1842/fd lr-x------ 1 meteora meteora 64 Aug 31 09:16 0 -> /dev/null l-wx------ 1 meteora meteora 64 Aug 31 09:16 1 -> /var/log/meteora/ingestor.log l-wx------ 1 meteora meteora 64 Aug 31 09:16 2 -> /var/log/meteora/ingestor.log lrwx------ 1 meteora meteora 64 Aug 31 09:16 3 -> socket:[28841] l-wx------ 1 meteora meteora 64 Aug 31 09:16 4 -> /var/lib/meteora/readings/2026-08-30.dat
There is the problem, and it jumps right out: descriptor 4 points at 2026-08-30.dat, yesterday's file. The process has been running for more than a day and does not rotate the data file when the day changes. Today's readings are being written into yesterday's file. We did not even need to read the source code.
The cmdline needs tr '\0' ' ' because the arguments are separated by null bytes, not spaces; without that conversion you would see everything run together.
And ps -eo lets you build exactly the view you need:
$ ps -eo pid,ppid,stat,ni,pri,rss,etime,nlwp,comm --sort=-rss | head -6
PID PPID STAT NI PRI RSS ELAPSED NLWP COMMAND
1901 1 Ss 0 19 148320 22:14:07 1 meteo-api
1877 1 Ssl 5 14 31456 22:14:09 3 aggregator
1842 1 Ssl -5 24 18204 22:14:09 2 ingestor
892 1 Ss 0 19 9812 6-03:22:41 1 sshdColumn by column: NI is the niceness value (nice) and PRI the resulting priority — both are the subject of the next lesson; RSS is the physical memory actually occupied, in KB; ELAPSED is the time since startup (sshd has been up for 6 days); NLWP is the number of threads. An operational decision is already visible here: ingestor has NI -5 (raised priority, because losing readings from the network is irreversible) and the aggregator has NI 5 (lowered, because it can wait).
Common Mistakes and Tips
Believing that fork() copies all the memory. It does not copy it: it shares with copy-on-write and only duplicates the pages that get written. Practical consequence: fork() on an 8 GB process is fast, but if the child writes all over the place you will end up paying for the copy anyway. And there is a treacherous case: if the system does not allow overcommit, fork() can fail with ENOMEM even though it is not going to copy anything, because the kernel reserves just in case.
Forgetting wait() in a process that creates children. It is the number one cause of zombies. The minimal solution in a daemon is to install a SIGCHLD handler that calls waitpid(-1, NULL, WNOHANG) in a loop until it returns 0, or simply signal(SIGCHLD, SIG_IGN) if you do not care about the exit codes (this tells the kernel not to generate zombies).
Trying to kill a zombie with kill -9. It does not work and never will: it is already dead. What you have to fix is the parent. And if you are in a hurry, restarting the parent turns its zombies into orphans, and systemd cleans them up instantly.
Confusing state R with "consuming CPU". R includes those waiting their turn. To find out who is really consuming CPU, look at the %CPU column in top or ps, not the state.
Panicking at a huge VSZ. A process with a VSZ of 4 GB and an RSS of 50 MB is perfectly normal: it reserves a lot of address space (which is free) and uses little physical memory. The metric that matters for RAM is RSS, and even that comes with caveats we will see in 02-04.
Using exit() instead of _exit() in a child after fork. The child inherited the parent's stdio buffers; exit() flushes them and you duplicate output that was already written. In the child, always _exit().
Diagnostic tip: when a process "has hung", the order that works is: ps -o stat to see the state, cat /proc/<pid>/stack if it is in D (it tells you what kernel function it is stuck in), strace -p <pid> if it is in S (it tells you what system call it is waiting on), and perf top -p <pid> if it is in R at 100% (it tells you what code is burning the CPU). Each state is investigated with a different tool.
Exercises
Exercise 1: predict the output of a nested fork
Given this program, how many processes are created in total (counting the original) and how many times is the letter printed? Explain why.
Then answer: if you replace printf("M\n") with printf("M") (no newline) and redirect the output to a file, does the number of letters printed change? Why?
Exercise 2: diagnosing zombie processes
On meteo-01 you observe this:
$ ps -eo pid,ppid,stat,etime,comm
PID PPID STAT ELAPSED COMMAND
1 0 Ss 6-04:11:02 systemd
1877 1 Ssl 22:14:09 aggregator
4021 1877 Z 03:12 rotator
4088 1877 Z 02:11 rotator
4155 1877 Z 01:10 rotator
4222 1877 Z 00:09 rotator- What exactly is going on?
- How often does the problem occur, and what does that tell you about the design of the
aggregator? - What would happen if you left the system like this for a week? Work it out.
- Give two solutions: an immediate one as an operator and a correct one as a developer.
Exercise 3: reading a process's real state
Write a command that shows, for all processes belonging to the meteora user, the PID, the state, the number of threads, and the voluntary and involuntary context switches, sorted by involuntary context switches from highest to lowest. Then interpret what it would mean if the aggregator had 200,000 involuntary switches and only 300 voluntary ones.
Solutions
Solution 1
Four processes are created in total and four letters are printed.
The reasoning step by step:
- After the first
fork()there are 2 processes: P and A. - Both execute the second
fork(), because the child continues right after theforkthat created it. P creates B, A creates C. - All 4 reach the
printfand each prints once. Total: 4 lines.
The general formula is 2^n processes for n consecutive fork()s with no conditionals.
On the second part: yes, it changes, and more than 4 can end up being printed. This is the subtle detail:
- With
\nand output to a terminal,stdiouses line buffering: everyprintfflushes immediately. 4 letters. - Without
\nand output to a file,stdiouses full buffering of 4096 bytes. The "M" stays in the user-space buffer and is only written when the program ends. - The problem: the
stdiobuffer lives in the process's address space, so it is inherited across thefork. If the order wereprintf("M"); fork();, the child would inherit a buffer that already contains "M" and would write it too: you would see more letters than expected.
In the code as written (the forks come before the printf) it is still 4 letters, but the experiment reveals the underlying reason why a child uses _exit() and not exit(), and why it is a good idea to call fflush(NULL) before a fork if there is pending output. It is a real and baffling bug when it shows up in production.
Solution 2
1. What is going on. The aggregator (PID 1877) launches child processes called rotator — presumably to rotate the daily file in /var/lib/meteora/readings/ — and never calls wait(). The children finish their work correctly, but their task_structs stay in state Z because nobody collects their exit code. The children are not hung: they are dead and unburied.
2. How often. Looking at the ELAPSED column: 03:12, 02:11, 01:10, 00:09. The differences are approximately 61 seconds. That is, one per minute. This indicates that the aggregator has a loop or a timer that launches the rotator every minute, which is suspicious in itself: rotating a daily file does not require checking it every 60 seconds, and it suggests a design with a fork inside the main loop.
3. A week like this. The calculation:
The default PID limit on Linux (/proc/sys/kernel/pid_max) is usually 32,768 in conservative configurations. With that value:
That is, in a week you would have 60,480 zombies... except that the limit of 32,768 is reached first, around day 22 if nothing else were consuming PIDs. But the disaster does not wait for that moment: PIDs are assigned in a circular fashion, so long before they run out you will start seeing fork: Cannot allocate memory on any new command, including the ssh you would use to log in and fix it. That is the genuinely ugly scenario: the system has not gone down, but you cannot run anything on it.
4. The two solutions.
Immediate, as an operator: restart the parent process.
When 1877 dies, its zombies become orphans, systemd adopts them and its wait() loop cleans them up instantly. Killing the zombies directly with kill -9 4021 would do nothing.
Correct, as a developer: have the aggregator reap its children. The most robust approach is a SIGCHLD handler:
#include <signal.h>
#include <sys/wait.h>
static void reap_children(int sig) {
(void)sig;
int saved = errno; /* preserve errno */
while (waitpid(-1, NULL, WNOHANG) > 0) /* loop: several may arrive together */
;
errno = saved;
}
/* during initialization */
struct sigaction sa = { 0 };
sa.sa_handler = reap_children;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);Three details make this work properly: the loop is essential because signals are not queued (if three children die at almost the same time, a single SIGCHLD may arrive); WNOHANG stops the handler from blocking when there is nothing left to reap; and preserving errno avoids corrupting the value the interrupted code was working with.
If the exit code is of no interest at all, the one-line alternative is signal(SIGCHLD, SIG_IGN), which tells the kernel to discard the children without creating zombies.
Solution 3
The command:
$ ps -u meteora -o pid,stat,nlwp,comm --no-headers | while read pid rest; do
vol=$(awk '/voluntary_ctxt/ {print $2}' /proc/$pid/status | head -1)
inv=$(awk '/nonvoluntary_ctxt/{print $2}' /proc/$pid/status)
echo "$inv $pid $rest vol=$vol"
done | sort -rnTypical output:
291 1877 Ssl 3 aggregator vol=18422 118 1842 Ssl 2 ingestor vol=94013 44 1901 Ss 1 meteo-api vol=210884
A shorter alternative if your ps supports it, using the extended format:
although for the context switch counters there is no way around going to /proc/<pid>/status, because ps does not expose them.
Interpreting the case described (200,000 involuntary, 300 voluntary).
The two types mean opposite things:
| Type | When it happens | What it indicates |
|---|---|---|
| Voluntary | The process blocks waiting for I/O | I/O-bound |
| Involuntary | Its quantum expires or someone higher-priority arrives | CPU-bound and facing competition |
An aggregator with 200,000 involuntary and only 300 voluntary switches says three things clearly:
- It hardly ever waits for I/O. With 300 voluntary blocks over hours of execution, it barely touches the disk or the network: it has loaded the data and it computes.
- It is a CPU-bound process that wants to run continuously.
- There is real competition for the CPU. 200,000 preemptions mean the scheduler keeps taking it away because there are other runnable processes. If the
aggregatorwere the only active process, its involuntary count would be low.
The operational conclusion: the aggregator is competing with ingestor and meteo-api for the CPU, and since its tasks are deferred (hourly averages) while the other two are latency-sensitive, the right decision is to lower its priority, not raise it:
That does not take CPU away from it when the machine is idle — it will still use all of it — but it guarantees that it yields when ingestor has readings to attend to. Exactly why it works that way, and what the scheduler does with that number, is precisely the subject of the next lesson.
Conclusion
A program is a passive file; a process is that program alive, with a memory image in four regions — shared read-only code, data, a heap that grows upward and a stack that grows downward — and a record in the kernel. That record is the PCB, which on Linux is task_struct: about 7 KB holding the identity, the state, the scheduling data, the CPU context, the pointer to the address space, the descriptor table and the credentials. The fact that mm is a pointer is what will make threads possible.
The states are not a theoretical whim: they reflect that the CPU is a scarce resource and that blocking on I/O must release it. The real ps codes refine the model, and two are especially revealing: R means runnable, not running, and D is an uninterruptible sleep on disk that not even kill -9 gets you out of.
Creation through fork() + execve() looks strange until you see what it enables: between the duplication and the replacement there is a window in which the child can change user, redirect descriptors or apply limits before turning into another program. And copy-on-write makes duplicating a multi-gigabyte process cost half a millisecond. Zombies exist because the exit code belongs to the parent, and they are fixed by fixing the parent; orphans are adopted by PID 1, which is the original purpose of init. The context switch costs between 3 and 10 µs once you count the TLB and cold caches, which settles once and for all the order of magnitude of the quantum.
You now know what a process is, how it is born, what states it lives in and what it costs to switch from one to another. What is missing is the question we have been deferring in every section: when there are ten processes in state R and four cores, which one gets the CPU, for how long, and on what criterion? That decision, taken thousands of times per second, is what we will see in CPU Scheduling, where we will finally understand what that renice -n 10 we closed the last exercise with really does.
Operating Systems Fundamentals
Module 1: Introduction to Operating Systems
- Basic Concepts of Operating Systems
- History and Evolution of Operating Systems
- Types of Operating Systems
- Main Functions of an Operating System
- Kernel Architecture: Monolithic, Microkernel and Hybrid
- User Mode, Kernel Mode and System Calls
Module 2: Resource Management
- Process Management
- CPU Scheduling
- Memory Management
- Virtual Memory and Paging
- Storage Management
- Device Management
- Drivers, Interrupts and I/O Operations
Module 3: Concurrency
- Concurrency Concepts
- Threads and Processes
- Inter-Process Communication (IPC)
- Synchronization and Mutual Exclusion
- Classic Concurrency Problems
- Deadlocks: Prevention, Detection and Recovery
Module 4: File Structures
- File Systems
- Directory Structures
- Partitions, Mounting and the Virtual File System
- File Management
- Space Allocation, Journaling and Integrity
- File Security and Permissions
Module 5: System Protection and Security
- Protection Principles and Access Control
- Users, Authentication and Privilege Escalation
- Common Threats and System Hardening
- Auditing, Logging and Incident Response
Module 6: Virtualization and Containers
- Virtualization: Hypervisors and Virtual Machines
- Containers: Namespaces and cgroups
- The Operating System in the Cloud
- Mobile and Real-Time Operating Systems
