Go achieves high concurrency through its scheduler, which manages millions of goroutines on a small number of OS threads. Here’s how it works.

Overview of Go’s scheduler model
Go’s concurrency model lets you run millions of goroutines with just a few OS threads. This post explains how the scheduler multiplexes goroutines onto threads, how it handles blocking and non-blocking I/O, and how the G-M-P model enables efficient concurrency.
Go uses goroutines, not threads. Important differences:
Threads (traditional):
- Managed by OS
- Heavy (~1–2MB stack each)
- Limited (typically 100s-1000s)
- Context switching is expensive (OS kernel involved)
Goroutines:
- Managed by Go runtime
- Lightweight (~2KB stack initially)
- Can spawn millions
- Context switching is cheap (user-space)
GOMAXPROCS
OS threads are limited by GOMAXPROCS.
// Default: GOMAXPROCS = number of CPU cores
// You can set it:
runtime.GOMAXPROCS(4) // Use 4 OS threads
Example on an 8-core machine:
- 8 OS threads
- Can handle thousands of concurrent goroutines
- Go scheduler multiplexes goroutines onto threads
How the OS thread handles multiple goroutines
The OS thread doesn’t run all goroutines (let’s say Gn in the diagram below) at the same time. The Go scheduler switches between them.

Overview of Go concurrency model
Let’s zoom-in here and try to get a full clear picture how the Go runtime scheduler actually juggles between goroutines on a single OS thread.
There can be two types of operation being performed by goroutine (Gn):
- Non-blocking (HTTP/network)
- Blocking (disk I/O)
Non-blocking Task
Let’s say the goroutine (G1) waiting to be scheduled needs to perform a HTTP call over the internet, here’s how the scheduler will work:
- The goroutine (G1) initiates the HTTP call
- It yields control to the scheduler
- The scheduler switches to another goroutine (G2)
- The OS thread continues with other work
Visual Timeline:
Code-level view:
// G1's code
func handler1() {
// G1 is running on OS Thread 1
resp, err := http.Get("https://api1.com")
// ↑ At this point:
// 1. Creates socket
// 2. Sends request
// 3. Registers with poller
// 4. Yields to scheduler (gopark)
// 5. G1 marked as "waiting"
// 6. Scheduler switches to G2
// Later, when response arrives:
// 1. Network poller detects it
// 2. Scheduler marks G1 as "runnable"
// 3. Scheduler switches back to G1
// 4. G1 continues here with response
fmt.Println(resp.StatusCode)
}
// G2's code
func handler2() {
// G2 runs when G1 yields
// Could do anything: CPU work, another HTTP call, etc.
}
// G3's code
func handler3() {
// G3 runs when G2 yields or completes
}
Blocking Task
Let’s say the goroutine (G2) waiting to be scheduled needs to perform a disk I/O like a write operation, here’s how the scheduler will work:
- The goroutine (G2) writes some heavy data to disk (say in GBs)
- The thread is blocked in the kernel
- Scheduler cannot preempt it (it’s in kernel)
- Go creates a new OS thread for other goroutines
Visual Timeline:
Code-level view:
// When you call os.Open()
func Open(name string) (*File, error) {
// 1. Enter syscall
runtime.entersyscall()
defer runtime.exitsyscall()
// 2. Make actual syscall
fd, err := syscall.Open(name, ...)
// 3. If blocking detected, scheduler
// already created new thread
return newFile(fd, name), err
}
The three components: G, M, P
Go’s scheduler uses three main components:
G = Goroutine (your code)
M = Machine (OS thread)
P = Processor (execution context)
What is a Processor (P)?
A Processor (P) is an execution context that:
- Holds a local run queue of goroutines
- Binds to an OS thread (M) to execute goroutines
- Manages resources for running goroutines
- Acts as a bridge between goroutines and threads

Overview of Processor (P) in Go
How Processor (P) manages OS threads?
Consider two Goroutines; G1 and G2, both waiting in queue of P1 (Processor). P1 is binded to OS thread 1 which is running on Core 1. Now:
- G1 wants to performs a network call (non-blocking)
- G2 needs to open a file from disk (blocking)
Here’s how Go scheduler will manage:
- Runs G1 from P1 queue on OS thread 1
- Goes to Network poller (epoll/kqueue), G1 waiting, OS thread 1 is free
- Scheduler runs G2 from P1 queue on OS thread 1
- G2 performs syscall() and is blocked in kernal
- Scheduler detects OS thread 1 is blocked
- Scheduler creates new OS thread, call runtime.newosproc()
- Scheduler detaches P1 from OS thread 1 and binds to OS thread 2
- Now Go Scheduler runs G3…Gn from P1 on OS thread 2
Example on a logical 4-core machine:
No of Processor = GOMAXPROCS = No of logical CPU cores.
So, on 4 core machine, it will have P1…P4, where each P is binded to OS thread which is equal to GOMAXPROCS = 4 (CPU cores).






Top comments (0)