Operating Systems Lesson Plan
A progressive curriculum to understand how your OS works by observing it directly.
Lesson 1: Processes
Section titled “Lesson 1: Processes”Goal: Understand what a process is, how processes are created, and how to observe them.
Concepts
Section titled “Concepts”A process is a running program — it has a PID, memory space, file descriptors, and a state. The kernel creates processes using fork (clone the parent) and exec (replace with a new program). Every process has a parent; PID 1 is the root. Processes transition through states: running, sleeping, stopped, zombie.
Exercises
Section titled “Exercises”-
Observe running processes
Terminal window ps aux | head -20 # Snapshot of all processesps -eo pid,ppid,state,comm # Show parent PIDs and stateps -p $$ # Your current shell processecho $$ # Your shell's PID -
Watch processes in real time
Terminal window top -l 1 -n 10 # macOS: one snapshot, top 10# Or on Linux:top -b -n 1 | head -20Press
qto quit interactive top. Note the columns: PID, CPU%, MEM%, STATE, COMMAND. -
Explore the process tree
Terminal window pstree -p $$ 2>/dev/null || ps -eo pid,ppid,comm | head -30# See parent-child relationships# On macOS, install pstree: brew install pstree -
Create processes with fork/exec
fork_demo.py import ospid = os.fork()if pid == 0:print(f"Child: PID={os.getpid()}, Parent={os.getppid()}")else:print(f"Parent: PID={os.getpid()}, Child={pid}")os.waitpid(pid, 0)Terminal window python3 fork_demo.py
Checkpoint
Section titled “Checkpoint”Run ps -eo pid,ppid,state,comm and identify your shell, its parent, and at
least one zombie or sleeping process.
Lesson 2: Memory
Section titled “Lesson 2: Memory”Goal: Understand virtual memory, address spaces, and how to observe memory usage.
Concepts
Section titled “Concepts”Each process gets its own virtual address space — it sees a flat range of addresses regardless of physical RAM layout. The kernel maps virtual pages to physical frames using page tables. When a process accesses a page not in RAM, a page fault loads it from disk. Swap extends physical memory to disk at the cost of speed. Resident set size (RSS) shows how much physical RAM a process actually uses.
Exercises
Section titled “Exercises”-
Observe system memory
Terminal window # macOSvm_stat # Page-level memory statssysctl hw.memsize # Total physical RAM# Linux# free -h # Human-readable summary# cat /proc/meminfo # Detailed breakdown -
Watch per-process memory
Terminal window ps -eo pid,rss,vsz,comm --sort=-rss 2>/dev/null | head -15# rss = resident set (physical), vsz = virtual size# macOS alternative:ps -eo pid,rss,vsz,comm | sort -k2 -rn | head -15 -
Allocate memory and observe
mem_demo.py import os, timeprint(f"PID: {os.getpid()}")input("Press Enter to allocate 100MB...")data = bytearray(100 * 1024 * 1024) # 100 MBprint("Allocated. Check RSS with: ps -o pid,rss -p " + str(os.getpid()))input("Press Enter to exit...")Terminal window python3 mem_demo.py# In another terminal, watch the RSS grow:ps -o pid,rss,vsz -p <PID> -
Observe page faults
Terminal window # macOS -- use /usr/bin/time (not shell builtin)/usr/bin/time -l python3 -c "x = bytearray(50_000_000)" 2>&1 | grep fault# Linux# /usr/bin/time -v python3 -c "x = bytearray(50_000_000)" 2>&1 | grep fault
Checkpoint
Section titled “Checkpoint”Run the memory allocation script. Confirm RSS increases by roughly 100MB using
ps.
Lesson 3: File Systems
Section titled “Lesson 3: File Systems”Goal: Understand inodes, file descriptors, and the “everything is a file” abstraction.
Concepts
Section titled “Concepts”Files on disk are represented by inodes — data structures storing metadata (size, permissions, block pointers) but not the name. Directory entries map names to inode numbers. When a process opens a file, the kernel returns a file descriptor (a small integer). File descriptors 0, 1, 2 are stdin, stdout, stderr. The “everything is a file” philosophy means devices, pipes, and sockets all use the same read/write interface.
Exercises
Section titled “Exercises”-
Examine inodes
Terminal window ls -i /etc/hosts # Show inode numberstat /etc/hosts # Full inode metadata# Create a hard link -- same inode, different nameecho "hello" > /tmp/original.txtln /tmp/original.txt /tmp/hardlink.txtls -i /tmp/original.txt /tmp/hardlink.txt # Same inodestat /tmp/original.txt # Note the link count -
Explore file descriptors
Terminal window # See open file descriptors for your shellls -la /dev/fd/# macOS: lsof on your shelllsof -p $$ -
Watch file descriptors in action
fd_demo.py import osf = open("/tmp/fd_test.txt", "w")print(f"File descriptor: {f.fileno()}")print(f"PID: {os.getpid()}")input("Check lsof -p <PID> in another terminal. Press Enter to close...")f.close()Terminal window python3 fd_demo.py# In another terminal:lsof -p <PID> | grep fd_test -
See “everything is a file”
Terminal window file /dev/null # Character special devicefile /dev/stdin # Link to fd/0echo "hello" > /dev/null # Write to the voidcat < /dev/urandom | head -c 16 | xxd # Read random bytes
Checkpoint
Section titled “Checkpoint”Create a file, find its inode with ls -i, open it in Python, and confirm the
file descriptor appears in lsof.
Lesson 4: I/O
Section titled “Lesson 4: I/O”Goal: Understand blocking vs non-blocking I/O, buffering, and I/O multiplexing.
Concepts
Section titled “Concepts”By default, read and write calls block — the process sleeps until the operation completes. Non-blocking I/O returns immediately if data is not ready. Buffering (in libc or the kernel) batches small writes into larger ones for efficiency. I/O multiplexing (select, poll, epoll/kqueue) lets a single thread monitor many file descriptors at once, which is how event-driven servers (nginx, Node.js) handle thousands of connections.
Exercises
Section titled “Exercises”-
Observe blocking I/O
blocking_io.py import os, timeprint(f"PID: {os.getpid()}")print("Blocking on stdin... (type something)")data = input()print(f"Got: {data}")Terminal window python3 blocking_io.py &ps -o pid,state,comm -p $! # Process is sleeping (S)fg # Bring back and type input -
See buffering effects
Terminal window # Unbuffered vs buffered outputpython3 -c "import sys, timefor i in range(5):sys.stdout.write(f'{i} ')time.sleep(0.5)print()"# Output appears all at once (buffered to non-terminal)# Pipe to cat to see buffering:python3 -c "import sys, timefor i in range(5):sys.stdout.write(f'{i} ')sys.stdout.flush() # Force unbufferedtime.sleep(0.5)print()" -
I/O multiplexing with select
select_demo.py import select, sysprint("Type lines (Ctrl-D to quit). Timeout after 3s of silence.")while True:ready, _, _ = select.select([sys.stdin], [], [], 3.0)if ready:line = sys.stdin.readline()if not line:breakprint(f" Got: {line.strip()}")else:print(" (no input for 3 seconds)")Terminal window python3 select_demo.py -
Measure I/O throughput
Terminal window # Write 100MB and measure speeddd if=/dev/zero of=/tmp/testfile bs=1m count=100 2>&1# Read it backdd if=/tmp/testfile of=/dev/null bs=1m 2>&1rm /tmp/testfile
Checkpoint
Section titled “Checkpoint”Run the select demo. Confirm it detects both input and timeouts. Explain why a web server uses multiplexing instead of one thread per connection.
Lesson 5: Scheduling
Section titled “Lesson 5: Scheduling”Goal: Understand how the kernel allocates CPU time and how to observe scheduling behavior.
Concepts
Section titled “Concepts”The scheduler decides which process runs on which CPU and for how long. Modern schedulers use preemptive multitasking — they interrupt running processes to share the CPU fairly. Priority and “nice” values influence scheduling decisions. A process with a higher nice value yields more CPU time to others. Real-time processes get strict priority guarantees.
Exercises
Section titled “Exercises”-
Observe scheduling with top
Terminal window # Watch CPU distribution in real timetop -o cpu # macOS: sort by CPU# top -o %CPU # Linux alternative# Look at: TIME (total CPU used), STATE, PRI/NI columns -
Experiment with nice values
Terminal window # Run a CPU-bound task at normal prioritynice -n 0 python3 -c "import time; start = time.time()x = sum(range(50_000_000))print(f'Normal: {time.time()-start:.2f}s')"# Run with low priority (be "nicer" to other processes)nice -n 19 python3 -c "import time; start = time.time()x = sum(range(50_000_000))print(f'Nice 19: {time.time()-start:.2f}s')" -
Observe context switches
Terminal window # macOS/usr/bin/time -l python3 -c "import timefor _ in range(1000): time.sleep(0.001)" 2>&1 | grep "voluntary context"# Linux# /usr/bin/time -v python3 -c "..." 2>&1 | grep context -
Compete for CPU
Terminal window # Start two CPU-bound tasks with different nice valuesnice -n 0 python3 -c "x=sum(range(100_000_000)); print('normal done')" &nice -n 19 python3 -c "x=sum(range(100_000_000)); print('nice done')" &wait# The normal-priority task should finish first
Checkpoint
Section titled “Checkpoint”Run two competing tasks with different nice values. Confirm the lower-nice (higher-priority) task gets more CPU time.
Lesson 6: Concurrency
Section titled “Lesson 6: Concurrency”Goal: Understand threads vs processes, race conditions, and synchronization primitives.
Concepts
Section titled “Concepts”Threads share memory within a process; processes have separate address spaces. Shared memory makes threads fast to communicate but dangerous — two threads writing the same variable cause race conditions. Locks (mutexes) prevent concurrent access to shared data. Deadlock occurs when two threads each hold a lock the other needs. Python’s GIL serializes CPU-bound threads, so use multiprocessing for true parallelism in Python.
Exercises
Section titled “Exercises”-
Threads vs processes
threads_vs_procs.py import threading, multiprocessing, osdef show_ids(label):print(f"{label}: PID={os.getpid()}, TID={threading.get_ident()}")t = threading.Thread(target=show_ids, args=("Thread",))t.start(); t.join()p = multiprocessing.Process(target=show_ids, args=("Process",))p.start(); p.join()show_ids("Main")# Thread has same PID as Main; Process has a different PIDTerminal window python3 threads_vs_procs.py -
Demonstrate a race condition
race.py import threadingcounter = 0def increment():global counterfor _ in range(1_000_000):counter += 1threads = [threading.Thread(target=increment) for _ in range(4)]for t in threads: t.start()for t in threads: t.join()print(f"Expected: 4000000, Got: {counter}")# Result will be less than 4000000 due to race conditionsTerminal window python3 race.py -
Fix with a lock
race_fixed.py import threadingcounter = 0lock = threading.Lock()def increment():global counterfor _ in range(1_000_000):with lock:counter += 1threads = [threading.Thread(target=increment) for _ in range(4)]for t in threads: t.start()for t in threads: t.join()print(f"Expected: 4000000, Got: {counter}")# Correct, but much slower due to lock contentionTerminal window python3 race_fixed.py -
Observe threads in the OS
Terminal window python3 -c "import threading, time, osprint(f'PID: {os.getpid()}')def worker(): time.sleep(30)for i in range(4): threading.Thread(target=worker, daemon=True).start()time.sleep(30)" &# View threads (macOS)ps -M -p $!# Linux: ps -T -p <PID>kill $!
Checkpoint
Section titled “Checkpoint”Run the race condition demo. Confirm the result is wrong. Apply the lock and confirm correctness.
Lesson 7: System Calls
Section titled “Lesson 7: System Calls”Goal: Understand the userspace/kernel boundary and trace system calls with real tools.
Concepts
Section titled “Concepts”System calls are the interface between user programs and the kernel. When your
code calls open(), read(), or write(), the C library translates these into
system calls that trap into kernel mode. On macOS, use dtruss (requires SIP
disabled or sudo). On Linux, use strace. Tracing system calls reveals
exactly what a program asks the kernel to do — invaluable for debugging file,
network, and permission issues.
Exercises
Section titled “Exercises”-
Trace a simple command
Terminal window # macOS (requires sudo)sudo dtruss -f ls /tmp 2>&1 | head -40# Linux# strace ls /tmp 2>&1 | head -40# Look for: open/openat, read, write, close, stat -
Count system calls by type
Terminal window # macOSsudo dtruss ls /tmp 2>&1 | awk '{print $1}' | sort | uniq -c | sort -rn | head# Linux# strace -c ls /tmp# Shows a summary table of syscall counts and time -
Trace a Python script
syscall_demo.py f = open("/tmp/syscall_test.txt", "w")f.write("hello from userspace")f.close()Terminal window # macOSsudo dtruss python3 syscall_demo.py 2>&1 | grep syscall_test# Linux# strace -e openat,write,close python3 syscall_demo.py# You will see openat(), write(), close() for your file -
Trace network system calls
Terminal window # macOSsudo dtruss curl -s https://example.com -o /dev/null 2>&1 | grep -E "socket|connect|send|recv" | head -20# Linux# strace -e socket,connect,sendto,recvfrom curl -s https://example.com -o /dev/null
Checkpoint
Section titled “Checkpoint”Trace cat /etc/hosts and identify the open, read, write, and close system
calls in the output.
Lesson 8: Putting It Together
Section titled “Lesson 8: Putting It Together”Goal: Trace a web request through the full OS stack — processes, memory, files, I/O, and syscalls.
Concepts
Section titled “Concepts”When a browser requests a page, dozens of OS mechanisms activate: DNS resolution (socket syscalls), TCP connection (connect, send, recv), the server forks or dispatches a thread, reads files from disk (open, read), allocates memory for the response, and writes it back over the socket. Understanding this full path turns the OS from an abstraction into a visible machine you can inspect and debug.
Exercises
Section titled “Exercises”-
Run a minimal HTTP server and observe it
server.py from http.server import HTTPServer, SimpleHTTPRequestHandlerimport osos.chdir("/tmp")with open("index.html", "w") as f:f.write("<h1>Hello OS</h1>")print(f"Server PID: {os.getpid()}")HTTPServer(("127.0.0.1", 8080), SimpleHTTPRequestHandler).serve_forever()Terminal window python3 server.py &SERVER_PID=$!# Observe the processps -o pid,state,comm -p $SERVER_PID# See its open file descriptorslsof -p $SERVER_PID | head -20# Make a requestcurl http://127.0.0.1:8080/index.htmlkill $SERVER_PID -
Trace the server handling a request
Terminal window python3 server.py &SERVER_PID=$!# macOS: trace syscalls while making a requestsudo dtruss -p $SERVER_PID 2>/tmp/trace.out &sleep 1curl http://127.0.0.1:8080/index.htmlsleep 1kill $SERVER_PID# Examine the trace -- find accept, read, open, write, closegrep -E "accept|read|write|open|close|send" /tmp/trace.out | head -30 -
Observe the full connection lifecycle
Terminal window python3 server.py &SERVER_PID=$!# Watch network connectionslsof -i :8080 # See LISTEN statecurl http://127.0.0.1:8080/index.html &lsof -i :8080 # See ESTABLISHED during request# Check memory usageps -o pid,rss,vsz -p $SERVER_PIDkill $SERVER_PID -
Build a mental model end to end
Terminal window # Trace a full curl request to see every OS interaction# macOSsudo dtruss curl -s http://127.0.0.1:8080/index.html -o /dev/null 2>&1 | \grep -E "socket|connect|send|recv|write|read" | head -30# Map each syscall to a layer:# socket() -> create network endpoint# connect() -> TCP handshake# send() -> HTTP request# recv() -> HTTP response# close() -> tear down connectionStart the server again before running this, then clean up after.
Checkpoint
Section titled “Checkpoint”Start the HTTP server. Make a request while tracing. Identify at least one syscall from each category: network (socket/connect), file (open/read), and I/O (write/send).
Practice Projects
Section titled “Practice Projects”Project 1: Process Monitor
Section titled “Project 1: Process Monitor”Write a script that polls ps every second and logs when new processes appear
or existing ones exit. Track PIDs, parent PIDs, and lifetimes. Run it for 5
minutes during normal usage and analyze the output.
Project 2: Memory Pressure Experiment
Section titled “Project 2: Memory Pressure Experiment”Write a program that allocates memory in 10MB increments, pausing between each. Monitor RSS, virtual size, and swap usage as you approach system limits. Record the point where the OS starts swapping and measure the performance cliff.
Project 3: Web Server Autopsy
Section titled “Project 3: Web Server Autopsy”Start a Python HTTP server. Use lsof, ps, and dtruss/strace to produce a complete annotated log of what happens when a client connects, requests a file, and disconnects. Document every process, file descriptor, and system call involved.
Quick Reference
Section titled “Quick Reference”| Topic | Key Commands |
|---|---|
| Processes | ps aux, top, pstree, kill, wait |
| Memory | vm_stat, vmstat, ps -o rss, free -h |
| Files | ls -i, stat, lsof, file, /dev/fd |
| I/O | dd, select, lsof -i, iostat |
| Scheduling | nice, renice, top -o cpu, taskset |
| Concurrency | ps -M (threads), ps -T, htop |
| Syscalls | dtruss (macOS), strace (Linux), dtrace |
| Network | lsof -i, netstat, ss, tcpdump |
See Also
Section titled “See Also”- Unix Commands — Shell tools used throughout these lessons
- Complexity — Why OS abstractions exist and when they leak
- Concurrency
- Networking
- tmux