How Code Runs - From Instructions to Running Processes
Context
A program is a file sitting on disk doing nothing at all. A process is that same file loaded into memory and actually executing, with the operating system deciding moment by moment when it gets the processor. Those are two different things, and the gap between them is where most confusion about "my code is running" lives.
Every piece of machine learning work you will ever do arrives as a process. You launch a training script and the operating system hands it a process ID. You call one script from another to preprocess a dataset and you have just created a second process. A data loader that reads images while the model trains is doing it in worker processes the framework started for you.
Without this distinction, a script that is stuck and a script that is working look identical from the outside, and you have no way to ask the machine which one you have. You cannot explain why closing a terminal killed a six-hour training run, or why a program that ran fine yesterday is now competing with something invisible for memory. This is the first lesson of the module, so the vocabulary set here is what every later lesson stands on.
Goal
- Distinguish an instruction, a program, and a process precisely, and explain why a file on disk is not the same as something running in memory.
- Trace how one line of Python expands into multiple low-level instructions using the built-in
dismodule. - Identify the operating system's role between your code and the hardware, and inspect it from Python with the
platformandosmodules. - Launch a real process from Python, read its PID, and terminate it using the
subprocessmodule. - Trace a process through its lifecycle states (New → Ready → Running → Waiting → Terminated) and detect the Terminated state with
poll().
Prerequisites
Neither of these is required and the lesson does not assume you watched them. They are here if you would rather see the picture before you build it, and the Theory section teaches everything from zero either way.
- (YouTube) Operating Systems: Crash Course Computer Science #18 - CrashCourse channel. Thirteen minutes on how an OS loads and juggles running programs.
- (YouTube) The Central Processing Unit (CPU): Crash Course Computer Science #7 - CrashCourse channel. What a single machine instruction actually is, and how the CPU runs them one after another.
Table of Contents
- Instruction
- Program
- Operating System
- Process
- Process Lifecycle
Theory
Instruction
What. An instruction is a single, atomic step the CPU can perform, load a value, add two numbers, jump to another step. It is the smallest unit of "doing something" on a computer.
Why. Everything a computer does, from training a model to drawing this text, is millions of these tiny steps executed in order. Knowing that high-level code becomes many small instructions kills the beginner illusion that "one line = one thing the computer does."
When. This mental model matters every time you reason about performance, debugging, or why a "simple" line is slower than you expected. You won't write instructions by hand, but you will constantly reason about them.
You write code in a high-level language (Python). The CPU cannot read Python directly, it only understands its own instruction set. Python first translates your code into an intermediate set of instructions called bytecode, and Python's built-in dis (disassembler) module lets you see that translation. dis ships with Python, nothing to install.
import dis # built-in module: turns Python code into the list of low-level steps it becomes
def add_features(a, b): # a tiny program: take two numbers, add them
return a + b # ONE line of source code...
dis.dis(add_features) # ...print every instruction this line actually expands intoOn Python 3.12.8 this prints:
3 0 RESUME 0
4 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
The left number is the source line, printed where that line's instructions begin, the next is each instruction's offset (its position in the bytecode), and then come the instruction's name and its argument, with what the argument stands for in parentheses. LOAD_FAST pushes a and then b onto the stack (Python's pile of working values), BINARY_OP pops both and pushes their sum, and RETURN_VALUE hands that sum back to the caller.
The single line return a + b became four low-level steps, and dis lists a fifth, RESUME, against the def line, the prologue (a setup step that readies the function to run) Python 3.11+ adds to every function. That is the whole point: one readable line of yours is several lower-level steps underneath. Python's interpreter, itself a program the CPU is running, works through them top to bottom.
import dis
dis.dis("learning_rate = 0.01") # disassemble even a single assignment statementThis shows the assignment also breaks into steps, load the constant 0.01, then store it under the name learning_rate. Even "doing one thing" (assigning a variable) is several steps for Python's interpreter to work through.
DON'T:
- DON'T equate one line of Python with one CPU instruction, one line almost always expands into several.
- DON'T try to memorize bytecode opcode names. The goal is the concept (code → many small steps), not the vocabulary.
- DON'T expect identical
disoutput across Python versions, opcodes change between them.
Program
What. A program is a complete, ordered collection of instructions saved as a file (for us, a .py file) on disk. It is static, just bytes sitting in storage.
Why. Beginners say "run the program" and "the program is doing X" as if the file itself is alive. It isn't. A program is a recipe written down, it does nothing on its own. Separating the recipe (program) from the act of cooking (process, next sections) is the single most important distinction in this lesson.
When. Every time you save a .py file you have authored a program. Every time you type python3 something.py you are asking the OS to turn that program into action.
Let's create a real program. Open your editor, type the following, and save it as train.py:
# train.py - a program: an ordered list of instructions saved as a file
dataset = [3, 1, 4, 1, 5] # some data the program will work on
total = 0 # a place to accumulate the sum
for value in dataset: # repeat the next step once per item
total = total + value # add this value to the running total
print("sum of dataset:", total) # report the result to the screenThis file is a program. Right now, saved on disk, it is inert, it has not added anything. It is text describing what should happen. Now ask the OS to execute it from your terminal:
# same on every OS (Windows users: inside the WSL terminal)
python3 train.pyThis prints sum of dataset: 14. The instructions only ran because you invoked them, the file itself never "started."
# Prove the program is just bytes on disk, list the file's size, it's storage, not activity
ls -l train.py # '-l' = long listing: shows the size in bytes, same command everywhere (WSL included)This shows train.py as a file with a size in bytes. A program is exactly that: stored bytes waiting to be executed. Nothing about it is "running."
DON'T:
- DON'T say a program "is doing" something while it sits unopened on disk, it is passive until executed.
- DON'T confuse the file (the program) with the live, running instance of it (the process, defined two sections down).
- DON'T assume saving a file runs it. Saving and running are two separate actions.
Operating System
What. The operating system (OS) is the master program that controls the whole computer: it manages the hardware (CPU, memory, disk), starts and stops your programs, and hands out resources. Windows, macOS, and Linux are operating systems.
Why. Your Python code never touches the hardware directly. When you print(), read a file, or start a program, your code asks the OS to do it. The OS is the gatekeeper and referee for every program running at once. Understanding it explains why your code behaves the same logically but differs in tiny OS-specific ways (paths, commands).
When. This matters whenever code reads/writes files, talks to the network, spawns other programs, or behaves differently on a teammate's machine. It is the backdrop of everything.
You can interrogate the OS from Python using two built-in modules: platform (facts about the OS) and os (a doorway to OS services). Neither needs installing.
import platform # built-in: descriptive info about the operating system
import os # built-in: the standard doorway to ask the OS for things
print(platform.system()) # the OS family: Windows, Linux, or Darwin (= macOS)
print(platform.release()) # the OS version string
print(os.getcwd()) # ask the OS: "which folder am I running in right now?"This prints your OS name, its version, and your current working directory. Notice your program didn't know any of this itself, it asked the OS, which is the only component that actually tracks it.
import os
# the OS reports how many logical CPUs it schedules on, often two per physical core
print("logical CPUs the OS manages:", os.cpu_count())Your program cannot count CPUs. The OS knows the hardware and answers when asked. That request-and-answer pattern is the relationship between your code and the OS.
DON'T:
- DON'T assume your program talks to hardware directly, every keypress, file, and pixel goes through the OS.
- DON'T hardcode one OS's commands/paths if your code must run elsewhere (Windows uses
\, macOS/Linux use/), ask the OS viaosinstead. - DON'T confuse the OS (the manager) with your program (one of the many things it manages).
Process
What. A process is a program in execution, a live instance loaded into memory and actively running its instructions. The OS gives every process its own private memory and a unique number called a PID (Process ID).
Why. This is the payoff of the whole lesson. The program is the dead recipe, the process is the meal being cooked right now. One program can be running as many separate processes at once (run the same script in two terminals at once → two processes, two PIDs). Confusing the two is the root of countless beginner misunderstandings about memory, crashes, and "why is it still running?".
When. Every running thing on your machine is a process, your editor, your browser tab, the python3 train.py you just launched. Whenever you start, monitor, or kill something, you are operating on a process.
Your currently running Python is itself a process. Ask the OS for its PID:
import os
# the unique ID the OS assigned to me
print("this running program is process", os.getpid())Run that same script twice and you get two different PIDs. Same program, two distinct processes.
You can also start a brand-new process from Python with the built-in subprocess module:
import subprocess, sys # subprocess: start other programs as their own processes
# sys.executable: the path to the Python that's running right now
# launch a separate process that runs a tiny one-line Python command:
proc = subprocess.Popen([sys.executable, "-c", "print('hello from a child process')"])
# the OS gave the child its own PID
print("I started a child process, its PID is", proc.pid)
proc.wait() # pause here until that child process finishesFor a moment two processes existed at once, parent and child, each with its own PID and its own memory. The parent is not running the child's code, it is waiting on a separate living thing.
# See the live process list your OS maintains right now (same command everywhere, WSL included):
ps aux # 'ps' = process status, 'aux' = show ALL processes with their owners and PIDsYour shell and the ps aux you just typed both appear in that list, alongside everything else still running, which is concrete proof that "process" means running thing rather than file.
DON'T:
- DON'T equate a program with a process, one program file can become many independent processes.
- DON'T assume PIDs are permanent or unique forever, the OS recycles a PID after its process ends.
- DON'T assume a child process shares memory with its parent, by default each process gets its own isolated memory.
Process Lifecycle
What. A process is not simply "on" or "off." It moves through a sequence of states managed by the OS: New (being created) → Ready (waiting for a turn on the CPU) → Running (executing instructions now) → Waiting (paused for something like disk or input) → Terminated (finished or killed).
Why. A computer has few CPU cores but runs hundreds of processes. The OS scheduler rapidly shuffles processes between Ready and Running, and parks them in Waiting when they need slow resources. This is why many programs appear to run "at the same time" on a 4-core machine. It also explains why "my program is running" doesn't mean it's literally on the CPU at this instant, it might be Ready or Waiting.
When. This model underlies everything from "why is my script frozen?" (it's Waiting on input) to "why did training pause?" (Waiting on disk I/O) to graceful shutdown (moving a process to Terminated). It's the dynamic counterpart to the static "process" idea.
A process is admitted into Ready, the scheduler dispatches it to Running, it may drop to Waiting when it needs something slow, return to Ready when that's done, and finally exit to Terminated. It can bounce between Ready ↔ Running ↔ Waiting many times before finishing.
We can watch the start and end of this cycle from Python. poll() peeks at a process: it returns None while the process is still alive, and an exit code number once it has terminated.
import subprocess, sys
# NEW -> READY -> RUNNING: start a process that just sleeps for 5 seconds
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(5)"])
print("PID", proc.pid, "is RUNNING")
# None = still running (alive)
print("finished yet? poll() ->", proc.poll())
proc.terminate() # ask the OS to move it to TERMINATED
proc.wait() # wait until it is fully gone (reaped)
print("done, exit status ->", proc.poll()) # a number = it has terminatedWhile the child sleeps, poll() returns None, it is alive (Running/Waiting). terminate() asks the OS to end it, after wait(), poll() returns a number (the exit code), proving the process reached the Terminated state. You just drove a process through the start and end of its lifecycle.
DON'T:
- DON'T assume a "running" process is glued to the CPU, it constantly cycles between Ready and Running as the OS schedules it.
- DON'T forget to
wait()for a child you started, an un-reaped finished process lingers in the OS table as a leftover entry. (A finished-but-uncollected process is called a "zombie", covered lightly in the assessment.) - DON'T treat
terminate()as instantaneous, the process isn't truly gone untilwait()confirms it.
Additional - what the exit code says
The number poll() returns at the end is the exit code (also called return code): 0 conventionally means "finished successfully," and anything non-zero (or a negative number after terminate()) signals it was stopped or failed. You'll lean on exit codes constantly when running real ML training scripts to know whether a job actually succeeded.
Lab Exercises
Both problems first, then both solutions.
Lab Exercise 1: Source Code vs. Instructions
Covers the first half (Instruction, Program).
(a) Write a program saved as scale.py that builds dataset = [10, 20, 30], multiplies every value by 2, and prints the new list. Run it from your terminal.
(b) In a second file, use dis to disassemble the function below and identify how many instructions its single line return x * 2 produces.
def double(x):
return x * 2Lab Exercise 2: Who Am I, and Who Did I Start?
Covers the second half (Operating System, Process, Process Lifecycle).
Write whoami.py that:
(a) prints the OS name (via platform) and the script's own PID (via os),
(b) starts a child process that prints "child reporting in", prints the child's PID, then waits for it to finish and prints "child terminated".
Solution 1: Source Code vs. Instructions
# scale.py - part (a)
dataset = [10, 20, 30]
scaled = [] # somewhere to collect results
for value in dataset:
scaled.append(value * 2) # multiply by 2 and store it
print("scaled dataset:", scaled) # report the new listRun it with python3 scale.py. It prints scaled dataset: [20, 40, 60]. The file did nothing until you invoked it, that's the program-vs-execution point in action.
# part (b)
import dis # built-in disassembler
def double(x): # a one-line function...
return x * 2 # ...this single line is what we inspect
dis.dis(double) # print the instruction listThe output lists four instructions against the return line, LOAD_FAST for x, LOAD_CONST for 2, BINARY_OP for * and RETURN_VALUE, plus the RESUME prologue against the def line. One line of Python → four lower-level steps. That count is the answer.
DON'T:
- DON'T claim
return x * 2is "one instruction",disproves it is several. - DON'T forget to
print()the result in part (a), building the list without printing produces no visible output.
Solution 2: Who Am I, and Who Did I Start?
# whoami.py
import platform, os, subprocess, sys
print("OS:", platform.system())
print("my PID:", os.getpid()) # the OS's unique ID for THIS running process
# (b) start a child process that prints a message:
child = subprocess.Popen([sys.executable, "-c", "print('child reporting in')"])
print("child PID:", child.pid) # the child's own, different PID
child.wait() # NEW->...->TERMINATED: wait until it's done
print("child terminated") # confirmed finishedRunning this prints your OS, the parent's PID, the child's (different) PID, the child's message, and finally child terminated. Two PIDs from one script is the concrete proof that a process is a running instance, not the file.
DON'T:
- DON'T expect the parent and child PIDs to match, they are separate processes by definition.
- DON'T skip
child.wait(), without it the parent may exit before the child is reaped.
Theory Assessment
Conceptual reinforcement, not a coding section. All four questions with their hints first, then all four answers.
Q1: The gap between what you wrote and what runs
Two parts, and they are the same gap seen from either end. First, why is a single line of Python source usually not a single CPU instruction? Second, why is a saved .py file described as inert, and what specific event has to occur before it does any work at all?
Hint: For the first part, recall what dis.dis(...) printed for return a + b. For the second, a recipe written on paper has not cooked anything, so name the equivalent of cooking.
Q2: The middleman
Describe the operating system's role between your Python code and the computer's hardware, and give two concrete examples of services it provides to your program.
Hint: When your code runs print() or os.getcwd(), something has to actually touch the screen or the disk. Name who.
Q3: One recipe, many meals, five states
Define program and process, then describe a real situation where one program corresponds to many processes at once. Then name the five lifecycle states in order, and give one event that moves a process from Running to Waiting.
Hint: Ask what happens to PIDs when you run the same script in two terminals at once. For the states, Waiting is what happens when a process needs something slow that it cannot do itself.
Q4: The process that finished but did not leave
A child process finishes, but its parent never calls wait(). Something of it remains in the operating system. Say what remains and why, what the condition is called, and why a great many of them is a problem. Then answer the part most people get wrong: how is this different from a process sitting in Waiting?
Hint: The exit code has to be collected by someone before the OS can discard the entry. One of the two states in the last part is alive and one is not.
Answer 1:
A single line of Python is high-level and human-readable, and before it can run, Python translates it into several lower-level bytecode instructions, which is why return a + b became load, load, add, return. One source line maps to many lower-level steps, each of them many CPU instructions in turn, not one, and the cause is the abstraction gap rather than anything about the line.
The file is inert for a related reason: a .py file is stored bytes describing instructions, and storage is not execution, so nothing happens on its own. It does work only when the operating system loads it into memory and runs it, which is to say when it becomes a process, triggered by you invoking python3 file.py.
DON'T say "it depends on the line length", even a short assignment expands into multiple instructions. DON'T say "saving the file runs it", saving writes bytes to disk, running is a separate action performed later by the OS.
Answer 2:
The OS is the manager sitting between your program and the hardware: your code cannot touch the CPU, memory, or disk directly, so it asks the OS, which performs the action and returns the result. Two concrete services: printing to the screen and writing files, which is I/O, and allocating memory and CPU time, which includes starting and stopping processes.
DON'T claim your program accesses hardware directly, every such action is a request routed through the OS.
Answer 3:
A program is a static file of instructions on disk. A process is a live, running instance of that program in memory, with its own PID and its own memory. Running the same script in two terminals at once creates two processes with two PIDs from the one program file, the same recipe producing two simultaneous meals.
The five states in order are New → Ready → Running → Waiting → Terminated. A process moves from Running to Waiting when it requests something slow it must pause for, reading from disk or waiting for input, yielding the CPU until that finishes.
DON'T assume two processes from one program share memory or state, each gets its own isolated memory. DON'T read Waiting as "crashed", a waiting process is healthy and returns to Ready when its resource arrives.
Answer 4:
The finished child still has an entry in the OS process table holding its exit code, because nobody collected it via wait(). That is a zombie process. A few are harmless, but many un-reaped entries leak limited process-table slots and PIDs, and can eventually stop new processes from starting.
The difference from Waiting is the one to hold onto: a zombie is already dead and only its bookkeeping remains, while a waiting process is alive and paused. One will never run again and is occupying a slot for nothing. The other is mid-life and will return to Ready the moment its resource arrives. Both look idle from outside, which is exactly why the distinction has to be reasoned about rather than observed.
DON'T confuse the two, and DON'T assume a zombie can be killed, it is already finished, and what clears it is its parent collecting the exit code.
Assignment
One assignment. The problem and its hints first, then the solution.
Assignment: Mini Job Runner with Timeouts
Build runner.py that takes a small list of "jobs" (each a short Python command string), runs each as its own process, but enforces a timeout: if a job runs longer than a set number of seconds, terminate it and mark it TIMEOUT. A job that finishes is judged by its exit code, 0 is OK and anything else is FAILED. For every job, also print how long it actually ran. At the end, print a summary: how many jobs succeeded, failed, and timed out.
Hints:
subprocess.run([...], timeout=SECONDS)raisessubprocess.TimeoutExpiredif the job overruns, wrap it intry/exceptto catch the timeout case and mark it accordingly.- Record
time.perf_counter()before and after each job to compute its duration, and keep three counters (ok / failed / timed-out) to build the final summary.
Solution:
# runner.py
import subprocess, sys, time
jobs = [ # each job is a short Python command
"print('quick job')", # finishes fast -> OK
"import time; time.sleep(5)", # overruns the limit -> TIMEOUT
"raise SystemExit(2)", # exits with code 2 -> FAILED (non-zero exit)
]
TIMEOUT = 2 # seconds allowed per job
ok = failed = timed_out = 0 # summary counters
for cmd in jobs:
start = time.perf_counter() # start the per-job timer
try: # subprocess.run executes the entire run of a subprocess, if it fails (by running over the set timeout) then that failure will be caught in the later section 'except' which catches that error called 'TimeoutExpired'
result = subprocess.run([sys.executable, "-c", cmd], timeout=TIMEOUT)
elapsed = time.perf_counter() - start
if result.returncode == 0: # clean success
ok += 1
print(f"OK ({elapsed:.1f}s) exit 0 | {cmd}")
else: # ran to completion but failed
failed += 1
print(f"FAILED ({elapsed:.1f}s) exit {result.returncode} | {cmd}")
except subprocess.TimeoutExpired: # job overran the timeout
elapsed = time.perf_counter() - start
timed_out += 1 # run() already stopped the process by force
print(f"TIMEOUT ({elapsed:.1f}s) | {cmd}")
print(f"summary -> ok:{ok} failed:{failed} timed_out:{timed_out}")Each job runs as its own process via subprocess.run. The timeout=TIMEOUT argument makes run raise TimeoutExpired (and stop the process by force) if it overruns, which the try/except catches and counts as TIMEOUT. Jobs that finish are classified by their exit code: 0 is OK, anything else is FAILED. time.perf_counter() gives each job's real duration, and three counters build the final summary. This is a tiny but real version of how job/training runners enforce limits in practice.
DON'T forget the except subprocess.TimeoutExpired, without it an overrunning job crashes your runner instead of being recorded. And DON'T treat a non-zero exit code as a crash of your script, it's the child's result and must be handled as data, not an error.
Additional Resources
Optional, and nothing later in the bootcamp depends on either one.
- (YouTube, openly licensed CC-BY-NC-SA) MIT OpenCourseWare - 6.0001 Introduction to Computer Science and Programming in Python, Lecture 1: "What is Computation?". The instruction and program ladder, from an MIT lecture.
- (Official docs) Python -
subprocess, sections "Using the subprocess Module" and "Popen Objects". The full API for launching and managing processes, past the two calls used here.
Summary
This lesson built the ladder that every other lesson in this bootcamp stands on. At the bottom sits the instruction, the single, tiny step a CPU actually performs. Stack those steps in order, write them down, and you have a program: a recipe at rest, doing nothing, just bytes on a disk. Ask the machine to act on that recipe and something new comes alive in memory, a process, with its own identity and its own slice of the computer's attention. Between the recipe and the running meal stands the operating system, the manager that loads programs, hands out CPU and memory, and arbitrates between everything running at once. None of these ideas is exotic, together they are simply the difference between code you wrote and code that is running.
What's worth carrying forward is the distinction between the static and the live. Beginners blur "the program" and "the process," and that blur is the source of a surprising amount of later confusion, about memory, about why something is still running, about why the same script run in two terminals at once becomes two processes that don't interfere. You now have a sharper picture: a program is a noun on disk, a process is a verb in motion, and the lifecycle is the story of that motion from birth to termination, with the OS scheduling the whole cast on a stage that has only a few CPU seats.
You will not think about PIDs and process states every day. But when a training run hangs, when a script silently does nothing, when a job needs to be killed after running too long, this is the layer you'll reach for, and the vocabulary you built today is exactly what makes those moments legible instead of mysterious. Everything from here forward, all the way up to training models in later modules, is just more elaborate programs becoming more elaborate processes, managed by the same operating system, executing the same kind of instructions you disassembled in your very first script.
Cheat Sheet
Part A - Reference Table
| Concept | What it is | Inspect / do it with | Key fact |
|---|---|---|---|
| Instruction | One atomic CPU/bytecode step | dis.dis(fn) |
One source line → many instructions |
| Program | Ordered instructions saved as a file | a .py file, python3 file.py |
Static, inert until executed |
| Operating System | Manager of hardware + running programs | platform.system(), os |
Your code asks the OS, it never touches hardware directly |
| Process | A program running in memory | os.getpid(), subprocess.Popen |
One program → many processes, each its own PID |
| Process Lifecycle | New→Ready→Running→Waiting→Terminated | proc.poll(), proc.terminate(), proc.wait() |
poll() is None while alive, a number once terminated |
Part B - Compact Code Block
import dis, os, platform, subprocess, sys # all built-in, no install
dis.dis("y = x + 1") # instruction: see the low-level steps
print(platform.system()) # OS family: Windows/Linux/Darwin
print("my PID:", os.getpid()) # this process's unique id
# new process
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(2)"])
print(p.pid, "alive?", p.poll()) # poll() -> None means still running
p.terminate(); p.wait() # move it to terminated, then reap it
print("exit code:", p.poll()) # a number now -> it has terminated# run a program / list processes (same on every OS, Windows users inside WSL)
python3 file.py # execute a program -> the OS creates a process
ps aux # list every live process the OS manages