Foundation · Core

Operating Systems

The kernel as an operator sees it — the syscall boundary, the scheduler, the page cache, cgroups, and the failure modes each one produces in production.

26 min read Level: core → advanced Foundation 02 / 10
The model

WHAT SITS BETWEEN YOU AND THE HARDWARE

Everything a process does that it cannot do itself crosses this boundary exactly once.

USER SPACEYour processlibc / runtimeShared libsvDSOTHE BOUNDARYsyscall instructionTrap → ring 0seccomp filterKERNEL: PROCESSScheduler (EEVDF)task_structSignalscgroupsnamespacesKERNEL: MEMORYVirtual memoryPage cacheWritebackOOM killerKERNEL: I/OVFSFilesystemBlock layerNet stackDRIVERSDevice driversIRQ handlersHARDWARECPU / MMURAMDiskNIC

Containers do not add a layer to this diagram. They are the cgroups and namespaces boxes applied to an ordinary process — which is why a container problem is always a Linux problem underneath.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat does the kernel do between your read() and the disk?
KERNEL, ON ONE READ()read(fd, buf, n)process contextbytes into bufblocked →schedulerENTRYsyscall boundaryuser → kernel modefd table lookupper-processpermission checkFILESYSTEMVFSone API, many filesystemspage cachehit means no disk at allfilesystem driverext4 / xfsBLOCKblock layer + I/O schedulermerge and reorderdevice driverthe actual device
The page cache is the hop that decides everything. A hit returns in microseconds and never reaches the block layer; a miss parks your process in D state where even kill -9 will not touch it.
ConnectionHow does a process get to run, and how does it stop running?
process createdrun queuefork / clonescheduler picks itCFS vEFTrunning on a CPUcontext switchblocks on I/Osyscall sleepswait queueD statewoken by completionIRQback to run queue
Load average counts the boxes in the run queue AND the ones in D state, which is why a machine with idle CPUs can show a load of 40: nothing is computing, everything is waiting on a disk.
Core

CORE CONCEPTS

The four abstractions that every production incident eventually comes back to.

Your process runs in user mode and cannot touch hardware, other processes' memory, or the page tables. Everything it needs from the outside world goes through a system call: it puts a number in rax, arguments in registers, and executes the syscall instruction. The CPU switches to ring 0, jumps to a fixed kernel entry point, and the kernel does the work on the process's behalf.

The base cost is roughly 50–100 ns, meaningfully more since Spectre/Meltdown mitigations added page-table isolation. That is cheap once and ruinous a million times a second — which is the entire reason io_uring, batched writes and buffered I/O exist.

The vDSO shortcut

Some calls don't need the kernel at all. gettimeofday() and clock_gettime() are served from the vDSO — a small shared page the kernel maps into every process, containing data the kernel keeps updated. The call becomes an ordinary function call at a few nanoseconds instead of a trap. If you ever wonder why timestamping every log line is affordable, this is why.

watching the boundary
$ strace -c -p 4412
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
61.28 2.914021 3 971340 futex
22.10 1.050882 2 525441 epoll_wait
9.44 0.448911 4 112228 write
971k futex calls means lock contention, not I/O. Look at the locking,
not the disk — strace -c is the fastest way to find that out.

Linux schedules tasks. A process and a thread are both a task_struct; the only difference is how much they share. fork() creates a task with a copy of the address space, clone() with CLONE_VM creates one that shares it. "Thread" is a userspace word for a task that shares memory with its siblings.

Copy-on-write fork

fork() does not copy your 8 GB heap. It marks every page read-only in both parent and child and copies a page only when one of them writes to it. This is why forking a large process is fast — and why a forked child can still trigger an out-of-memory kill minutes later, when the writes finally arrive. Redis's background save is the canonical example.

The scheduler

Since kernel 6.6 the default is EEVDF (Earliest Eligible Virtual Deadline First), replacing CFS. Both are fair-share designs: each runnable task accrues virtual runtime, and the one that has had least gets the CPU. EEVDF adds an explicit deadline so latency-sensitive tasks can be served ahead of throughput-hungry ones rather than merely fairly.

What matters operationally is unchanged: fair-share means nobody is starved and nobody is guaranteed. If you need a guarantee, you need SCHED_FIFO, cgroup CPU bandwidth, or pinning — not a nice value.

StateIn psWhat it means
Running / runnableROn a CPU, or in the run queue waiting for one
Interruptible sleepSWaiting on I/O or an event; signals wake it
Uninterruptible sleepDIn a kernel path that cannot be interrupted — usually disk or NFS. Persistent D state is a storage problem
StoppedTSIGSTOP, or under a debugger
ZombieZExited, but the parent hasn't called wait(). Harmless singly, a PID leak in bulk

The kernel uses every spare byte of RAM as page cache — a cache of file contents. A box showing 200 MB free and 60 GB cached is not short of memory; it is doing exactly what it should. Cache is reclaimable on demand. The number to watch is available in /proc/meminfo, which accounts for that.

Reading the memory columns

  • VSZ — everything mapped, including memory never touched and files mapped but not read. Nearly meaningless for capacity.
  • RSS — resident pages, but shared pages are counted in full against every process sharing them. Sum the RSS of 20 workers and you'll double-count libc twenty times.
  • PSS — proportional set size; shared pages divided among sharers. This is the number you want when asking "how much is this process really costing me".

Writeback

A buffered write returns as soon as the page is marked dirty. Flushing happens later, governed by vm.dirty_ratio and vm.dirty_background_ratio. When dirty pages exceed dirty_ratio, writers are throttled synchronously — the application stalls in the kernel until writeback catches up. On a box with lots of RAM and a slow disk, the defaults let gigabytes accumulate and then stall everything at once. Lowering the ratios trades a little throughput for far less latency variance.

reading memory honestly
$ free -h
total used free shared buff/cache available
Mem: 125Gi 48Gi 1.2Gi 892Mi 76Gi 75Gi
free 1.2Gi looks alarming; available 75Gi is the truth.
$ grep -E 'Dirty|Writeback' /proc/meminfo
Dirty: 4194304 kB
4 GB of dirty pages waiting on a disk that does 200 MB/s = a 20s stall
$ sysctl vm.dirty_ratio vm.dirty_background_ratio
$ grep Pss /proc/4412/smaps_rollup

A file descriptor is a small integer indexing a per-process table. Behind it the VFS presents one interface — read, write, seek, close — over regular files, sockets, pipes, devices, epoll instances, timers, even other processes' memory. That uniformity is why strace is so useful: almost everything a process does is a read or a write to some fd.

Descriptors are a hard-limited resource, and the limit is per-process, inherited at exec, and usually far lower than people expect. A service that leaks one fd per request will run for hours and then fail all at once with EMFILE — accept() starts failing while the process looks perfectly healthy.

fd accounting
$ ls /proc/4412/fd | wc -l
1021
$ cat /proc/4412/limits | grep 'open files'
Max open files 1024 1048576 files
soft limit 1024 and 1021 in use — this fails within the minute
$ ls -l /proc/4412/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head
847 socket:[8823191]
847 sockets: connections not being closed. Raising the limit buys
time; it does not fix a leak.
Advanced

ADVANCED

Container behaviour, memory accounting, signals and the modern I/O paths.

A container is a normal Linux process with two things done to it. Namespaces change what it can see; cgroups limit what it can use. There is no container object in the kernel, which is exactly why containers start in milliseconds and why a container escape is a kernel bug rather than a hypervisor bug.

The namespaces

  • pid — its own PID 1 and process tree. Your process is 1 inside, 48122 outside.
  • net — its own interfaces, routes, iptables rules, port space.
  • mnt — its own mount table; the root filesystem it sees.
  • uts — its own hostname.
  • ipc — its own shared memory and semaphores.
  • user — UID mapping; root inside, unprivileged outside. The one that makes rootless containers possible.
  • cgroup — hides the host's cgroup hierarchy from the process.

Why PID 1 matters inside

PID 1 has special duties: it reaps orphaned children and it does not get default signal handlers. Run an application as PID 1 without thinking about it and you get two classic bugs — zombie processes accumulating because nothing reaps them, and SIGTERM being ignored so every deploy waits the full termination grace period and then gets SIGKILLed. That is what --init and tini exist to fix.

a container is just a process
$ docker run -d --name web --memory=512m nginx
$ docker inspect -f '{{.State.Pid}}' web
48122
$ ls -l /proc/48122/ns/
lrwxrwxrwx ... net -> 'net:[4026532281]'
lrwxrwxrwx ... pid -> 'pid:[4026532283]'
$ cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
536870912
It's PID 48122 on the host, with different namespace inodes and a
cgroup memory ceiling. That is the entire trick.

Two different OOM paths exist and they behave differently. Global OOM fires when the whole machine is out and the kernel picks a victim by oom_score — roughly, whoever is using the most, adjusted by oom_score_adj. cgroup OOM fires when a single cgroup hits its memory.max, and kills something inside that cgroup only. The host has plenty of free memory; the container still dies.

This is the entire explanation for the most confusing Kubernetes failure mode: a pod is OOMKilled while the node's memory graph shows 40% used. The node was never the constraint. The container's limit was.

What counts against the limit

Under cgroup v2, the charge includes anonymous memory and page cache the cgroup caused. A process that reads a lot of files can be OOMKilled for cache it does not need and would happily give back — the kernel does try to reclaim first, but a fast enough reader can outrun reclaim. This is why a batch job that streams large files needs a limit well above its apparent working set.

post-mortem on a kill
$ dmesg -T | grep -i -A3 'killed process'
[Wed Sep 10 04:12:08] Memory cgroup out of memory: Killed process 48901 (java)
total-vm:9812344kB, anon-rss:4103288kB, file-rss:18244kB
'Memory cgroup out of memory' — a limit, not the node.
$ cat /sys/fs/cgroup/.../memory.events
low 0
high 2841
max 19
oom 3
oom_kill 1
high 2841 = it was throttled under reclaim pressure 2841 times before
it died. That pressure was visible for a long time first.

Signals are the kernel's interrupt mechanism for processes. Most have a default disposition: SIGTERM terminates, SIGKILL terminates and cannot be caught, SIGSEGV dumps core. A process can install a handler for anything except SIGKILL and SIGSTOP.

PID 1 is the exception. The kernel does not apply default dispositions to it. A process running as PID 1 with no explicit SIGTERM handler simply ignores the signal. Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds (30 by default), then SIGKILLs. If your entrypoint is the application itself and it has no handler, every single pod deletion takes the full 30 seconds and ends in a hard kill — connections dropped, in-flight requests lost, and a rolling deploy that takes half an hour.

Shell-form entrypoints swallow signals

ENTRYPOINT ./app in shell form becomes /bin/sh -c ./app. The shell is PID 1, your app is a child, and sh does not forward signals to it. Use exec form — ENTRYPOINT ["./app"] — or exec ./app in your wrapper script. This one line is behind a remarkable proportion of "why are deploys so slow" investigations.

proving it
$ kubectl exec -it web -- ps -o pid,comm
PID COMMAND
1 sh
7 node
node is PID 7. SIGTERM goes to sh, which does nothing with it.
$ time kubectl delete pod web
pod "web" deleted
real 0m30.4s
30 seconds every time = the grace period expiring, then SIGKILL

Buffered I/O is the default: reads are served from page cache when possible, writes return once dirty. Great throughput, unpredictable latency, and a copy between kernel and user buffers on every call.

Direct I/O (O_DIRECT) bypasses the page cache entirely and DMAs straight into your buffer. Databases use it because they maintain a better cache than the kernel can — they know which pages matter. It demands aligned buffers and aligned offsets, and it is slower for anything that would have hit cache.

io_uring replaces the syscall-per-operation model with two shared ring buffers between userspace and kernel. You write submission entries into a ring, the kernel writes completions into another, and with polling mode you can do sustained I/O with zero syscalls. For anything issuing hundreds of thousands of operations a second, this is the difference between spending 40% of your CPU in kernel entry overhead and spending almost none.

PathSyscalls per opPage cacheUse when
Buffered read/write1+YesAlmost everything. Default for a reason.
mmap0 after mapYesRandom access to a file that fits in RAM; beware page-fault stalls
O_DIRECT1+BypassedYou maintain your own cache — databases, mostly
io_uring~0 (batched)OptionalVery high IOPS, or many concurrent ops from few threads
In practice

TRIAGING A SLOW PROCESS

A process is slow and you have sixty seconds. This is the order that finds the answer fastest, because each step rules out an entire class of cause.

triage in four commands
1. Is it even running, or is it blocked?
$ ps -o pid,stat,wchan:20,comm -p 4412
PID STAT WCHAN COMMAND
4412 D io_schedule postgres
D + io_schedule = blocked on disk. Stop looking at the CPU.
2. What is it asking the kernel for?
$ strace -c -f -p 4412 -- sleep 10
3. Is the machine or the cgroup the constraint?
$ cat /sys/fs/cgroup/$(cut -d: -f3 /proc/4412/cgroup | tail -1)/memory.events
$ cat /sys/fs/cgroup/.../cpu.stat | grep throttled
nr_throttled 88213
throttled_usec 41028339
CPU throttling: the limit is the problem, not the code.
4. Where is the time actually going?
$ perf record -F 99 -g -p 4412 -- sleep 20 && perf report --stdio | head -30
cpu.stat before anything else, in a container

In Kubernetes, nr_throttled climbing is the single most common cause of "the app is slow and the CPU graph looks fine". CFS bandwidth control gives the cgroup a quota per 100 ms period; burn it in 20 ms and the process is frozen for the remaining 80 ms. The average utilisation looks like 20%. The p99 latency looks like a disaster. Both are true.

Reference

CHEATSHEET

CommandWhat it answers
ps -o pid,stat,wchan:25,comm -p PIDRunning, sleeping, or stuck — and in which kernel function
strace -c -f -p PIDWhich syscalls dominate, and which are erroring
ltrace -p PIDLibrary calls, when the syscalls look innocent
cat /proc/PID/statusThreads, signal masks, VmRSS, context switch counts
cat /proc/PID/limitsEvery rlimit, soft and hard
cat /proc/PID/stackKernel stack of a D-state process — what it's blocked in
grep Pss /proc/PID/smaps_rollupHonest per-process memory, shared pages apportioned
ls -l /proc/PID/ns/Which namespaces it's in — is it actually containerised?
cat /proc/PID/cgroupWhich cgroup, so you can find its limits
cat <cgroup>/cpu.statnr_throttled — CPU-limit throttling
cat <cgroup>/memory.eventshigh, max, oom_kill counts
dmesg -T | grep -i oomWho got killed, by which OOM path, and how big they were
vmstat 1Run queue, context switches, swap, io wait, at a glance
pidstat -d -p PID 1Per-process read/write throughput
sysctl -a | grep dirtyWriteback thresholds — the stall-under-load knobs
perf record -F 99 -g -p PIDSampled stacks; the only honest answer to 'where is the time'