DEV Community

Cover image for Processes vs Threads: What's Actually Different?
Aditya Sharma
Aditya Sharma

Posted on

Processes vs Threads: What's Actually Different?

Introduction

Type python app.py and press enter.

The Python interpreter starts, your code begins executing, and from your perspective, the program is running. But from the operating system's perspective, something more specific just happened. The OS didn't just start "a program." It created a process: a carefully constructed execution environment with its own identity, its own memory, and its own bookkeeping.

Now suppose that program creates a thread. What changes?

Most explanations reach for a glossary. True, and mostly useless. Let's start from the OS's perspective instead.

--

1. What a Process Actually Is

When the OS creates a process, it's constructing an isolated execution environment from scratch.

Every process gets its own virtual address space: a private range of memory addresses that the process believes it owns entirely. Physical RAM is shared, but the OS and CPU hardware coordinate to give each process the illusion of dedicated memory. From inside the process, another process's memory is simply not visible.

Within that address space lives everything the process needs: compiled code, a heap for dynamically allocated objects, one or more stacks, and global data. The OS tracks resources on the process's behalf and assigns a unique process ID (PID).

A program is a static artifact on disk. A process is a running instance with its own private resources and OS-managed state.

--

2. What a Thread Actually Is

A process can contain one or more threads: individual sequences of execution that run within the process's environment.

Each thread has its own execution state: its own program counter tracking which instruction it's executing, its own CPU registers, and its own stack for local variables and call tracking.

What threads don't have is their own address space. Every thread inside a process shares the same virtual memory: the same heap, the same global variables, the same open file descriptors. The process is the container; threads are the execution contexts running inside it.

PROCESS
├── Virtual address space
├── Code, Heap, Global data
├── Open file descriptors and resources
│
├── Thread A  [program counter, registers, stack]
├── Thread B  [program counter, registers, stack]
└── Thread C  [program counter, registers, stack]
Enter fullscreen mode Exit fullscreen mode

Creating a new process means constructing a new address space and all the OS bookkeeping that comes with it. Creating a new thread is typically cheaper because the address-space infrastructure already exists.

--

3. Why Sharing Memory Changes Everything

Because threads share an address space, they can read and write the same data with no special mechanism. But that simplicity has a catch.

Suppose two threads both increment a shared counter:

counter = 0
# Thread A and Thread B both run:
counter += 1
Enter fullscreen mode Exit fullscreen mode

Counter should end up as 2. But it might not. counter += 1 looks like one operation. It involves at least three steps: read the value, add one, write it back. Execution can be interleaved between these steps.

Thread A: reads counter (gets 0)
Thread B: reads counter (gets 0)   <-- before A writes back
Thread A: adds 1, writes 1
Thread B: adds 1, writes 1         <-- overwrites A's result
Enter fullscreen mode Exit fullscreen mode

Counter ends up as 1. This is a race condition: a bug that depends on the timing of concurrent operations, non-deterministic and hard to reproduce.

The fix is a lock (or mutex):

import threading
lock = threading.Lock()

def increment():
    global counter
    with lock:
        counter += 1
Enter fullscreen mode Exit fullscreen mode

Threads communicate easily because they share memory. That same shared memory is what makes concurrent programming hard.

--

4. Why Processes Feel Safer

When two processes run side by side, they typically cannot see each other's memory. If one crashes, the others continue. You get independent failure boundaries.

The trade-off is that inter-process communication requires explicit mechanisms: pipes, sockets, or shared memory regions the OS maps into multiple address spaces. A crash in one worker cannot bring down the others, which is why many server architectures prefer separate processes for independent workloads.

--

5. Context Switching Isn't Free

Your machine might have eight CPU cores and hundreds of runnable threads. The OS scheduler has to take turns. When it pauses Thread A to run Thread B, it performs a context switch: saving A's program counter, registers, and stack pointer, then restoring B's saved state so B continues where it left off.

The cost goes beyond saving registers. Modern CPUs cache recently accessed data. When the scheduler switches threads, the incoming thread's data probably isn't cached yet, so it pays for cache misses until things warm up.

Creating far more runnable threads than CPU cores doesn't create more parallelism. It creates more scheduling overhead.

This is also where concurrency and parallelism diverge. Concurrency is multiple tasks making progress over time, potentially interleaving on a single core. Parallelism is multiple tasks executing simultaneously on different cores. Threads enable both, but only if the hardware and runtime cooperate.

--

6. Why Python Has a GIL

If threads share memory and can run concurrently, why can't a Python program create threads to fully utilize every CPU core?

In traditional CPython builds, there's a constraint called the Global Interpreter Lock, or GIL: a mutex that ensures only one thread executes Python bytecode at a time, even on a sixteen-core machine.

Why? CPython's interpreter relies on shared internal state and reference counting. Protecting all of that safely with fine-grained locking would be complex and expensive. The GIL is a coarser solution: one global lock that serializes Python bytecode execution within an interpreter.

The practical consequence: CPU-bound Python threads won't run faster on a multi-core machine in a traditional build. For I/O-bound work, it's different: a thread blocking on a network call releases the GIL while it waits, so other threads can run.

The GIL is a property of CPython, not of the Python language itself. CPython now supports a free-threaded build without the GIL, though the GIL-enabled build remains the default and ecosystem compatibility is still maturing.

--

Conclusion

When you write threading.Thread(target=fn).start(), the OS allocates a new stack, registers a new execution context with its own program counter and registers, and adds it to the scheduler. That thread shares its process's address space and resources.

When you write subprocess.Popen(cmd), the OS constructs a new virtual address space, assigns a new PID, and starts a completely isolated running instance.

One line of code each. Underneath, the OS is creating two very different execution environments. Understanding that doesn't make the API harder to use. It makes the bugs less mysterious and the design choices more intentional.

Top comments (0)