02 Β· CS Fundamentals β€” file 01

Operating Systems

You already run an OS course's worth of machinery every day: every pod you deploy, every kubectl exec, every uvicorn worker is OS concepts wearing a trench coat. This page connects the theory to what you already operate.

01What an OS actually is

Strip away everything and an OS is a resource multiplexer with an armed guard:

Multiplexer

One CPU, one RAM bank, one disk, one NIC… but hundreds of programs that all believe they own the machine. The OS time-slices the CPU, carves up memory, and queues disk/network I/O so everyone gets the illusion of a private computer.

Armed guard

Programs run in user mode (restricted CPU mode); the kernel runs in kernel mode (can touch hardware, page tables, other processes' memory). The only way across the wall is a syscall.

      USER MODE                      KERNEL MODE
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ your FastAPI app  β”‚  syscall β”‚  kernel             β”‚
β”‚ psycopg2          β”‚ ───────► β”‚  scheduler          β”‚
β”‚ node service      β”‚ ◄─────── β”‚  virtual memory     β”‚
β”‚ kafka client      β”‚  return  β”‚  filesystem, TCP/IP β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Everything below is a consequence of these two jobs.

02Processes vs threads

The process: an isolation unit

A process is a running program plus everything the kernel tracks about it: its own virtual address space, file descriptor table, PID, credentials, signal handlers. The keyword is isolation β€” process A literally cannot address process B's memory (the page tables don't map it).

The thread: an execution unit

A thread is a schedulable stream of execution inside a process. Threads in the same process share the address space (heap, globals, code), file descriptors, and signal dispositions. Each thread privately owns its stack (local variables, call frames) and its register state / program counter.

Process vs Thread Explorer

One memory-layout diagram, two readings. Toggle: are these two execution streams separate processes, or two threads of one process?

code (text segment)
heap β€” objects, globals, malloc / PyObjects
file descriptor table β€” sockets, pipes, files

stack + registers / PC always private

stack + registers / PC always private

ProcessThread
MemoryIsolated (own address space)Shared
Creation costHeavy (fork, copy page tables)Light
CommunicationIPC: pipes, sockets, shared mem, signalsJust read/write shared memory (careful: races!)
One crashesOthers surviveWhole process can die
Context switch costHigher (address space switch, TLB flush)Lower (same address space)

Speak from experience: uvicorn/gunicorn --workers 4 = 4 processes (isolation, uses all CPU cores despite the GIL). Each worker may run an event loop with thousands of concurrent requests. Kafka consumers in a group = separate processes/pods, coordinated over the network, not shared memory β€” the "share nothing, communicate by message" philosophy scaled up.

03Context switching

The CPU runs one thread per core at a time. To switch, the kernel:

  1. Interrupt fires (timer tick, or thread blocks on I/O, or syscall returns).
  2. Kernel saves the current thread's registers + program counter into its task struct.
  3. Scheduler picks the next runnable thread.
  4. Kernel restores that thread's registers; if it's a different process, it also swaps the page table pointer (CR3 on x86) — which invalidates chunks of the TLB (the cache of virtual→physical translations).
  5. Return to user mode; the new thread continues as if nothing happened.

Cost: the register save/restore is ~1–2 Β΅s, but the real tax is cold caches β€” the new thread's data isn't in L1/L2, and TLB misses pile up. Effective cost can be tens of microseconds.

Why it matters to you: this is exactly why async I/O wins for high-concurrency network services. 10,000 threads = 10,000 stacks (~80 MB at 8 KB each just for kernel stacks, MBs more in user space) + constant context switching. One event loop = one thread, switching between coroutines in user space with no kernel involvement β€” nanoseconds, not microseconds. More in Concurrency & parallelism.

04Scheduling basics

  • The kernel keeps runnable threads in run queues (per-CPU). Linux's default is CFS/EEVDF ("completely fair"): every thread accumulates vruntime (weighted runtime); the scheduler always runs the thread that has had the least. Nice values weight it.
  • Preemptive: a timer interrupt guarantees no thread can hog a core β€” the kernel forcibly context-switches. (Contrast: an asyncio event loop is cooperative β€” a coroutine that never awaits blocks everyone. Same concept, opposite policy.)
  • I/O-bound vs CPU-bound: I/O-bound threads run briefly then block (on read, recv…), so they get scheduled quickly when data arrives β€” good latency. CPU-bound threads eat full time slices. Your API services are overwhelmingly I/O-bound: they spend their lives waiting on Postgres, Redis, OpenSearch, and LLM APIs.

K8s connection β€” a killer interview moment: Kubernetes CPU limits are implemented with the CFS bandwidth controller in cgroups: a limit of 500m means "this cgroup may run 50 ms per 100 ms period." Exceed it and your pod gets throttled β€” it sits frozen until the next period even if the node is idle. That's why a latency-sensitive service can show p99 spikes with CPU limits set too tight: the OS scheduler literally refuses to run it. CPU requests map to cgroup weights (proportional share under contention).

05Memory: stack, heap, virtual memory, paging

Stack vs heap

High addresses
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    stack     β”‚  ← grows down. Call frames, locals. Auto-freed on return.
β”‚      ↓       β”‚
β”‚   (unused)   β”‚
β”‚      ↑       β”‚
β”‚    heap      β”‚  ← grows up. malloc / Python objects. Freed by GC/free().
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  globals/BSS β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  code (text) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Low addresses
  • Stack: allocation is "bump a pointer" β€” basically free. Lifetime = the function call. Fixed max size (default ~8 MB on Linux) β†’ deep recursion = stack overflow (Python guards with RecursionError at ~1000 frames before the real stack would blow).
  • Heap: for data whose lifetime outlives a function call or whose size is dynamic. In Python, every object lives on the heap β€” the stack just holds references. Allocation is managed by the allocator (pymalloc for Python, V8's heap for Node) and reclaimed by GC.

Virtual memory & paging

Every process sees a private, flat address space starting near 0. That's a lie the MMU maintains:

  • Memory is managed in pages (4 KB typically). A page table (per process) maps virtual pages β†’ physical frames. The TLB caches these translations.
  • A virtual page can be: mapped to RAM, mapped to a file (mmap β€” how executables and shared libs load), swapped to disk, or not mapped at all (β†’ segfault on access).
  • Page fault = you touched a page with no valid mapping. It's not always an error: the kernel uses faults to lazily allocate memory (allocate-on-first-touch) and to load mmap'd file pages on demand. A "major fault" means disk I/O was needed β€” slow.
  • Copy-on-write (CoW): fork() doesn't copy memory; parent and child share pages marked read-only, and copies happen per-page on first write. This is why forking a fat process is cheap β€” and why gunicorn's --preload shares your app's memory across workers.

K8s connection β€” OOMKilled: memory limits are a cgroup memory cap. Blow past it and the kernel's OOM killer kills the process β€” that's your OOMKilled pod status with exit code 137 (128 + SIGKILL(9)). Note it's a kill, not an exception you can catch: from inside the container, the process just dies.

06File descriptors

To the kernel, everything you can read/write is a file descriptor: files, pipes, sockets (every HTTP connection!), timers, even event-notification handles (epoll). An FD is just a small integer indexing into the process's FD table.

  • 0 = stdin, 1 = stdout, 2 = stderr. This is why container logging works: your app writes to stdout, the runtime captures FD 1, kubectl logs reads the capture.
  • Every Postgres connection in your pool, every Kafka broker connection, every accepted HTTP request = 1 socket = 1 FD.
  • FDs are limited (ulimit -n, often 1024 by default, raised in prod). "Too many open files" during a traffic spike or a connection leak is an FD exhaustion bug β€” a classic war story to have ready.
  • epoll (Linux) is the mechanism that lets one thread monitor thousands of FDs: "tell me which of these sockets are readable." This is the syscall your asyncio/uvloop event loop and Node's libuv are built on. The event loop is epoll with a nice API.

07Syscalls

A syscall is a controlled function call into the kernel: put the syscall number + args in registers, execute a trap instruction (syscall on x86-64), CPU switches to kernel mode, kernel does the work, returns.

Ones you invoke constantly without thinking: read/write, open, socket/connect/accept/send/recv, epoll_wait, futex (what locks use to sleep), mmap, fork/execve, clone (creates threads and processes on Linux β€” a thread is just a clone() that shares the address space).

Syscalls cost ~hundreds of nanoseconds (mode switch + work). That's why high-performance I/O batches them (writev, sendfile, io_uring) and why "chatty" code (one tiny write per log line, per-byte parsing on sockets) gets slow.

Debug tool worth naming: strace -p <pid> shows every syscall a process makes β€” the "what is this thing actually doing" tool of last resort.

08Signals

Signals are the kernel's asynchronous nudge mechanism β€” a software interrupt delivered to a process.

SignalNumberMeaningCatchable?
SIGTERM15"Please shut down"Yes β€” do graceful shutdown
SIGKILL9Die nowNo β€” kernel just kills you
SIGINT2Ctrl-CYes
SIGSEGV11Bad memory accessSort of (usually you're toast)
SIGHUP1Terminal hangup / "reload config" by conventionYes
SIGCHLD17A child process exitedYes (how shells/init reap zombies)

K8s pod termination β€” know this cold:

kubectl delete pod / rollout
       β”‚
       β–Ό
1. Pod marked Terminating; removed from Service endpoints
2. SIGTERM sent to PID 1 in each container   ← your graceful-shutdown hook
3. terminationGracePeriodSeconds (default 30s) countdown
4. Still alive? SIGKILL. No appeal.

Gotcha worth mentioning: if your container's PID 1 is a shell (CMD sh -c "python app.py"), the shell may not forward SIGTERM to your app β†’ your app never gets a graceful shutdown and dies via SIGKILL at the deadline. Fix: exec form (CMD ["python", "app.py"]) or a tiny init like tini. Uvicorn handles SIGTERM by finishing in-flight requests then exiting β€” pair that with a preStop hook/readiness flip so the LB drains first.

Quick check: your process ignores kill <pid> but dies to kill -9 <pid>. What's happening?

kill with no flag sends SIGTERM (15) β€” a request, which the process is catching (or ignoring) in a signal handler, maybe stuck in graceful-shutdown logic. kill -9 sends SIGKILL, which never reaches the process at all: the kernel simply deallocates it. That's also why SIGKILL can't trigger cleanup β€” no atexit hooks, no flushing logs, no closing connections.

Quick check: why should your app handle SIGTERM even though SIGKILL exists?

SIGTERM is your only window to shut down gracefully: finish in-flight requests, commit/close DB connections, flush buffers, deregister from service discovery. If you ignore it, K8s waits out terminationGracePeriodSeconds and SIGKILLs you β€” dropped requests, half-done work, and no chance to clean up. Handle 15 so you never meet 9.

09Containers = namespaces + cgroups

There is no "container" in the kernel. A container is not a VM. It's a normal Linux process wearing blinders and a straitjacket:

Namespaces β€” the blinders (what you can see)

  • pid β€” your process is PID 1, can't see host processes
  • net β€” own network stack: interfaces, IPs, ports, routing table (why every pod gets its own IP and two pods can both bind :8080)
  • mnt β€” own mount table / root filesystem (the image's layers)
  • uts β€” own hostname
  • ipc, user, cgroup β€” own IPC objects, UID mappings, etc.

cgroups β€” the straitjacket (what you can use)

  • CPU β€” weights + CFS quota β†’ throttling
  • memory β€” cap β†’ OOM kill
  • pids β€” fork-bomb protection
  • IO β€” bandwidth limits

Plus a chroot-like pivot into the image filesystem (overlayfs layers), capabilities dropping, and seccomp filters (syscall allowlist).

VM:         App β†’ Guest OS kernel β†’ Hypervisor β†’ Host kernel β†’ HW
Container:  App ──────────────────────────────► Host kernel β†’ HW
                 (same kernel! just namespaced + cgrouped)

Consequences you've lived:

  • Containers share the host kernel β€” that's why they start in milliseconds and why uname -r in any pod shows the node's kernel.
  • A K8s pod = a group of containers sharing the same network (and IPC) namespace β€” which is exactly why sidecars talk to your app over localhost.
  • kubectl exec = spawn a process inside the container's namespaces (nsenter conceptually).
  • Resource limits/requests are literally cgroup settings written by the kubelet.

If asked "what happens when a container exceeds its memory limit": cgroup memory controller triggers the kernel OOM killer inside that cgroup β†’ SIGKILL β†’ exit 137 β†’ kubelet restarts per restartPolicy β†’ CrashLoopBackOff if it keeps happening.

10Python's GIL

The Global Interpreter Lock is a mutex inside CPython: only one thread may execute Python bytecode at a time, per process. It exists because CPython's memory management (refcounts on every object) isn't thread-safe without it.

What it does and doesn't mean:

  • CPU-bound Python + threads = no parallelism. Two threads crunching numbers take as long as one (worse β€” GIL handoff overhead). Fix: multiprocessing / process pools (each process has its own GIL), or push work into C (NumPy releases the GIL) β€” or Python 3.13+ free-threaded builds (experimental).
  • I/O-bound Python + threads = fine. The GIL is released during blocking I/O (socket reads, file I/O, time.sleep, most C-extension work). While one thread waits on Postgres, another runs. So a threaded scraper or a run_in_executor call genuinely overlaps work.
  • Async doesn't "beat" the GIL β€” it sidesteps the question. One thread, so the GIL is uncontended; concurrency comes from the event loop interleaving I/O waits.

The production pattern you actually run (say this): multiple uvicorn processes for CPU parallelism across cores Γ— an asyncio event loop inside each for massive I/O concurrency Γ— thread/process executors for the occasional blocking or CPU-heavy call. Processes for parallelism, coroutines for concurrency, threads for escape hatches.

Q&AInterview questions you should be able to answer

Q1. Process vs thread β€” difference and when to use which?

Process = isolated address space + own resources; thread = execution stream sharing its process's memory, with a private stack. Processes: fault isolation and (in Python) true CPU parallelism past the GIL. Threads: cheap concurrency with shared state β€” but you inherit race conditions. My services use multiple worker processes with async event loops inside each.

Q2. What happens during a context switch and why is it expensive?

Timer interrupt or block β†’ kernel saves registers/PC β†’ scheduler picks next thread β†’ restore its state; cross-process switches also swap page tables and hurt the TLB. Direct cost ~1–2 Β΅s; real cost is cold CPU caches afterwards. Motivates event loops: coroutine switches are user-space and nanosecond-scale.

Q3. Stack vs heap?

Stack: per-thread, call frames and locals, pointer-bump allocation, auto-freed on return, fixed size (overflow on deep recursion). Heap: dynamic, lifetime beyond the call, managed by allocator + GC. In Python all objects are heap-allocated; the stack holds references.

Q4. What is virtual memory / a page fault?

Each process gets a private virtual address space; the MMU + page tables map 4 KB virtual pages to physical frames (TLB caches translations). A page fault = access to an unmapped page; the kernel either fixes it (lazy allocation, mmap load-in, swap-in) or delivers SIGSEGV. Fork uses copy-on-write pages, so it's cheap.

Q5. What is a file descriptor?

A small integer handle into the process's table of open kernel I/O objects β€” files, pipes, and crucially sockets. 0/1/2 = stdin/out/err (container logs = captured FD 1). Every DB and HTTP connection consumes one; hitting ulimit -n gives "too many open files." epoll lets one thread wait on thousands of FDs β€” that's the foundation of asyncio and Node.

Q6. What is a syscall?

The controlled gate from user mode into the kernel via a trap instruction β€” read/write/socket/epoll_wait etc. Costs hundreds of ns, so fast paths batch or avoid them. strace shows them live.

Q7. SIGTERM vs SIGKILL, and how does Kubernetes stop a pod?

SIGTERM (15) is catchable β€” do graceful shutdown; SIGKILL (9) is not. K8s: remove pod from endpoints β†’ SIGTERM to PID 1 β†’ wait terminationGracePeriodSeconds (30 s default) β†’ SIGKILL. Exit 137 = 128+9 = killed, commonly the OOM killer. Watch for shell-as-PID-1 swallowing SIGTERM.

Q8. How do containers work? Container vs VM?

A container is a regular process isolated by namespaces (pid/net/mnt/uts/ipc β€” what it sees) and constrained by cgroups (CPU/memory/pids β€” what it uses), running on the shared host kernel over an overlay filesystem. A VM boots its own kernel on a hypervisor. Shared kernel β†’ ms startup, near-zero overhead, weaker isolation boundary. A pod = containers sharing one net namespace, hence sidecars on localhost.

Q9. What does a K8s CPU limit actually do?

Sets a cgroup CFS quota: e.g. 500m = 50 ms runtime per 100 ms period. Exceed it β†’ the scheduler throttles the process for the rest of the period even on an idle node β†’ mysterious p99 latency spikes. Requests, by contrast, are proportional weights used for scheduling and contention.

Q10. Explain the GIL. Does it make threads useless?

CPython mutex: one thread runs bytecode at a time (protects refcounting). Kills CPU parallelism in threads, but it's released during blocking I/O, so I/O-bound threading works fine. For CPU parallelism: multiprocessing or C extensions. Async isn't a GIL workaround β€” it's single-threaded concurrency via the event loop. Production answer: worker processes Γ— event loops Γ— executors for blocking calls.

Q11. Why is async better than threads for 10k concurrent connections?

10k threads = 10k stacks (GBs of address space, MBs resident) + kernel scheduling + context-switch/cache costs. One event loop watches 10k sockets with epoll and switches between coroutines in user space for nanoseconds. Concurrency here is waiting-on-I/O management, not computation β€” one core can juggle it all.

βœ“Self-test β€” explain it out loud before opening

Trace what happens, OS-level, when a pod is OOMKilled.

Pod's memory usage grows past its cgroup memory cap β†’ the cgroup memory controller invokes the kernel OOM killer inside that cgroup β†’ it SIGKILLs the biggest offender (your process; SIGKILL is uncatchable, so no cleanup runs) β†’ container exits with code 137 (128 + 9) β†’ kubelet sees the exit and restarts per restartPolicy β†’ repeated deaths become CrashLoopBackOff.

Draw the memory layout of a process from low to high addresses.

code (text) β†’ globals/BSS β†’ heap growing up β†’ big unused gap β†’ stack growing down from high addresses. Stack: call frames, auto-freed, ~8 MB cap. Heap: dynamic objects, allocator + GC. Every Python object is on the heap; the stack holds references.

Explain why two pods on one node can both bind port 8080.

Each pod has its own net namespace β€” a private network stack with its own interfaces, IP, ports, and routing table. Port 8080 in pod A's namespace and port 8080 in pod B's namespace are different sockets on different (virtual) interfaces. The node routes to each pod's IP.

Name the syscall chain from "browser sends request" to "your handler runs."

Your server called socket β†’ bind β†’ listen earlier; the event loop sits in epoll_wait. Connection arrives β†’ epoll reports the listening FD readable β†’ accept returns a new FD for the connection β†’ epoll watches it β†’ data arrives β†’ recv reads the request bytes β†’ your handler runs β†’ send writes the response.

Why does gunicorn --preload save memory?

Preload imports the app in the master before forking workers. fork() is copy-on-write: parent and child share pages until one writes. All the read-mostly memory (code objects, imported modules) stays physically shared across workers instead of being duplicated N times.

Your service shows p99 spikes but node CPU is idle. One OS-level suspect?

CFS quota throttling from a too-tight K8s CPU limit: the cgroup burned its quota (e.g. 50 ms of its 100 ms period at 500m) and the scheduler refuses to run it until the next period β€” even though the node has spare cores. Check throttled_time in cgroup stats / container CPU throttling metrics.