S060 · OS Basics — Processes, Threads, Memory, FDs
The four abstractions the OS gives you and why every senior debugging story starts with one of them. Processes, threads, virtual memory, and file descriptors — with real strace output, htop shots, and the incidents each one caused.
🎯 Explain how the Linux kernel schedules processes, allocates memory, and tracks file descriptors — then use ps, top, lsof, and strace to diagnose a real production symptom (‘Too many open files’).
Why this session exists
Every debugging story senior engineers tell — memory leaks, OOM kills, ‘too many open files’, threads deadlocking, forks that eat the box — eventually traces to one of four OS abstractions: processes, threads, virtual memory, or file descriptors. If you know how each one is represented and rationed by the kernel, most production mysteries stop being mysteries. This is the session that makes every future systems session cheaper.
- Explain the difference between a process and a thread, and give one workload that suits each.
- Read `ps`, `top`, `htop`, `lsof` output and identify what's expensive without googling.
- Describe virtual memory in one paragraph — pages, mapping, RSS vs VSZ, page faults.
- Diagnose ‘Too many open files (EMFILE)’ in under 5 minutes end-to-end.
- Trace a running process with `strace` and pick out the interesting syscalls.
Prerequisites
- S001 · Dev environment — you have a Linux/WSL shell.
- S003 · Command line basics — you're comfortable with pipes and grep.
(a) Intuition · 5 min
Imagine one shared office with 8 desks (CPU cores) and 1000 employees (processes). The office manager (the kernel) decides who gets a desk right now, who has to wait, who's on lunch, and who's asleep. Every employee gets a badge (PID) and a locker (memory pages) — even though the office only has physical space for a fraction of them at once.
Each employee can also spawn assistants (threads) who share the same locker. The manager tracks every phone call in progress (file descriptors), every conversation, every timer. Nothing happens without going through the manager.
The OS gives you four rationed resources: CPU time (via the scheduler), memory (via virtual pages), I/O handles (file descriptors), and identity (users, groups, capabilities). Every syscall is a request to the manager for one of these.
Understanding how each is allocated, tracked, and reclaimed lets you predict every ‘why is my program slow / crashing / hanging?’ debugging session before you open the code.
The four things to internalise
- Process — isolated memory + FD table + credentials. The unit of ‘program running’. Created via fork()/exec().
- Thread — shares memory + FDs with siblings, has its own stack + registers. Cheap concurrency, dangerous data races.
- Virtual memory — every process thinks it owns the whole address space. Kernel maps pages to physical RAM on demand.
- File descriptor — small integer indexing into a per-process table of open files/sockets/pipes/etc. Bounded per process (`ulimit -n`).
- PID + credentials — every process has a PID, UID/GID, cgroups, capabilities. This is how the kernel decides what you can touch.
How Unix's model got here
- 1969Unix at Bell LabsKen Thompson & Dennis Ritchie ship the first real multi-user OS with the process/file/user model still in use today.
- 1985POSIX threads standardisedConcurrent execution within a process becomes portable across Unix variants.
- 1991Linux 0.01 · Linus TorvaldsFree Unix kernel. Follows the POSIX process/FD/memory model and eventually surpasses commercial Unixes.
- 2007cgroups + namespacesGoogle (via LMCTFY / Borg) contributes resource isolation primitives. Foundation for Docker + Kubernetes.
- 2016io_uring proposedNew async I/O API that finally lets Linux match FreeBSD/Windows on high-fanout server workloads.
(b) Visual walkthrough · 15 min
The four tables the kernel maintains per process
The task_struct is Linux's per-thread record. Threads in the same process share the memory descriptor and FD table — that's what ‘shared memory’ means at the OS level.
Process vs thread — the real difference
Isolated address space
- Own memory pages — no sibling can touch them
- Own FD table (usually) — sockets don't leak between processes
- Crash = only this process dies
- IPC required for cooperation (pipes, sockets, shared memory)
- Heavier to create (~1 ms + copy-on-write pages)
Shared address space
- Shared memory — every thread sees every variable
- Shared FD table — one thread closing an FD closes it for all
- Crash = the whole process dies
- Cheap communication (just read the same variable)
- Cheap to create (~10 µs), heavier if you have thousands
Virtual memory — the piece everyone gets wrong
How your `int x = 42;` actually lands in RAM
File descriptors — the whole cascade
Smallest unused FD in the per-process table. 0/1/2 = stdin/stdout/stderr.
dup(), fork(), and sendfd all increment. Actual close only happens when count = 0.
Default soft limit is often 1024 — laughably low for any server.
‘Too many open files’ (EMFILE = per-process; ENFILE = system-wide). Common at scale.
lsof -p PID | wc -l tells you exactly how many. /proc/PID/fd/ shows what.
"A process and a thread are basically the same thing to the kernel — a thread is just a lightweight process. And more threads means more parallelism."
On Linux both are tasks to the scheduler, but they differ in exactly one decisive way: what they share. Threads share an address space; processes do not. That single difference produces every practical consequence — data races, cheap communication, and the fact that one thread's segfault kills the whole process. And more threads means more concurrency; parallelism is capped by the number of cores, after which extra threads only add context-switch and cache-pressure cost.
The myth is sticky because at the API level they look symmetric — fork() and pthread_create() both give you another thing that runs. And on Linux they are literally the same syscall (clone()) with different flags, so "same thing" is technically defensible at the kernel level while being catastrophically wrong at the application level. The address-space flag is the whole story.
See that the difference is memory sharing, not weight:
# threads of a process share the same memory map
ls /proc/$PID/task/ # one dir per thread
cat /proc/$PID/maps | wc -l # ONE address space for all of them
# spawn threads beyond core count and watch involuntary switches climb
pidstat -w -p $PID 1
# cswch/s = voluntary (blocked on I/O)
# nvcswch/s = involuntary (preempted - you have too many runnable threads)Rising involuntary context switches with flat throughput is the signature of more threads than cores doing CPU-bound work.
Why does a system call cost so much more than a function call, when both are "just a jump to some code"?
- 1The kernel must protect its memory and hardware access from user programs, or any process could read another's data and drive the disk directly.forced by · multi-user, multi-process isolation is the fundamental guarantee an OS exists to provide
- 2Protection is enforced by the CPU itself via privilege rings: user code physically cannot execute privileged instructions or touch kernel pages.forced by · a software-only check could be bypassed by jumping past it; the barrier must be in hardware
- 3Therefore requesting a kernel service requires a controlled privilege transition through a single fixed entry point the kernel defines — not an arbitrary jump.forced by · if user code could jump anywhere in the kernel, the protection boundary would be decorative
- 4That transition must save user register state, switch stacks, change privilege level, validate every argument as untrusted, and reverse all of it on return.forced by · the kernel cannot trust a single byte from user space, and must be able to restore the caller exactly
- 5It also disturbs caches, TLB entries and branch predictors, so the cost persists after the return as slower user-space execution.forced by · the kernel executes real code that evicts your working set from the caches you warmed
Therefore a syscall is expensive because it is a hardware-enforced trust boundary crossing, and that expense is the price of isolation. It is not overhead an implementation could optimise away without giving up the guarantee.
And note what this predicts: every major I/O performance technique is fundamentally about amortising or eliminating this crossing. Buffered I/O batches many small writes into one syscall. epoll reports many ready sockets per call instead of one. io_uring uses shared memory ring buffers so submissions need no syscall at all. mmap removes the crossing from the read path entirely by mapping pages into your address space. They look like unrelated inventions; they are five answers to the same derived constraint. Once you see it, you can predict where the next optimisation will come from.
Your program lives in a sandbox with a virtual address space that looks like it owns the machine. Every interaction with something real — memory pages, files, sockets, other processes, the clock — is a request through a guarded gate. The kernel decides who gets the CPU, which pages are resident, and when your I/O completes.
Every performance mystery resolves to one of four resources: CPU, memory, disk I/O, network. Identify which one is saturated before touching any code.
- Blocked and runnable are entirely different problems. A thread waiting on I/O consumes no CPU and adds no scheduling pressure; a runnable thread with no free core does. High load average with low CPU utilisation means processes are blocked (usually on disk), and adding threads makes it worse.
- Virtual memory means allocation is not usage. Pages are mapped lazily and backed on first touch, which is why RSS matters and VSZ mostly doesn't, and why a process can allocate far more than physical RAM without immediate consequence — until it touches it.
- The page cache makes most "disk" reads memory reads. This is why a second run of the same query is dramatically faster with no code change, and why benchmarking without dropping caches measures the wrong thing entirely.
- Sizing follows the bottleneck: CPU-bound work wants roughly one thread per core, I/O-bound work wants many more because threads spend their time blocked. Using one number for both is how thread pools get misconfigured.
Fire this model when you see: load average far above core count · a service slowing down with more worker threads · an OOM kill on a box with free memory shown in top · a benchmark that is fast on the second run · a process stuck in D state.
How do you handle ten thousand concurrent connections: a thread per connection, or an event loop with non-blocking I/O?
Choose by the ratio of waiting to computing. Mostly waiting favours an event loop; substantial computation per request favours threads, because you cannot escape needing a core for each concurrent computation regardless of the model.
The failure that actually happens in production is subtler than picking wrong: it is choosing an event loop and then calling something blocking inside it. Throughput collapses under load and the profile looks innocent because the CPU is idle — everything is waiting on one stalled loop. If you go async, the discipline is absolute: nothing blocking on the loop thread, ever, and that must be enforced in review because no test will catch it.
(c) Hands-on · 25 min
We'll write a Python program that intentionally leaks FDs, watch the count climb, hit EMFILE, and fix it — using lsof and strace as our microscope.
"""
os_demo.py — Demonstrate FDs, processes, threads, and memory.
Run:
python os_demo.py fdleak # deliberately leak file descriptors
python os_demo.py mem # allocate memory, watch RSS grow
python os_demo.py fork # fork children, print PIDs
python os_demo.py thread # spawn threads, watch task list
"""
from __future__ import annotations
import os
import resource
import sys
import time
def show_limits() -> None:
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"[limits] pid={os.getpid()} soft nofile={soft} hard nofile={hard}")
def open_fd_count() -> int:
"""Count open file descriptors for this process via /proc."""
try:
return len(os.listdir(f"/proc/{os.getpid()}/fd"))
except FileNotFoundError:
# macOS: /proc doesn't exist; skip the count
return -1
def fdleak() -> None:
"""Open new files without closing. Watch it hit EMFILE."""
show_limits()
files = []
try:
while True:
f = open("/dev/null", "r") # each open() grabs a new FD
files.append(f)
if len(files) % 100 == 0:
print(f"[fdleak] opened={len(files)} fds_reported={open_fd_count()}")
except OSError as e:
print(f"\n[fdleak] hit OS error at {len(files)} files: {e}")
print(f"[fdleak] this is why servers set ulimit -n to 65535+ in prod")
def mem() -> None:
"""Grow RSS by appending to a bytearray."""
show_limits()
buf = bytearray()
chunk = b"x" * (1024 * 1024) # 1 MB
for i in range(1, 501):
buf += chunk
if i % 50 == 0:
rss_kb = int(open(f"/proc/{os.getpid()}/status").read().split("VmRSS:")[1].split()[0])
print(f"[mem] appended {i} MB RSS={rss_kb/1024:.1f} MB")
def fork_demo() -> None:
"""Show how fork() creates a child that inherits FDs."""
print(f"[fork] parent pid={os.getpid()} ppid={os.getppid()}")
pid = os.fork()
if pid == 0:
# child
print(f"[fork] child pid={os.getpid()} ppid={os.getppid()}")
print(f"[fork] child sees the same open FDs: {open_fd_count()}")
os._exit(0)
else:
# parent
print(f"[fork] parent spawned child pid={pid}")
os.waitpid(pid, 0)
print("[fork] child reaped")
def thread_demo() -> None:
"""Threads share memory + FDs. Verify with /proc/self/task/."""
import threading
def worker(i: int) -> None:
time.sleep(0.3)
print(f" thread {i} tid={threading.get_ident()} sees fds={open_fd_count()}")
print(f"[thread] main pid={os.getpid()} fds={open_fd_count()}")
ts = [threading.Thread(target=worker, args=(i,)) for i in range(4)]
for t in ts: t.start()
# /proc/self/task shows every kernel-level task (thread) in this process
tasks = os.listdir(f"/proc/{os.getpid()}/task")
print(f"[thread] /proc/self/task/ has {len(tasks)} entries → all my threads")
for t in ts: t.join()
CMDS = {"fdleak": fdleak, "mem": mem, "fork": fork_demo, "thread": thread_demo}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
print(f"usage: python os_demo.py [{'|'.join(CMDS)}]")
sys.exit(1)
CMDS[sys.argv[1]]()Run each mode:
# 1) Watch FDs leak until the kernel refuses to open more.
ulimit -n 256 # lower the soft limit for a quick demo
python os_demo.py fdleak
# 2) Watch RSS grow. In another terminal: watch -n1 'ps -o pid,rss,vsz,cmd -p $(pgrep -f os_demo)'
python os_demo.py mem
# 3) fork() a child; see PPID chain.
python os_demo.py fork
# 4) Threads visible in /proc/self/task/.
python os_demo.py threadAnatomy of the script
What each block teaches
Run:
sudo strace -e trace=network,openat -f -o /tmp/curl.trace curl -s https://example.com > /dev/null
head -30 /tmp/curl.traceYou'll see (roughly):
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_TCP) = 4
connect(4, {sa_family=AF_INET, sin_port=htons(443), ...}, 16) = 0
sendto(4, "GET / HTTP/1.1\\r\\nHost: example.com\\r\\n...", ...) = ...
recvfrom(4, "HTTP/1.1 200 OK\\r\\n...", ...) = ...
close(4) = 0
Every network client and server on Linux is that same 5-syscall dance underneath. Understanding this level makes ‘why is this slow?’ debugging fundamentally different.
(d) Production reality · 15 min
To prove Twitter's real-time push architecture could scale, engineers tried to hold 10 million idle TCP connections on one Linux box. Immediate failure: default ulimit -n is 1024. Even after raising it, the kernel refused past a few tens of thousands.
They tuned: ulimit -n 12000000, raised /proc/sys/fs/file-max, increased ephemeral port range (net.ipv4.ip_local_port_range), tuned TCP memory. Result: a single box holding 10M idle connections at ~2 GB of RAM overhead. This tuning template circulated for years and is still the reference for high-fanout servers.
ulimit, ephemeral ports, and TCP memory as part of its base image.lsof showed 50k+ FDs per box, most in CLOSE_WAIT. The app was accepting connections but never calling close() on abandoned sockets — a leak masked as ‘slow client cleanup’.defer conn.Close(). Fix was two lines. Detection would have been instant if a graph of ‘open FDs per process’ existed on the dashboard.kubectl logs shows OOMKilled. Devs increase the memory request from 256Mi to 512Mi; it crashes again. And again. Nobody notices that inside the container, ulimit -n defaults to 1024 and the workload is hitting FD limits, not memory — but K8s reports it as OOM because the shim conflates them.Add these to every Dockerfile / K8s manifest as defaults:
- Explicit `ulimits` in Docker Compose or `securityContext.sysctls` in K8s (or use a base image with sane defaults).
- Node exporter / cAdvisor scraping
node_filefd_allocatedand per-container FD counts. - Alert on FD count crossing 80 % of the ulimit — always earlier than the OOM.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Move on when you can teach these:
- What is a file descriptor and why does a busy HTTP server run out?
- What's the difference between VSZ and RSS, and which matters for OOM?
- When should you pick processes over threads, and vice versa?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.