DEV Community

Cover image for 4 Lines of Go Taught Me More About CPU, Threads and Memory Than Hours of Theory
Chinmay Pandya
Chinmay Pandya

Posted on

4 Lines of Go Taught Me More About CPU, Threads and Memory Than Hours of Theory

I was looking at Activity Monitor while running this Go program:

package main

func main() {
    for {
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Image shows CPU utilization and memory allocation and analysis for the process

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 {
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

Those decisions are handled underneath our code.

Image shows thread, port and context switching metrics for the process

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Go runtime provides this machinery.

A simplified view:

┌─────────────────────┐
│      Go Runtime     │
├─────────────────────┤
│ Scheduler           │
│ Goroutine management│
│ Memory management   │
│ Garbage collection  │
│ Thread management   │
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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       │
└──────────────────┘
Enter fullscreen mode Exit fullscreen mode

The operating system keeps currently needed parts in physical memory.

So:

Source code size ≠ Process memory
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ──┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The OS and CPU translate virtual addresses to physical memory.

A simple analogy

A 10-digit phone-number system has:

0000000000 → 9999999999
Enter fullscreen mode Exit fullscreen mode

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            │
└───────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The runtime and OS provide the machinery that our source code doesn't explicitly manage.

For memory:

Virtual address
      │
      ▼
   CPU / MMU
      │
      ▼
Physical memory
      │
      ▼
     RAM
Enter fullscreen mode Exit fullscreen mode

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)