The Scheduler and GOMAXPROCS

The runtime multiplexes many goroutines onto a few OS threads; GOMAXPROCS caps how many run Go code at once.

Syntaxruntime.GOMAXPROCS(n int) int

Concurrency is structuring a program as independent pieces that can make progress in any order. Parallelism is two pieces literally executing at the same instant on two processors. A single-core machine can run thousands of goroutines concurrently and nothing in parallel. The two are different, and goroutines give you the first whether or not you get the second.

What the runtime does

The Go scheduler, part of the runtime inside your binary, keeps a small pool of operating-system threads and runs goroutines on them. GOMAXPROCS is the limit on how many threads may run Go code at once. Its default is the number of processors available to the process. Since Go 1.25, on Linux that means the container's CPU limit when one is set, and the runtime adjusts it if the limit changes; setting the GOMAXPROCS environment variable or calling runtime.GOMAXPROCS with a positive number turns that adjustment off.

Blocking

When a goroutine blocks on a channel, a mutex or network I/O, the scheduler parks it and runs another goroutine on the same thread. No operating-system thread is wasted. A blocking system call, such as reading a local file, does block its thread, so the runtime hands that thread's slot to another one and creates threads as needed. This is why thread count can exceed GOMAXPROCS.

What trips people up

Raising GOMAXPROCS above the CPUs you actually have does not add throughput; it adds switching.

Example

Example · go
package main

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

func main() {
	// Allow at most two threads to run Go code at once. GOMAXPROCS
	// returns the previous setting, which we do not need here.
	runtime.GOMAXPROCS(2)

	// Eight goroutines, two running at any instant: concurrency of
	// eight, parallelism of two. All eight still finish.
	results := make([]int, 8)
	var wg sync.WaitGroup
	for i := range results {
		wg.Go(func() {
			sum := 0
			for j := 0; j <= i; j++ {
				sum += j
			}
			results[i] = sum
		})
	}
	wg.Wait()

	// Passing 0 reports the current setting without changing it.
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
	fmt.Println("results:", results)
}

// Output:
// GOMAXPROCS: 2
// results: [0 1 3 6 10 15 21 28]

When to use it

  • Explain why a service is no faster after you doubled the number of worker goroutines.
  • Set the worker count of a CPU-bound image pipeline from the container's real CPU limit.
  • Diagnose a pod that saturates its CPU quota and gets throttled after a GOMAXPROCS change.

More examples

Reading the limit instead of setting it

Log these three values once at start-up in any service you deploy. When a pod is slower than the same binary on a laptop, this is the first thing to check: the numbers differ because the container limit is doing its job. The output depends on the machine, which is why it is not promised here.

Example · go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	// NumCPU is the machine's usable processor count, fixed at start-up.
	// GOMAXPROCS(0) is what this program will actually use, which in a
	// container with a CPU limit is usually the smaller number.
	fmt.Println("cpus:", runtime.NumCPU())
	fmt.Println("gomaxprocs:", runtime.GOMAXPROCS(0))
	fmt.Println("goroutines:", runtime.NumGoroutine())
}

Sizing a worker pool from GOMAXPROCS

CPU-bound and I/O-bound work want different limits. Image resizing or compression scales with processors, so size the pool from GOMAXPROCS; waiting on a database or an HTTP API does not, and a much larger limit is fine there. The channel here is used purely as a counter of permits.

Example · go
package main

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

func compress(block int) int {
	return block * block // stands in for CPU-heavy work
}

func main() {
	blocks := []int{1, 2, 3, 4, 5, 6}
	out := make([]int, len(blocks))

	// For CPU-bound work, more goroutines than processors buys nothing.
	// A semaphore channel caps how many run at once.
	limit := make(chan struct{}, runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup
	for i, b := range blocks {
		wg.Go(func() {
			limit <- struct{}{}
			defer func() { <-limit }()
			out[i] = compress(b)
		})
	}
	wg.Wait()
	fmt.Println(out)
}

Yielding inside a tight loop

Before Go 1.14 a goroutine could only be switched out at certain points, so a loop with no function calls could starve every other goroutine on that thread. Asynchronous preemption fixed that. Keep runtime.Gosched in mind for reading old code rather than for writing new code.

Example · go
package main

import (
	"fmt"
	"runtime"
)

func main() {
	runtime.GOMAXPROCS(1)

	done := make(chan int)
	go func() {
		total := 0
		for i := 0; i < 1000; i++ {
			total += i
			// Gosched hands the processor to another goroutine and
			// resumes later. Since Go 1.14 the runtime can also
			// interrupt a loop like this on its own, so this call is
			// almost never needed.
			if i%100 == 0 {
				runtime.Gosched()
			}
		}
		done <- total
	}()

	fmt.Println(<-done)
}

// Output:
// 499500

Discussion

  • Be the first to comment on this lesson.