Search Tech Journey

Find topics, journeys and posts

6-month learning plan60 / 130
back to blog
systems infrastructureintermediate 55m read

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.

⚙️SystemsM07 · Systems & Infrastructure· Session 060 of 130 90 min

🎯 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.

You will be able to
  • 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

The OS is a very busy office manager
🌍 Real world

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.

💻 Code world

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

These are the four you'll debug forever
  • 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

  1. 1969
    Unix at Bell Labs
    Ken Thompson & Dennis Ritchie ship the first real multi-user OS with the process/file/user model still in use today.
  2. 1985
    POSIX threads standardised
    Concurrent execution within a process becomes portable across Unix variants.
  3. 1991
    Linux 0.01 · Linus Torvalds
    Free Unix kernel. Follows the POSIX process/FD/memory model and eventually surpasses commercial Unixes.
  4. 2007
    cgroups + namespaces
    Google (via LMCTFY / Borg) contributes resource isolation primitives. Foundation for Docker + Kubernetes.
  5. 2016
    io_uring proposed
    New 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

Process

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)
Thread

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

Virtual address (what your program sees)
Every process has its own address space (up to 128TB on x86_64). Your `&x` is a virtual address, not a physical one.
virtual
Page table (per process)
Kernel-maintained lookup: virtual page → physical frame (or ‘not resident, fetch from swap’). Walked by the CPU's MMU on every memory access.
map
Physical page (4KB frame)
Actual RAM. Shared across processes via copy-on-write until one writes. That's how fork() is cheap.
physical
Page fault
Access a page that's not in RAM → CPU traps to kernel → kernel loads it (from swap, from a file, or zero-fills for new allocations). Slow.
fault
RSS vs VSZ
VSZ = virtual size (address space allocated). RSS = resident set size (actually in RAM right now). RSS is what matters for OOM.
metric

File descriptors — the whole cascade

11
open() returns int

Smallest unused FD in the per-process table. 0/1/2 = stdin/stdout/stderr.

22
Kernel tracks reference count

dup(), fork(), and sendfd all increment. Actual close only happens when count = 0.

33
Bounded by ulimit -n (soft) + /proc/sys/fs/nr_open (hard)

Default soft limit is often 1024 — laughably low for any server.

4🚨
Exceed = EMFILE / ENFILE

‘Too many open files’ (EMFILE = per-process; ENFILE = system-wide). Common at scale.

5🔍
Diagnose with lsof + /proc/PID/fd

lsof -p PID | wc -l tells you exactly how many. /proc/PID/fd/ shows what.


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

Why does a system call cost so much more than a function call, when both are "just a jump to some code"?

  1. 1
    The 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
  2. 2
    Protection 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
  3. 3
    Therefore 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
  4. 4
    That 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
  5. 5
    It 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

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.

Mental modelThe kernel is a resource arbiter behind a hardware wall

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.
🔔 Fires when you see

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.

The tradeoff

How do you handle ten thousand concurrent connections: a thread per connection, or an event loop with non-blocking I/O?

Thread (or process) per connection
+ you gain the code is written in a straight line — blocking calls, ordinary stack variables, exceptions and debuggers all work normally. The kernel scheduler handles fairness for you, and a slow handler blocks only its own connection.
− you pay each thread reserves stack memory (typically megabytes of virtual address space) and adds scheduler and cache pressure. Beyond a few thousand threads, context switching and cache thrashing consume a growing share of the CPU rather than doing work.
pick when connection counts in the hundreds to low thousands, or when handler logic is complex enough that readability dominates — most internal services live here
Event loop with non-blocking I/O
+ you gain one thread per core handles tens of thousands of connections. Memory per connection is a small structure rather than a stack, and there are no context switches between connections at all.
− you pay the code inverts into callbacks or async/await, and a single blocking call anywhere — a DNS lookup, a synchronous file read, a CPU-heavy JSON parse — stalls every connection on that loop. Debugging and profiling are meaningfully harder because the stack no longer tells the story.
pick when many mostly-idle connections doing little CPU work each: proxies, websocket fanout, API gateways
Event loop plus a worker pool
+ you gain the loop handles I/O and hands CPU-heavy work to a bounded pool of threads, so one expensive request cannot stall the loop while connection scaling is preserved.
− you pay two concurrency models in one codebase, plus a queue between them that becomes a new failure mode — a saturated pool turns into unbounded latency unless you bound the queue and shed load.
pick when the realistic production answer whenever request handling includes any genuinely CPU-bound step
What a senior engineer actually does

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 thread

Anatomy of the script

What each block teaches

resource.getrlimit(RLIMIT_NOFILE)
Show the soft/hard FD limits for the process. Soft is what you actually hit; hard is what a superuser can raise you to.
limits
open_fd_count() via /proc/PID/fd
The kernel-level truth — one entry per open FD. Symlinks that show what each FD points to.
fd
fdleak() until OSError
Every real ‘Too many open files’ bug looks exactly like this: a socket, a file, or a connection opened and never closed in a loop.
leak
mem() with /proc/PID/status VmRSS
RSS is the number to trust for real memory usage. VmSize (VSZ) includes reservations that may never be touched.
rss
fork() child sees the same FDs
The child inherits the parent's FD table by default. That's how shells implement pipes.
fork
/proc/PID/task/ for threads
One entry per kernel task. Python threads are real kernel threads even if the GIL serialises Python bytecode.
threads
Try itTrace the syscalls behind an HTTP fetch

Run:

sudo strace -e trace=network,openat -f -o /tmp/curl.trace curl -s https://example.com > /dev/null
head -30 /tmp/curl.trace

You'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.

💡 Hint · `strace -e trace=network,openat -o /tmp/curl.trace curl -s https://example.com > /dev/null`, then read /tmp/curl.trace. You'll see socket(), connect(), sendto(), recvfrom(), close(). Every network operation you do compiles down to those five verbs.

(d) Production reality · 15 min

War story Twitter (X)· 2013the ‘10 million connections’ demo
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Defaults were set decades ago for single-user Unix boxes. Every serious server tunes ulimit, ephemeral ports, and TCP memory as part of its base image.
Post-mortem
War story A CDN edge fleet· 2018500-node fleet, 24h intermittent failure
🔥 What broke
A new deploy started rejecting ~2 % of TLS handshakes with obscure errors. Ops discovered 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’.
🧯 The fix
The root cause was a bug in a new middleware layer that returned early on error but skipped the defer conn.Close(). Fix was two lines. Detection would have been instant if a graph of ‘open FDs per process’ existed on the dashboard.
🎓 Lesson to steal
‘Open FDs per process’ is a boring metric that should be on every service dashboard. It's the leading indicator for connection/socket leaks that turn into 5xx storms.
War story Common failure mode · Docker/Kubernetesevery container platform
🔥 What broke
A Node app crashes intermittently in production. 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.
🧯 The fix

Add these to every Dockerfile / K8s manifest as defaults:

  1. Explicit `ulimits` in Docker Compose or `securityContext.sysctls` in K8s (or use a base image with sane defaults).
  2. Node exporter / cAdvisor scraping node_filefd_allocated and per-container FD counts.
  3. Alert on FD count crossing 80 % of the ulimit — always earlier than the OOM.
🎓 Lesson to steal
Container limits inherit from the host and often have surprising defaults. Every ‘it works in dev, crashes in prod’ story eventually traces to one of these limits.

Where this shows up in the rest of the plan

OS primitives underlie every session that follows
S061 · Networking (TCP/DNS)
Sockets are FDs. Every network diagnostic uses the tools from this session.
S062 · Concurrency in Python
Threads vs processes vs asyncio — this session's mental models are the base.
S065 · Containers (Docker)
cgroups + namespaces are how containers get isolation on top of processes.
S068 · Observability
‘Golden signals’ start with FD counts, RSS, and CPU utilisation.
S095 · Performance debugging
strace, perf, and eBPF ride on the process + syscall model taught here.
S110 · SRE fundamentals
Every incident postmortem references at least one of these four abstractions.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Move on when you can teach these:

  1. What is a file descriptor and why does a busy HTTP server run out?
  2. What's the difference between VSZ and RSS, and which matters for OOM?
  3. 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.