The Scheduler and GOMAXPROCS
The runtime multiplexes many goroutines onto a few OS threads; GOMAXPROCS caps how many run Go code at once.
runtime.GOMAXPROCS(n int) intConcurrency 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
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.
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.
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.
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