S060 · OS Basics — Processes, Threads, Memory, FDs
What the OS actually does.
Module M07: Systems & Infrastructure · Session 60 of 130 · Track: SYS 🖥️
What you'll be able to do after this session
- Explain processes, threads, memory, and file descriptors to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · Processes vs Threads — Computerphile — clearest 10-minute breakdown of the difference.
- 🎥 Deep dive (30–60 min) · Operating Systems in 60 minutes — Neso Academy / MIT-style crash course — the classic tour of scheduling, memory, and I/O.
- 🎥 Hands-on demo (10–20 min) · Linux
top,ps,lsoffor engineers — LearnLinuxTV — the tools you'll actually use to debug production. - 📖 Canonical article · Operating Systems: Three Easy Pieces (free online book) — Ch. 1–5 — Remzi's OSTEP is the modern OS textbook; free, warm, and correct.
- 📖 Book chapter / tutorial · Linux
man 7 credentials,man 7 fanotify,man 2 fork— the actual syscall docs; skimfork,execve,mmap,open. - 📖 Engineering blog · Cloudflare — How we tripled max_connections for Postgres — real story about hitting OS-level limits (file descriptors, memory) and fixing them.
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: What the OS actually does.
Think of the operating system as the landlord of your laptop. The physical building (CPU, RAM, disk, network card) is shared among many tenants (running programs). The OS's job is to give each tenant the illusion of having the whole building to themselves — its own memory space, its own CPU, its own view of files — while actually multiplexing everyone onto the real hardware and preventing them from stepping on each other.
Four concepts you must have burned into your brain:
- Process — a running program with its own isolated memory space. Chrome and VS Code are separate processes; if one crashes, the other lives. Every process gets a PID.
- Thread — an independent line of execution within a process. Threads share memory with each other. Cheap to create, dangerous to synchronise (that's why Python has the GIL, and why concurrent-map bugs cost real money).
- Virtual memory — every process sees a flat 64-bit address space as if it owned all of RAM. The OS + MMU translate virtual addresses to physical ones on the fly, swap cold pages to disk, and enforce that process A can't peek at process B's memory. This is the illusion that makes multi-tenancy safe.
- File descriptor (fd) — a small integer (
0,1,2,3, ...) that identifies an open thing the process can read from or write to. Files, sockets, pipes, and even timers are all fds. Every "open" hands you one; every "close" returns it. Run out (EMFILE) and the process dies.
Real-life analogy: fds are like coat-check tickets — the OS holds your open resources, you hold a numbered ticket, and you must give tickets back or the coat room fills up and nobody else can check anything in.
Forever gotcha: the two most common production incidents at the OS layer are: (a) file-descriptor exhaustion (a bug leaks sockets, EMFILE: too many open files, service dies), and (b) memory bloat leading to the OOM killer (Linux picks a process by score and kills it — often the one you least expected). Every backend engineer eventually gets paged for one of these.
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
Process vs thread — side-by-side table:
| Aspect | Process | Thread |
|---|---|---|
| Memory | Own address space | Shared with peer threads |
| Crash impact | One dies, others live | One dies, whole process usually dies |
| Creation cost | ~1 ms (fork, execve) | ~10 µs (pthread_create) |
| IPC (talk to each other) | Pipes, sockets, shared memory | Just read/write shared variables (careful!) |
| Isolation | Strong (kernel enforces) | None |
| Use when | Independent apps, security boundary | Parallel work in one app |
Virtual memory in one picture:
Process A virtual | Process B virtual | Physical RAM
0x00000000 code | 0x00000000 code | 0x0000 kernel
0x00400000 heap | 0x00400000 heap | 0x1000 A code page
0x7ffff0.. stack | 0x7ffff0.. stack | 0x2000 A heap page
| | 0x3000 B code page
| | 0x4000 shared libc page (A + B)
| | 0x5000 A stack page
Both processes see address 0x00400000 as their heap; the MMU translates to different physical pages. Some pages (shared libraries) map into both — that's why loading libc costs no extra RAM after the first process.
File descriptors — what you almost never see but always depend on:
| fd | Convention |
|---|---|
| 0 | stdin |
| 1 | stdout |
| 2 | stderr |
| 3+ | Whatever you open(), socket(), pipe(), eventfd(), ... |
ulimit -n shows the per-process cap (default 1024 on many distros, 8192+ on newer ones). Web servers, databases, and browsers routinely raise this to 65535 or higher.
A worked example — the anatomy of a curl command:
- Shell
fork()s → child gets a copy of parent's memory (copy-on-write). - Child calls
execve("/usr/bin/curl", ["curl", "http://example.com"])→ replaces its memory image with curl's binary. - Curl opens a TCP socket →
socket()returns fd 3. - Curl
connect()s to example.com → kernel does DNS + TCP handshake. - Curl
write()s the HTTP request to fd 3;read()s response bytes back. - Curl
write()s response bytes to fd 1 (stdout). - Curl
close(3), thenexit(0). Kernel reclaims all fds and memory.
Every network client + server on every OS boils down to these six steps.
The OOM killer — when Linux runs out of memory, it doesn't fail-allocate. It calls a heuristic ("badness score" based on RSS, uid, oom_score_adj) and murders a process. You typically see it as: your service disappears with no core dump, and dmesg shows Out of memory: Killed process 4200 (myapp). Tune oom_score_adj per-service if you have life-critical daemons.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Poke every concept from part (b) with real Linux tools.
# 1. Look at your own process tree
ps -ef --forest | head -30
# 2. What is your shell's PID? Its parent? Its threads?
echo "my shell pid: $$"
ps -o pid,ppid,nlwp,comm -p $$ # nlwp = number of threads
# 3. What are its open file descriptors?
ls -l /proc/$$/fd | head -20
# Try this: open a file in another terminal, then check
exec 7< /etc/hosts # open /etc/hosts on fd 7
ls -l /proc/$$/fd/7 # -> symlink pointing at /etc/hosts
exec 7<&- # close fd 7
# 4. See virtual memory map (huge but educational)
cat /proc/$$/maps | head -20
cat /proc/$$/status | grep -E '^(Vm|Threads)' # VmSize, VmRSS, ThreadsNow the Python demo — fork, thread, and fd exhaustion in one script:
# os_demo.py
import os, threading, time, resource, socket
# 1. What's my ulimit?
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"fd limit: soft={soft} hard={hard}")
# 2. Fork a child process
pid = os.fork()
if pid == 0:
print(f"[child] my pid={os.getpid()}, parent={os.getppid()}")
time.sleep(1)
os._exit(0)
print(f"[parent] my pid={os.getpid()}, spawned child pid={pid}")
os.waitpid(pid, 0)
# 3. Spawn 5 threads (SHARED memory — same address space)
shared_counter = [0]
def worker(name):
for _ in range(1000):
shared_counter[0] += 1 # RACE CONDITION if not for GIL/lock
print(f" thread {name} done, counter={shared_counter[0]}")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(f"final counter (expected 5000): {shared_counter[0]}")
# 4. Leak sockets to demonstrate fd exhaustion (BE CAREFUL)
print("\n--- leaking sockets until EMFILE ---")
leaked = []
try:
for i in range(soft + 100):
s = socket.socket()
leaked.append(s)
if i % 100 == 0:
print(f"opened {i} sockets, sample fd={s.fileno()}")
except OSError as e:
print(f"boom at {len(leaked)} sockets: {e}")
# Cleanup so shell stays happy
for s in leaked: s.close()Run: python os_demo.py
What to observe:
fd limit: on most Linuxes this is 1024 by default. Web servers raise it to 65535 (ulimit -n 65535).fork()returns 0 in the child, the child's PID in the parent — that is the classic UNIX idiom every server framework uses under the hood.- Threads share
shared_counterbecause they share memory — try running the same logic acrossmultiprocessingand note counter stays at 1000 per process (no sharing without IPC). - Section 4 will fail around the ulimit with
OSError: [Errno 24] Too many open files. That isEMFILE— the exact error your production service hits when it leaks sockets. - Every socket you open is an fd; every leaked socket is one closer to disaster.
Try this modification: replace section 4 with multiprocessing.Pool doing the same work, and watch pmap $$ before + after: total memory grows dramatically because each process gets its own address space, unlike threads which share.
Bonus one-liners for real debugging:
lsof -p <pid> # every fd a process holds
strace -p <pid> -c # count syscalls (great for "why is it slow?")
pmap <pid> # memory map
top -H -p <pid> # per-thread CPU
cat /proc/<pid>/limits # every rlimit for that process(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
Every production incident eventually touches OS primitives. You cannot run a real system without knowing what a file descriptor is, why the OOM killer nuked your service, or why increasing threads did not increase throughput. Kubernetes hides a lot of this, but the moment you have a real problem, you are back to strace, lsof, top, and dmesg.
War stories:
- The 1024 fd wall. A Node.js API worked fine in staging. In production, at ~800 concurrent websockets, every new connection got
EMFILE. Root cause: defaultulimit -n = 1024. Fix: raise to 65535 via systemd (LimitNOFILE=65535) or container runtime (--ulimit nofile=65535:65535). Also add a socket-leak detector (leaked fds don't show as connections; they show as unclosed handles). - The OOM assassination. Java service kept getting killed.
dmesgshowed OOM killer. Buttopshowed only 60% memory used. Culprit:Xmxset too high + JIT compile spikes pushed RSS over cgroup limit; kernel killed with no swap. Fix: set container memory request/limit to matchXmx+ overhead; enable heap dump on OOM; tuneoom_score_adjif you have a shepherd process that must stay alive. - The thread-storm. Python service used a
ThreadPoolExecutorunbounded (max_workers=None→ 32 threads per CPU). Under load it hit 4000 threads, each with 8MB stack → 32 GB VmSize, then OOM. Fix: cap thread count; use asyncio for I/O-bound work; measure withtop -H. - The fd leak in the JSON logger. Every log line opened + never closed a file (bug in a custom formatter). 24h later,
EMFILE, service down.lsofshowed 65000 open handles to/var/log/app.log. Fix: audit everyopen()for a matchingclose(); prefer context managers (with open(...):); monitor fd count as a metric. - The mystery slowdown. Postgres was slow.
topsaid CPU idle, disk idle. Turned out: process was thrashing between too few and too many pages via swap. Root cause:overcommit_memory=2+ tight ratio + noisy neighbour. Fix: right-size cgroup memory, turn off swap for latency-sensitive services (or use zswap), monitorpgmajfault. - The COW (copy-on-write) surprise. Ruby app forked workers (unicorn/puma). After a few hours, RSS ballooned way past expected. Copy-on-write "shared" pages became "private" as GC touched them. Fix: force pre-heat + GC compact before fork; consider forking earlier; consider threads-based server.
How top teams handle it: every service ships with prometheus exporters for process_open_fds, process_max_fds, process_resident_memory_bytes, process_cpu_seconds_total; alerts fire at 70% of any limit; every deploy pipeline runs stress-ng-style resource checks; every container has explicit memory + CPU + PID limits; on-call runbooks always include "check dmesg, lsof, strace output before restarting."
Gotcha to memorise: when a service disappears with no logs and no panic, it was almost always the OOM killer, or kill -9 from the orchestrator when a healthcheck timed out. dmesg -T | grep -i kill is the first command to run before anything else.
(e) Quiz + exercise · 10 min
- State two concrete differences between a process and a thread.
- Why does
fork()return two different values in the two resulting processes, and what are they? - What is a file descriptor, and what error do you get when you run out of them?
- Explain virtual memory in one paragraph — what illusion does it provide and why is it critical for multi-tenant safety?
- What is the OOM killer, and what is the single first command you should run when a service vanishes with no logs?
Stretch (connects to S055 — HTTP): An HTTP server holds one file descriptor per active connection. On a 4-core machine with ulimit -n = 65535, what is your theoretical max concurrent connection count, and what real-world limits (memory, kernel tables, ephemeral ports) will hit you first?
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is a process / thread / fd? (one sentence each, no jargon)
- When would you use threads vs processes? (one concrete example)
- When would you NOT increase threads to speed up a program? (one anti-pattern)
What comes next
- Next session (S061): Networking — TCP/IP, DNS, Ports
- Previous (S059): AuthN & AuthZ — OAuth 2.0, OIDC, JWT
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M07 · Systems & Infrastructure
all modules →