Skip to Content
Course content

204: Goroutine Scheduling and GOMAXPROCS

Click on the "Edit" button in the top corner of the screen to edit your slide content.

One of the most common misconceptions I run into when developers move to Go is the idea that a goroutine is just a "lightweight thread." While that's a helpful shorthand, it's technically misleading. If you treat them like OS threads, you'll likely end up fighting the runtime instead of letting it work for you. The real magic is the Go scheduler, and the knob you have to control it is GOMAXPROCS.

The "More Threads, More Speed" Fallacy

Imagine you're writing a tool to calculate SHA-256 hashes for a million large files. You know this is a CPU-bound task. The naive approach is to think: "I have a million files, so I'll spawn a million goroutines, and then I'll set runtime.GOMAXPROCS(1000) to make sure the OS gives me as much power as possible."

// The naive, "brute force" approach
func naiveHash() {
    runtime.GOMAXPROCS(1000) // Trying to force more parallelism
    for i := 0; i < 1000000; i++ {
        go func(id int) {
            computeHeavyHash(id)
        }(i)
    }
}

On the surface, this looks like it's maximizing concurrency. But in reality, you've just created a scheduling nightmare. GOMAXPROCS doesn't control how many goroutines can exist; it controls how many OS threads can execute Go code simultaneously. When you set this number far beyond your actual CPU core count, you aren't adding more computing power—you're just forcing the operating system to perform constant context switches.

I've seen this crash production services. The OS spends more time swapping thread contexts in and out of the CPU registers than it does actually calculating your hashes. You'll see your CPU usage hit 100%, but your actual throughput will plummet. It's like trying to speed up a kitchen by hiring 1,000 chefs for a kitchen that only has four burners; they'll just spend all their time bumping into each other.

Working with the M:N Scheduler

Go uses what we call an M:N scheduler. It maps M goroutines onto N OS threads. By default, N (the GOMAXPROCS value) is set to the number of logical CPUs available on your machine. For the vast majority of your projects, you should leave it exactly where it is. Go is incredibly good at multiplexing thousands of goroutines onto a handful of threads.

The better way to handle that hashing problem isn't to tweak the scheduler, but to manage your work distribution. Instead of a million goroutines fighting for a few cores, you use a worker pool or a semaphore to limit the number of active CPU-heavy tasks to something close to your core count.

// The reasoned approach
func betterHash() {
    // We leave GOMAXPROCS at the default (number of cores)
    jobs := make(chan int, 100)
    var wg sync.WaitGroup

    // Spawn only as many workers as we have CPU cores
    for w := 0; w < runtime.NumCPU(); w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs {
                computeHeavyHash(j)
            }
        }()
    }

    for i := 0; i < 1000000; i++ {
        jobs <- i
    }
    close(jobs)
    wg.Wait()
}

In this version, we aren't fighting the runtime. We're aligning our application's concurrency with the hardware's parallelism. The Go scheduler can now keep those few OS threads fully saturated with work without the overhead of constant switching.

When the Scheduler Actually Struggles

Now, you might be wondering: if the scheduler is so smart, why does GOMAXPROCS even exist? There are edge cases where the default isn't ideal. For instance, if you're running your Go binary in a container (like Docker or Kubernetes) with CPU limits (e.g., 2 cores) on a machine that has 64 cores, Go might see all 64 cores and set GOMAXPROCS to 64.

This leads to "CPU throttling." The Go runtime thinks it has 64 slots to fill, but the CFS quota in Linux will step in and pause your entire process once you hit your 2-core limit. This creates weird latency spikes that are incredibly hard to debug. In those specific containerized environments, manually setting GOMAXPROCS to match your container's limit—or using a library like uber-go/automaxprocs—is the professional move.




📋 Practical Task

Exercise: Fixing the CPU-Bound Throttler

You've inherited a piece of code that performs a heavy mathematical simulation. The previous developer tried to "optimize" it by spawning a goroutine for every single data point and manually bumping GOMAXPROCS to a massive number, causing the application to stutter and lag due to context switching.

Your task: Modify the provided code to remove the manual GOMAXPROCS override and implement a worker pool that limits the number of concurrent simulations to the number of available CPU cores (using runtime.NumCPU()). Ensure that the program still processes all data points and waits for all workers to finish before exiting.

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

// Simulate a CPU-heavy task
func heavySimulation(id int) {
	sum := 0
	for i := 0; i < 10000000; i++ {
		sum += i
	}
}

func main() {
	// TODO: Remove the naive GOMAXPROCS override
	runtime.GOMAXPROCS(1000)

	dataPoints := 5000
	var wg sync.WaitGroup

	// TODO: Replace this naive loop with a worker pool 
	// that matches the number of CPU cores.
	for i := 0; i < dataPoints; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			heavySimulation(id)
		}(i)
	}

	wg.Wait()
	fmt.Println("Simulation complete.")
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.