Learn Operating Systems
From “what is an operating system for?” down to page tables and epoll. Every lesson starts from the problem the mechanism solves, asks “why does this exist?”, labels what is conceptual versus Linux/Unix/Windows-specific, and most carry a simulator you can step through and break.
What an OS is for, and what happens between `./server` and the first instruction on a CPU: loader, address space, process, scheduler.
Why? — Several programs want the same CPU, memory, disk and NIC. Who coordinates them?
Several programs want the same CPU, memory, disk and network card at the same time; the operating system is the program that owns those resources and hands them out, and everything it does follows from that job.
Typing `./server` triggers a chain — shell, fork, exec, loader, address space, dynamic linker, stack, heap, registers, scheduler — and the first instruction of your `main` runs only after every one of those steps has succeeded.
A program is a passive file of instructions on disk; a process is one running instance of it with its own address space, state and PID — which is why one `chrome` binary can be twenty processes and why killing one leaves the file untouched.
A process’s virtual address space is divided into regions with different lifetimes and permissions — text, data, BSS, heap, mapped libraries, stack — and knowing which region a variable lives in tells you how it is allocated, how long it lives and how it can be corrupted.
A process is a running program with an identity, an address space, a state, open resources and a parent. The process table, the state machine, fork and exec.
Why? — How does one executable become three isolated running instances?
Everything a process "is" lives in one kernel record — identity, parent, state, address space, descriptor table, credentials, accounting and scheduling data — and every tool from `ps` to `top` to `/proc` is a view of those fields.
A process is always in exactly one state — new, ready, running, waiting or terminated — and the transitions are driven by four things: the scheduler taking the CPU away, the process asking for something that is not ready, that thing arriving, and exit.
Unix-style systems create processes by cloning the caller (`fork`) and then optionally replacing the clone’s program (`exec`); the parent collects the result with `wait`; Windows does it in one `CreateProcess` call, and every language’s "run a subprocess" API is a thin wrapper over one of the two.
Threads inside a process, concurrency vs parallelism, threads vs async vs processes, and how C++, JavaScript/TypeScript and Python each map onto the OS.
Why? — Why can a single core run a thousand tasks "at once", and why is that not parallelism?
A thread is an independently scheduled instruction stream inside a process: it has its own stack, registers and instruction pointer, and shares everything else — heap, globals, descriptors — with its siblings, which makes threads cheap to create and communicate through, and easy to corrupt.
Processes buy isolation at the price of expensive creation, expensive switching and explicit communication; threads buy cheap sharing at the price of shared failure — and every runtime, browser and database picks a point on that line for a reason.
There are three ways to have many things in flight — a thread per task, an event loop that parks tasks while they wait, or a process per task — and the choice is decided by whether the work is CPU-bound or I/O-bound, how many tasks you need, and what it costs to have one of them fail.
Concurrency is having several tasks in progress at once, achieved on a single core by interleaving them in time slices; parallelism is executing several at the same instant, which requires several cores — a single core is always concurrent and never parallel.
C++ exposes OS threads directly; JavaScript hides them behind an event loop per realm and reaches the OS through the runtime’s own threads; CPython wraps OS threads but serialises bytecode with the GIL in its default build — three different contracts over the same kernel.
An event loop is a single thread that repeatedly takes the next completed event from a queue and runs its handler to completion; the runtime and the OS do the waiting elsewhere, so one thread can hold thousands of in-flight operations as long as no handler blocks it.
An `await` on an I/O call hands the request to the runtime, which hands it to the OS or a helper thread, frees the calling thread to run other work, and resumes the function as a continuation when the kernel reports completion — with mechanisms that differ between files and sockets and between Node, Python and C++.
A hundred runnable processes, eight cores. Ready queues, time slices, priority, preemption, fairness, and what a context switch actually saves and restores.
Why? — There are 100 runnable processes and 8 cores. Who runs, and for how long?
A hundred runnable processes and eight cores forces a decision every few milliseconds — who runs, on which core, for how long, and who waits — and every scheduler is one particular answer to that question.
Run six processes on one to four cores under three textbook policies and watch the convoy effect, starvation and the quantum trade-off appear in the timeline — as an educational model, not a kernel.
A context switch saves one task’s registers and stack pointer, swaps the address space, and restores another’s — a few microseconds of direct work whose real cost is the cold caches and TLB the new task inherits.
Why applications cannot touch hardware directly, the user/kernel boundary, and what a system call costs.
Why? — Why can’t my program just write to the disk itself?
An application cannot read a disk, send a packet or create a process on its own; it deposits a request number and arguments in registers, executes a trap instruction, and the kernel does it — a few hundred nanoseconds per crossing, which is why batching interfaces exist.
The CPU runs in one of two privilege levels; user mode cannot touch devices, page tables or other processes, and the only ways into kernel mode are a trap, an interrupt or an exception — which is why a segfault is a fault delivered to you as a signal, not a crash of the machine.
The process memory layout, stack frames pushed and popped by function calls, the heap under `new`/`malloc`/object allocation, and what a stack overflow really is.
Why? — Where does a local variable live, where does an object live, and why does recursion have a limit?
The stack is a bump pointer that allocates a function’s locals in one instruction and frees them on return; the heap is an allocator you ask for memory whose lifetime is not tied to any call — and every language you use maps its values onto those two regions differently.
Each call pushes a frame — return address, saved frame pointer, arguments that did not fit in registers, locals and callee-saved registers — and each return pops it; the layout is fixed by a calling convention that varies by platform but always answers the same three questions.
Unbounded recursion pushes frames until the stack pointer crosses into a guard page the kernel deliberately left unmapped; the resulting fault is reported as SIGSEGV, "Maximum call stack size exceeded" or RecursionError depending on who catches it first.
`new Object()`, `obj = SomeObject()` and `malloc(64)` all end in the same place — a user-space allocator handing out slices of pages it obtained from the kernel’s virtual memory system, with the physical memory appearing only when a page is first touched — but the three runtimes take very different routes to get there.
Every process believes it owns a huge private memory. Pages, frames, page tables, the TLB, page faults, memory pressure, memory mapping and copy-on-write.
Why? — Multiple processes each believe they have their own large continuous memory. How?
Every process on the machine believes it owns a large, private, contiguous memory starting at the same address; one level of indirection — a per-process map from virtual pages to physical frames — makes that belief true and buys isolation, relocation, lazy allocation, sharing and protection in a single mechanism.
Process A’s address 0x1000 and process B’s address 0x1000 are two different bytes because each process has its own page table; the address space they see is 48 bits wide, split between a user half and a kernel half that is mapped into every process but unreachable from user mode.
Memory is managed in fixed-size pages: each virtual page maps to a physical frame through an entry carrying present, read/write/execute, dirty and accessed bits, and the "not present" state is not an error but the hook the kernel uses to allocate, share and swap lazily.
The page table is the per-process map from virtual pages to frames; because a flat table for a 48-bit space would be 512 GB, it is a four-level radix tree that exists only where the address space is populated, walked by the hardware from a base register the kernel loads at each context switch.
Walking four levels of page table on every load would make memory five times slower; the translation lookaside buffer caches recent virtual-to-physical translations so that the walk happens once per page per working set — and its reach, its flushes and its tagging decide how expensive huge heaps and context switches really are.
When a translation finds no present page, the CPU traps to the kernel, which classifies the fault — minor (page exists or can be made cheaply: lazy allocation, page cache, copy-on-write), major (read from disk or swap), or invalid (SIGSEGV) — fixes the page table, and resumes the same instruction.
When processes want more memory than the machine has, the kernel reclaims: it drops clean page-cache pages, writes dirty ones back, pushes cold anonymous pages to swap, and — if the working sets still do not fit — kills a process; "free memory" was never the number that mattered.
mmap makes a file appear as a range of the address space — reads become page faults served from the page cache, writes dirty shared pages the kernel flushes later — which is why databases and high-performance systems love it and why its unpredictable stalls and lack of write control make some of them avoid it.
After fork the parent and child share every page read-only; the first write to a page faults, the kernel copies just that page, and the two processes diverge one page at a time — so forking a 10 GB process is cheap until someone writes, which is exactly the property Redis snapshots and process spawning depend on.
Paths, files, directories, metadata, permissions, offsets; the descriptor table; how a path becomes blocks on storage; inodes on Unix-style systems.
Why? — What is the difference between a filename and a file — and what is FD 3?
A filename is a directory entry that points at a file object; the object has metadata, permissions and blocks, and it can outlive — or have several — names.
A descriptor is a small integer indexing a per-process table whose entries point at system-wide open file descriptions (offset, flags) which in turn point at file objects — and on Linux sockets, pipes, timers and epoll instances are descriptors too.
A file system turns a path into directory lookups, metadata, and a map from byte ranges to storage blocks, tracks which blocks are free, and uses journaling or copy-on-write so that a crash halfway through an operation does not leave garbage.
On Unix-style file systems the inode is the file: a fixed-size record holding mode, owner, size, timestamps, link count and the block map — everything except the name, which lives in directories that point at it by number.
Follow a `read()` from the call to the SSD and back; blocking, non-blocking, asynchronous and multiplexed I/O; and why a file and a socket are the same kind of thing.
Why? — How can one thread wait for 10,000 sockets without spinning?
One `read(fd, buf, 4096)` goes from a libc wrapper through a mode switch into the VFS, the file system’s block map and the page cache; a hit is a microsecond memory copy, a miss is a block-layer request, a DMA transfer and a wake-up ~100 µs later on an SSD.
Four I/O models differ in two independent questions — does the call return before the data is ready, and who moves the data — and high-concurrency servers exist in the corner where the kernel tells you readiness (epoll/kqueue) or completion (io_uring/IOCP) so one thread can wait on thousands of descriptors.
A thread per socket is too expensive and a polling loop burns CPU, so the kernel provides one call that sleeps on many descriptors and returns the ready ones — `select` and `poll` scan the whole set per call, `epoll` and `kqueue` keep an interest list and return only what changed, and Windows IOCP reports completions instead of readiness.
On Unix-style systems a file, a socket, a pipe, a device, an eventfd and a timer all sit behind descriptors that answer to the same `read`/`write`/`close`/`poll` verbs; what differs is whether the thing is seekable, whether reads and writes can be short, and what end-of-file means.
Race conditions, lost updates, visibility, critical sections, mutexes, semaphores, atomics, and the four ingredients of a deadlock.
Why? — Two threads both add one to a counter and the result is one. Why?
Concurrency bugs come in seven recognisable shapes — race, lost update, visibility/ordering, deadlock, livelock, starvation, priority inversion — each with a distinct mechanism and a distinct production symptom, and naming the shape is most of the diagnosis.
`counter++` is three instructions — load, add, store — and if two threads interleave between them one increment is lost; races are timing-dependent by nature, and the same read-then-act shape on the file system (TOCTOU) is a security bug rather than a counting bug.
A critical section is the stretch of code that touches shared state and must not interleave with another such stretch; synchronisation exists to enforce that, and its cost is decided by how much you put inside, how long you hold it, and how many threads want it — which Amdahl’s law turns into a hard ceiling on speedup.
A mutex is a lock with an owner: the thread that locks it must unlock it, a second thread that wants it waits — spinning briefly or sleeping in the kernel via a futex — and the uncontended path is a single atomic instruction that never enters the kernel.
A semaphore is a counter with blocking decrement and non-blocking increment and no notion of an owner — the right tool for "at most N at once" and for signalling between threads — while a mutex owns and a condition variable waits for a predicate; the three are different primitives, not interchangeable spellings.
CPUs provide indivisible read-modify-write instructions — fetch-and-add, compare-and-swap, load-linked/store-conditional — that make a lock-free counter possible and every lock implementable; the subtleties are the ABA problem and the memory-ordering flags that say what else becomes visible when an atomic does.
A deadlock needs four things at once — mutual exclusion, hold-and-wait, no preemption, circular wait — and the last is a cycle in the waits-for graph, which is why detection is depth-first search, prevention is imposing an order on the graph, and a database deadlock detector runs the same algorithm on transactions.
Pipes, shared memory, message queues, signals and sockets — compared on speed, isolation, complexity and local-vs-remote.
Why? — Two isolated processes need to talk. What are the options and what does each cost?
Processes are isolated by design, so every way for two of them to communicate — pipes, shared memory, sockets, message queues, signals — is a hole the kernel punches on purpose, and each hole trades speed, isolation and reach differently.
A pipe is a small kernel-owned ring buffer with a write end and a read end; the kernel blocks the writer when it is full and the reader when it is empty, turns the last close into EOF, and that is enough to build every shell pipeline and every `subprocess.PIPE`.
Map the same physical pages into two address spaces and data moves between processes at the speed of a load and a store — but the kernel is no longer between them, so every rule about who may write when has to be rebuilt in user space with process-shared locks and atomics.
A signal is a number delivered to a process at a moment it did not choose, interrupting whatever it was doing; that is enough to implement Ctrl-C, graceful shutdown and crash reporting, and it is also why signal handlers are the most constrained code you will ever write.
The socket abstraction as the OS sees it: a descriptor with buffers behind it, and the bridge into the Networking domain.
Why? — What does the kernel actually give me when I call `socket()`?
Containers are not small virtual machines: namespaces, control groups, layered filesystems, shared host kernel, and how that differs from a hypervisor.
Why? — If a container has no kernel of its own, what is actually isolating it?
A container is an ordinary process (or tree of processes) whose view of the system has been narrowed by namespaces, whose resource use is capped by control groups, and whose root filesystem is a stack of layers — there is no guest kernel, which is both why containers are cheap and why their isolation is weaker than a VM’s.
Two containers on a host each have a process that believes it is PID 1 and a process tree that stops at it; the host kernel sees all of them as ordinary processes with ordinary PIDs, which is exactly why a kernel bug is a bug in every container at once.
A VM puts a whole guest kernel and virtual hardware between the workload and the host; a container puts only a narrowed view of one shared kernel — the difference decides isolation strength, startup time, density and which kernel you get, and microVMs exist because neither answer was right for running other people’s code.
A configurable OS simulator — cores, processes, threads, RAM, I/O, locks — and a panel of buttons to break it, then diagnose the failure.
Why? — What happens to the scheduler, memory and I/O when I exhaust something?
A configurable model of a kernel — set cores, processes, threads, RAM, I/O operations and locks, then watch the scheduler, context switches, memory, page faults, blocked tasks and lock contention respond; every number is an educational simulation of the mechanisms in this domain, not a measurement of any real machine.
Six buttons that push a simulated system past a limit — memory, locks, threads, disk, descriptors, the stack — each paired with what the real symptom looks like in logs and tools, and the one diagnostic question that separates the failure from its look-alikes.
High CPU, high memory, hangs, too many open files: the diagnosis playbook, and the capstone — explain a 50,000-connection server layer by layer, then diagnose what was injected.
Why? — The process is at 100% CPU. What are the four different things that could mean?
High CPU, high memory, a hang and “too many open files” are the four symptoms the OS shows you; each hides three or four different causes with different fixes, and each cause has one observation — user vs system time, RSS vs virtual, `D` vs `S` state, what the descriptors are — that tells it apart from its neighbours.
Explain, layer by layer, how a Node.js, Python or C++ server holds 50,000 open connections — process, threads or event loop, sockets, descriptors, per-connection memory, the scheduler, system calls, the kernel network stack, buffers, the I/O model and CPU — then diagnose the five faults the simulator injects.
Follow `send()` through the socket API, the kernel, the transport stack and the NIC to a server that wakes up in `recv()`; build a tiny server from blocking to event-driven; buffers, backpressure, zero-copy and a combined failure simulator.
Why? — What actually happens between writing `send()` and another machine’s process waking up?
Between `send()` returning in one process and `recv()` returning in another there are two kernels, two NICs, three copies, a congestion gate, a routing decision and at least one context switch — and every one of them is a place where bytes wait.
Six versions of the same server, each one born from the specific failure of the previous one: a single request, a blocking loop, a thread per client, a pool, non-blocking sockets, and finally an event loop.
accept → read → process → write → next: the simplest correct server, and the clearest demonstration that a blocking call parks the whole program on one client’s behaviour.
Give every client its own thread and let the scheduler interleave them: the code stays sequential and the OS supplies the concurrency — until the number of threads becomes the workload.
A fixed set of workers pulling connections from a bounded queue: thread cost becomes a constant, overload becomes a queue length you can see, and the slow client returns as "one slow request occupies a worker".
One thread, many non-blocking sockets, and a kernel API that says which ones are ready: the server sleeps until something happens and then does exactly the work that is possible — as long as nothing in it ever blocks.
The limits are concrete and countable: threads, descriptors, kernel socket memory, wake-up cost, ephemeral ports, middlebox state — and each has a mechanism that moved it, which is why the number went from 10K to 10M without the laws of physics changing.
Application buffer → socket send buffer → device queue → wire → NIC ring → socket receive buffer → application: a chain of bounded queues in which every full buffer pushes back on the one above, sized by bandwidth × delay and dangerous when oversized.
A fast sender and a slow reader: the receive buffer fills, the window closes, the send buffer fills, and the sender’s `write()` blocks, returns EAGAIN, returns false, or awaits — depending only on which I/O model it chose. Buffer in user space instead and it fails by running out of memory.
Serving a file the naive way copies it four times and crosses the user/kernel boundary four times; `sendfile`, `splice`, scatter-gather DMA and, at the extreme, kernel bypass remove the copies the CPU does not need to make — until TLS puts one back.
High-throughput systems are built by letting the page cache be the shared buffer between disk, process and NIC, and by batching every crossing of the user/kernel boundary: Kafka’s log, a database’s buffer pool, `writev`, and io_uring.
Application → kernel → network → remote: exhaust descriptors, fill a buffer, block a thread, drop packets, add latency, kill the process, restart the server — and follow each failure across the layers to the symptom the user sees and the tool that proves it.
A user in Warsaw opens your application hosted in another region and the page takes three seconds: enumerate every layer where the time could be, assign each to the domain that explains it, put a typical cost and a measurement next to each, and bisect.