The Intuitive Way to Understand Thread and Process
The usual explanations of processes and threads are technically correct, but I never found them particularly intuitive.
You often see definitions like:
A process is a program that is currently being executed within the OS.
and
A thread is the smallest unit of execution within a process.
These definitions tell us what a process and a thread are, but they do not really give us a mental picture of what is happening inside the computer.
So in this blog, I want to look at processes and threads from a slightly different angle and build a more intuitive mental model for understanding them.
Throughout this explanation, we will consider our machine to have a single CPU core. The same concept applies to a multicore system, with one important difference. With multiple cores, several threads can run at the exact same instant. That is real parallelism, and it is out of reach on a single core, where threads can only take turns.
My mental model
For me, a process is a project.
A project has some work that needs to be completed. The OS is the manager who manages these projects, and the CPU core is the worker who actually does the work.
Now the project breaks down into pieces of work that need to be done. Each unit of that work is a thread. Threads do not wait their turn one after another. Each one is a unit of execution, and several can be in progress alongside each other. The OS decides when a thread gets to run, and the CPU core is what actually executes it.
So you can roughly imagine it like this:
OS = Manager
Project 1 = VS Code
Project 2 = VLC
CPU Core = Worker
Project 1
└── Thread(s) → work that needs to be executed
Project 2
└── Thread(s) → work that needs to be executedThe important thing here is that the thread is what actually gets executed by the CPU.
A process is more like the environment that contains the application and its resources. A process can have one or multiple threads.
Now let us take a real example
Suppose I have VS Code open in the background and I am also watching a movie using VLC media player.
Since these are two separate applications, we can think of them as two separate processes.
VS Code itself does more than one thing: the UI thread keeps the editor responsive, while the ext-host thread runs extensions. VLC is the same: one thread decodes video, another plays audio.
OS
│
├── VS Code process
│ ├── Thread ui
│ └── Thread ext-host
│
└── VLC process
├── Thread decoder
└── Thread audioEach process has its own process ID and its own memory space. VS Code threads share VS Code memory, VLC threads share VLC memory, but VS Code memory never touches VLC memory.
Now remember, our machine has only one CPU core.
So how can VS Code keep running while VLC is playing the movie?
The CPU cannot literally execute both threads at exactly the same time on a single core.
Instead, the OS scheduler gives the CPU a small amount of work from one thread, then switches to another thread.
Something like:
CPU Core
VS Code ui thread
↓
VLC decoder thread
↓
VS Code ext-host thread
↓
VLC audio thread
↓
VS Code ui thread
↓
...This switching happens very quickly, so from our point of view it looks like both applications are running at the same time.
This is where context switching comes in.
Before switching away from a thread, the OS can save the current state of that thread. Later, when the thread gets CPU time again, the OS restores that state and continues its execution.
So the CPU is basically doing:
work on VS Code → save state → work on VLC → save state → work on VS Code → repeat
This is how a single CPU core can keep multiple tasks progressing concurrently.
But where does the process fit into all this?
This is the part that helped me understand the difference.
A process is not the thing that the CPU directly executes.
The thread is the unit that gets scheduled and executed by the CPU.
The process provides the environment in which those threads run.
For example:
VS Code Process
│
├── Code ┐
├── Data │ shared by all threads
├── Heap ┘
│
├── Thread ui
│ ├── own stack
│ └── own state
├── Thread ext-host
│ ├── own stack
│ └── own state
│ ↓
CPU executes the threadAnd VLC media player has its own process and its own threads. It has the exact same shape as the VS Code process. The only thing that changes is what the threads are doing.
VLC Process
│
├── Code ┐
├── Data │ shared by all threads
├── Heap ┘
│
├── Thread decoder
├── Thread audioThe code, the data and the heap belong to the process and are shared by every thread. The heap is where memory you allocate at runtime lives, so one thread can allocate a buffer and another thread can use it without any copying. What each thread keeps private is its own stack and its own execution state. Because that shared memory can be reached by all of them, two threads can read and write the same data at the same time. Unless you coordinate them, that leads to data races, which is why shared state usually needs synchronization such as locks.
This sharing is exactly what makes processes different. Each process lives in its own protected memory space, so one process crashing usually does not take another one down. Inside a single process the threads are not isolated like that. They share the same memory, so one bad thread can corrupt data another thread is using, or bring the whole process down with it. That trade off, easy sharing against safety, is the real difference between threads and processes.
Making it concrete with Rust
I wrote a small Round-Robin simulation to make this real: 2 processes (VS Code and VLC media player) with isolated memory, 2 threads each sharing their parent memory, and a scheduler with quantum 2 that preempts and re-queues.
First, a thread needs a lifecycle state. Ready means waiting in the queue. Done means its burst hit zero and it will never be scheduled again. A real OS also has Blocked for threads waiting on network or disk, like fetch or recv. Blocked threads leave the Ready queue until IO completes, so CPU is not wasted.
use std::collections::VecDeque;
#[derive(Debug, PartialEq)]
enum State { Ready, Done }
#[derive(Debug)]
pub struct Thread {
tid: u32,
name: String,
burst_remaining: u32,
state: State
}
impl Thread {
pub fn new(tid: u32, name: String, burst: u32) -> Self {
Self {
tid,
name,
burst_remaining: burst,
state: State::Ready
}
}
}The burst field is a simulation-only cheat. A real OS does not know in advance how much CPU a thread needs. It just runs the thread until it exits, blocks, or the quantum expires. Here we need the countdown to know when we are Done and to demo preemption. So vscode ui with 5 means it needs 5 ticks total.
Next is one Round-Robin turn. Quantum is the max time the OS lets one thread run before forcing it to stop, here Q equals 2. We take the minimum of Q and remaining, so if you need 1 but Q is 2, you take only 1. A large Q means almost no preemption and it becomes FIFO. A small Q is fair but causes many context switches.
The parent process memory is passed in instead of stored, to avoid a self-referential struct. A Process cannot own memory and also lend out a mutable reference to it from its own Thread field.
impl Thread {
pub fn tick(&mut self, quantum: u32, proc_mem: &mut Vec<i32>) -> u32 {
let run = quantum.min(self.burst_remaining);
self.burst_remaining -= run;
let len = proc_mem.len();
let idx = (self.tid as usize) % len;
proc_mem[idx] += run as i32;
if self.burst_remaining == 0 {
self.state = State::Done;
}
run
}
}That memory write is fake work to prove threads share process memory. VS Code ui with tid 0 and ext-host with tid 1 touch the same vscode memory vec. VLC threads touch a different vlc memory vec, so processes stay isolated. The len split into two lines works around E0502, where you cannot immutably borrow len while mutably borrowing an index in the same expression. If the countdown hits zero the thread is Done and the scheduler will not re-queue it. Otherwise it was preempted: it still needs CPU but Q expired, so it goes to the back of the queue.
A process then owns isolated memory plus many threads. The old single-thread version used Option, this one holds a Vec. VS Code memory never touches VLC memory. Threads inside one process share it by receiving a mutable reference in tick.
#[derive(Debug)]
pub struct Process {
pid: u32,
name: String,
memory: Vec<i32>,
threads: Vec<Thread>,
}
impl Process {
fn new(pid: u32, name: String) -> Self {
Self {
pid,
name,
memory: vec![0; 8],
threads: vec![]
}
}
fn add_thread(&mut self, t: Thread) {
self.threads.push(t);
}
}The scheduler holds the time slice, a global simulated clock advanced by the run value returned from tick, and a Ready queue of pid and tid pairs. Blocked threads would not be here. Q equals 2 in main, which gives fair interleaving of vscode and vlc.
pub struct Scheduler {
quantum: u32,
time: u32,
queue: VecDeque<(u32, u32)>
}
impl Scheduler {
fn new(quantum: u32) -> Self {
Self {
quantum,
time: 0,
queue: VecDeque::new()
}
}
}The main loop pops from the front, runs one tick, and pushes back if not Done. A real CPU runs one thread at a time and rapid switching fakes parallelism. If the thread is Done its burst is zero and it will never run again. If preempted the timer forced us to stop, so we put it at the back and the next process gets a turn, for example vlc after vscode. This is different from Blocked on a network call: that thread would leave the queue entirely while the NIC and kernel buffer packets in the background via DMA and interrupts, append to the socket recv buffer, then move the thread from Blocked back to Ready.
impl Scheduler {
fn run(&mut self, procs: &mut Vec<Process>) {
for p in procs.iter() {
for t in p.threads.iter() {
self.queue.push_back((p.pid, t.tid));
}
}
while let Some((pid, tid)) = self.queue.pop_front() {
let proc = procs.iter_mut().find(|p| p.pid == pid).unwrap();
let th = proc.threads.iter_mut().find(|t| t.tid == tid).unwrap();
if th.state == State::Done {
continue;
}
println!("[th = {}] RUN {}:{}", self.time, proc.name, th.name);
let run = th.tick(self.quantum, &mut proc.memory);
self.time += run;
if th.state == State::Done {
println!(" -> Done {}:{} at t = {}", proc.name, th.name, self.time);
} else {
println!(" -> PREEMPTED remaining={}", th.burst_remaining);
self.queue.push_back((pid, tid));
}
}
println!("all done at t = {}", self.time);
for p in procs.iter() {
println!("{} mem: {:?}", p.name, p.memory);
}
}
}Finally the demo setup. Two processes with isolated memory and two threads each with shared memory. VS Code pid 1 has ui needing 5 ticks and ext-host needing 8 ticks. VLC pid 2 has decoder needing 6 ticks and audio needing 4 ticks.
fn main() {
let mut vscode = Process::new(1, "vscode".to_string());
vscode.add_thread(Thread::new(0, "ui".to_string(), 5));
vscode.add_thread(Thread::new(1, "ext-host".to_string(), 8));
let mut vlc = Process::new(2, "vlc".to_string());
vlc.add_thread(Thread::new(0, "decoder".to_string(), 6));
vlc.add_thread(Thread::new(1, "audio".to_string(), 4));
let mut sched = Scheduler::new(2);
sched.run(&mut vec![vscode, vlc]);
}What this shows: the queue holds pid and tid pairs, so vscode-ui, vscode-ext-host, vlc-decoder, and vlc-audio take turns with Q equals 2. Threads in the same process mutate the same memory vec, while vscode memory and vlc memory stay separate. When burst hits 0 the thread goes Done and is never re-queued, otherwise it is preempted to the back.
So the simplest mental model I have now is:
Process = the project and its environment
Thread = the unit of work that gets scheduled
CPU core = the worker that actually executes a thread
OS = the manager deciding which thread gets CPU time
And on a single core, the OS keeps switching between threads so that multiple processes can make progress concurrently.
This mental model makes it much easier for me to understand why processes and threads exist and, more importantly, how they actually work together when a computer is running multiple applications at the same time.