I was looking at Activity Monitor while running this Go program:
package main
func main() {
for {
}
}
There is almost nothing here.
Yet Activity Monitor showed:
| Metric | Value |
|---|---|
| CPU | 100% |
| Threads | 5 |
| Context switches | 22K |
| Real memory | 3.2 MB |
| Shared memory | 700 KB |
| Virtual memory | ~400 GB |
That made me ask:
If my code is almost empty, why does the operating system have so much going on?
1. Why is CPU at 100%?
The answer is the loop:
for {
}
There is no sleep, wait, I/O, or other blocking operation.
The CPU finishes one iteration and immediately starts another.
┌───────────┐
│ for { } │
└─────┬─────┘
│
▼
Execute again
│
└──────────┐
▼
Execute again
The goroutine is always runnable, so the CPU always has work to execute.
That is why the process can consume approximately 100% of one logical CPU.
The important part
Our code never says:
"Give me a CPU."
"Create a thread."
"Run me on CPU core 2."
Those decisions are handled underneath our code.
2. Why do we need a Go runtime?
Our code describes the work.
It does not implement all the machinery required to keep that work running.
For example, if we write:
go doSomething()
we don't manually write code to:
Create an OS thread
Find a CPU
Schedule the work
Manage its stack
Handle blocking
Manage memory
Run garbage collection
The Go runtime provides this machinery.
A simplified view:
┌─────────────────────┐
│ Go Runtime │
├─────────────────────┤
│ Scheduler │
│ Goroutine management│
│ Memory management │
│ Garbage collection │
│ Thread management │
└─────────────────────┘
So the running program contains more than the code we wrote.
3. Why do we need a scheduler?
Our code says what work exists.
It doesn't decide when or where that work runs.
Go programs can have thousands of goroutines, while a machine has a much smaller number of CPU cores.
The Go scheduler manages that mismatch:
Many goroutines
│
▼
┌───────────────┐
│ Go Scheduler │
└───────┬───────┘
│
▼
Fewer OS threads
The scheduler decides which runnable goroutine should execute on an available OS thread.
This is why we can write concurrent Go code without manually creating and managing an OS thread for every piece of work.
4. Why are there 5 threads if I never created them?
Activity Monitor showed:
Threads: 5
But our code contains no thread creation.
That's because goroutines are not OS threads. Our main() code runs as the main goroutine and it is not a 1:1 mapping with OS threads.
Goroutine
│
▼
Go Runtime
│
▼
OS Thread
We create and manage goroutines at the Go level.
The Go runtime manages OS threads underneath them.
The OS then manages those threads.
So the 5 threads are part of the machinery used to run the process; they weren't five threads explicitly created in our source code.
5. Why do we need an OS scheduler?
The Go scheduler is not the final scheduler.
The machine may have threads from many different processes:
Chrome
VS Code
Go
Terminal
System processes
│
▼
┌───────────────┐
│ OS Scheduler │
└───────┬───────┘
│
▼
CPU
The OS scheduler decides which OS thread gets to execute and when.
This lets many threads share a limited number of CPU cores.
Our Go code does not need to know which CPU core is currently executing it.
6. Why are there context switches?
If several threads need the CPU, the OS has to switch between them.
Thread A
│
▼
Save state
│
▼
Thread B
│
▼
Save state
│
▼
Thread C
A context switch is the transition from one execution context to another.
The OS saves enough state to later resume the previous thread, such as CPU registers and its instruction location.
Activity Monitor showed:
Context switches: 22K
So the process was involved in many such switches during the measurement period.
Context switches are normal; excessive switching can become a performance cost.
7. Why does an almost-empty program use 3.2 MB of RAM?
The source code is tiny.
The running process is not.
A Go process also contains things such as:
┌──────────────────┐
│ Go Process │
├──────────────────┤
│ Your code │
│ Go runtime │
│ Goroutine stacks │
│ Heap │
│ Libraries │
│ Other data │
└──────────────────┘
The operating system keeps currently needed parts in physical memory.
So:
Source code size ≠ Process memory
The 3.2 MB real memory is the physical memory currently resident for the process.
8. Why is some memory shared?
Activity Monitor showed:
Shared memory: 700 KB
Processes can use the same physical memory pages.
For example, multiple processes may use pages belonging to the same shared library:
Process A ──┐
Process B ──┼──► Shared pages
Process C ──┘
The same physical pages can therefore be mapped into multiple processes.
The 700 KB does not mean our program explicitly created a 700 KB shared-memory buffer.
It is memory classified as shared for the process.
9. Why is virtual memory ~400 GB?
This was the strangest number:
Virtual memory ≈ 400 GB
My laptop obviously doesn't have 400 GB of RAM.
That's because virtual memory is not physical RAM.
Programs work with virtual addresses:
Program
│
▼
Virtual address
│
▼
CPU / MMU
│
▼
Physical memory
The OS and CPU translate virtual addresses to physical memory.
A simple analogy
A 10-digit phone-number system has:
0000000000 → 9999999999
That's a huge range of possible numbers.
It doesn't mean that every number has an active SIM card.
Virtual address space works similarly:
┌───────────────────────────┐
│ Virtual address │
│ space │
├───────────────────────────┤
│ Mapped regions │
│ Reserved regions │
│ Unused regions │
└───────────────────────────┘
A process can therefore have a large virtual-memory footprint without using the same amount of physical RAM.
So:
~400 GB virtual memory
≠
~400 GB RAM
The real memory number is the one describing currently resident physical memory.
The mental model
The whole experiment can be reduced to two flows:
Code
│
▼
Go Runtime
│
▼
OS
│
▼
CPU
The runtime and OS provide the machinery that our source code doesn't explicitly manage.
For memory:
Virtual address
│
▼
CPU / MMU
│
▼
Physical memory
│
▼
RAM
And that explains why four lines of Go can produce all those Activity Monitor numbers:
| What I saw | Why |
|---|---|
| 100% CPU | The loop is continuously runnable |
| 5 threads | The runtime and OS manage execution using OS threads |
| 22K context switches | Threads are switched during execution |
| 3.2 MB real memory | The running process needs resident physical memory |
| 700 KB shared memory | Some physical pages can be shared |
| ~400 GB virtual memory | Virtual-memory accounting is separate from physical RAM |
A tiny program doesn't mean a tiny execution environment.
The code can be four lines while the machinery underneath it involves runtime, goroutines, threads, scheduling, virtual memory and physical memory.


Top comments (0)