DEV Community

Pavel Sanikovich
Pavel Sanikovich

Posted on

Go From Zero to Depth — Part 6: The Goroutine Scheduler (The Part Nobody Tells Beginners)

At some point every Go developer asks the same question: “How does Go actually run my goroutines?”
Beginners are often told not to worry about it. “The runtime handles it.” “It just works.” “Goroutines are cheap.” All of that is true — and completely insufficient once your programs grow beyond toy examples.

To write reliable concurrent Go code, you don’t need to know everything about the scheduler. But you do need a mental model. Without it, performance issues feel random, deadlocks feel mysterious, and race conditions feel unfair. With it, many problems become predictable.

Let’s build that model.

When you write this:

go work()
Enter fullscreen mode Exit fullscreen mode

you are not creating a thread. You are creating a goroutine, which is a lightweight task managed entirely by the Go runtime. Goroutines live in user space, not in the operating system. They are scheduled by Go, not by the OS.

This distinction matters.

The Go scheduler is based on a simple but powerful idea, often called the M–P–G model. You don’t need to memorize the letters, but you need to understand what they represent.

A G is a goroutine. It contains the function to execute, its stack, and some metadata.
An M is an OS thread. It executes machine instructions.
A P is a processor — a logical resource that connects goroutines to threads.

A goroutine does not run unless it is assigned to a P, and a P does not run unless it is attached to an M. This indirection is what gives Go its flexibility.

The number of Ps is controlled by GOMAXPROCS. By default, it equals the number of CPU cores.

fmt.Println(runtime.GOMAXPROCS(0))
Enter fullscreen mode Exit fullscreen mode

If GOMAXPROCS is 4, Go can run up to four goroutines in parallel. You can create a million goroutines, but only four will execute simultaneously. The rest are waiting in queues.

This already explains something many beginners find confusing: creating many goroutines does not mean doing many things at once.

Consider this code:

for i := 0; i < 5; i++ {
    go func(id int) {
        fmt.Println("goroutine", id)
    }(i)
}
Enter fullscreen mode Exit fullscreen mode

The order of output is unpredictable. This is not because Go is random, but because scheduling is opportunistic. Goroutines are placed in local run queues. Ps steal work from each other when idle. The runtime tries to be fair, but it does not guarantee order.

Now let’s introduce blocking.

go func() {
    time.Sleep(time.Second)
    fmt.Println("done")
}()
Enter fullscreen mode Exit fullscreen mode

While this goroutine sleeps, it does not block the entire program. The runtime parks the goroutine and frees the P to run something else. This is why goroutines are cheap. Blocking calls like time.Sleep, channel operations, and network I/O cooperate with the scheduler.

But not all blocking is equal.

Consider this:

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

This goroutine never blocks. It spins forever. On a single-core system, this can starve other goroutines. The scheduler will eventually preempt it, but preemption is not instantaneous. This is why CPU-bound loops should yield naturally or be designed carefully.

Now consider system calls.

go func() {
    data, _ := os.ReadFile("large_file.txt")
    fmt.Println(len(data))
}()
Enter fullscreen mode Exit fullscreen mode

If a goroutine enters a blocking syscall, the runtime detaches the M from the P and creates or reuses another M so that other goroutines can continue running. This is expensive compared to normal scheduling, but still far cheaper than managing threads manually.

This explains why Go performs well for I/O-heavy workloads: blocking I/O does not block the scheduler.

Now let’s look at a classic beginner pitfall involving loops and goroutines:

for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)
    }()
}
Enter fullscreen mode Exit fullscreen mode

This often prints 3 3 3. Not because the scheduler is broken, but because all goroutines capture the same variable, and execution happens later. The scheduler delays execution long enough for the loop to finish.

The fix is not about timing. It’s about ownership:

for i := 0; i < 3; i++ {
    go func(v int) {
        fmt.Println(v)
    }(i)
}
Enter fullscreen mode Exit fullscreen mode

Here each goroutine owns its own copy. Scheduling no longer changes correctness.

Let’s talk about fairness.

The scheduler uses work stealing. Each P has a local run queue. When it runs out of work, it steals from others. This improves cache locality and reduces contention. But it also means goroutines may not run in the order you expect.

This matters when beginners assume round-robin execution:

go printA()
go printB()
Enter fullscreen mode Exit fullscreen mode

There is no guarantee that printA and printB will interleave evenly. One might run to completion before the other even starts.

Now combine this with channels:

ch := make(chan int)

go func() {
    ch <- 1
}()

go func() {
    ch <- 2
}()

fmt.Println(<-ch)
Enter fullscreen mode Exit fullscreen mode

Which value prints first? You don’t know. The scheduler decides which goroutine runs first, and channels only guarantee synchronization — not fairness.

Understanding this prevents subtle bugs.

One more example that surprises beginners:

runtime.GOMAXPROCS(1)

go func() {
    for {
        fmt.Println("A")
    }
}()

go func() {
    for {
        fmt.Println("B")
    }
}()
Enter fullscreen mode Exit fullscreen mode

Even with one OS thread, both goroutines will run. Go uses cooperative scheduling with preemption. But the output will be uneven. One goroutine may dominate for a while before being interrupted.

This is why relying on scheduling behavior for correctness is a mistake. Correct concurrent programs work under any scheduling order.

The Go scheduler is not something to fight. It is something to design for. When your goroutines have clear lifetimes, when blocking points are intentional, when shared state is explicit, the scheduler becomes invisible — and that’s the goal.

In the next part, we’ll zoom in on channels themselves. We’ll explain why channels are not queues, why buffering changes semantics, and how channel patterns shape program structure. This is where many concurrency designs either become elegant — or collapse under their own complexity.

By now, you should feel it: Go’s simplicity is not hiding chaos. It’s hiding a system that rewards clarity. The scheduler is not your enemy. It’s your silent partner.


Want to go further?

This series focuses on understanding Go, not just using it.

If you want to continue in the same mindset, Educative is a great next step.

It’s a single subscription that gives you access to hundreds of in-depth, text-based courses — from Go internals and concurrency to system design and distributed systems. No videos, no per-course purchases, just structured learning you can move through at your own pace.

👉 Explore the full Educative library here

Top comments (0)