Recommended resources
The freely available resources this lesson will direct you to.
Hello. In the previous lesson, you varied the number of goroutines in a RunParallel benchmark while holding GOMAXPROCS at 2. This time, you’ll keep a CPU-bound operation fixed and vary GOMAXPROCS itself. The aim is to measure whether more execution capacity improves aggregate throughput, where that improvement levels off, and what the benchmark can—and cannot—tell you about why.
What changes when you change GOMAXPROCS?
A goroutine is not an operating-system thread, and neither is a dedicated CPU core. The runtime schedules runnable goroutines onto threads. This short segment introduces the scheduler’s G, M, and P terminology; focus on the point that a P is scheduling capacity, not a physical core.
Go’s Secret Sauce: The Concurrency Model | Golang for Devs
Watch “Go’s Secret Sauce: The Concurrency Model” by Chris Elder for a visual explanation of goroutines, threads, and scheduler processors.
In G, M, and P, follow what each represents and why having runnable goroutines does not mean all of them execute at once. Stop at 00:03:43; you do not need the later discussion of default settings for this experiment.
For the precise limit—and for the caveat about defaults—consult the runtime documentation.
Read the Go runtime documentation to distinguish a limit on simultaneous Go execution from a limit on goroutines, and to see why you should not assume a fixed default across environments.
In the environment-variable description, find the paragraph beginning “The GOMAXPROCS variable limits” and read the limit, including its exception for threads blocked in system calls. Then find the GOMAXPROCS function entry: read the function description. Under its “Default” and “Updates” subsections, read the default behavior, stopping before “Compatibility.” Focus on why an explicit benchmark setting is easier to interpret than an assumed machine default.
For CPU-bound Go code, GOMAXPROCS limits how many threads can execute Go code simultaneously; it does not cap how many goroutines you may create. Nor does setting it to 8 guarantee eight available hardware execution units. CPU affinity, container quotas, and other work on the machine still matter. We’ll use the benchmark flag -cpu to set explicit values rather than rely on the runtime’s environment-dependent default.
Build a workload that can use more than one CPU
The mutex-protected counter from last time is a poor workload for this question: its operations all compete for one lock. If it failed to scale, you could not easily tell whether CPU capacity or lock contention was responsible. Instead, give each benchmark worker its own evolving integer state and perform arithmetic on it. There is no shared lock or I/O in the per-operation path.
In a new directory, run go mod init example.com/cpumixbench and save this as cpumix_bench_test.go:
package cpumixbench
import (
"sync/atomic"
"testing"
)
var resultSink uint64
func mix(x uint64) uint64 {
for i := 0; i < 64; i++ {
x ^= x >> 12
x ^= x << 25
x ^= x >> 27
x *= 2685821657736338717
}
return x
}
func BenchmarkCPUMix(b *testing.B) {
var nextSeed atomic.Uint64
var combined atomic.Uint64
b.RunParallel(func(pb *testing.PB) {
x := nextSeed.Add(1) // One seed per worker, not per operation.
for pb.Next() {
x = mix(x)
}
combined.Add(x) // One update per worker, after its work.
})
resultSink = combined.Load()
}
One successful pb.Next() corresponds to one call to mix, regardless of how many workers participate. Within that call, each arithmetic step depends on the preceding value, so a single operation does not itself split into independent goroutine tasks. Different workers, however, can perform their operations concurrently.
resultSink makes the final computation observable; otherwise, an optimizer might be able to discard work whose result is never used. The atomic seed and combination touch shared state only once per worker, outside the pb.Next() loop. They are still part of the timed RunParallel call, so this is not a pure measurement of mix alone. Running each benchmark long enough helps make that fixed per-worker cost small relative to its many operations.
The testing documentation confirms an important detail for the sweep: without SetParallelism, RunParallel defaults to GOMAXPROCS worker goroutines.
testing package - testing - Go Packages
Revisit the Go testing documentation specifically for how RunParallel responds when you change the CPU setting.
Under the RunParallel method entry, read its behavior and reporting, ending just before “Example.” Then, under SetParallelism, read the parallelism guidance. Notice why this CPU-bound benchmark leaves SetParallelism at its default.
This design means the sweep changes both the permitted simultaneous Go execution and the default number of benchmark workers. It measures the performance of this workload under each complete setting; it does not isolate a change in scheduler capacity while holding the worker count fixed.
Run and interpret the sweep
On a relatively quiet machine, run:
go test -run '^$' -bench '^BenchmarkCPUMix$' \
-cpu=1,2,4,8 -benchtime=2s -count=5 -benchmem
If your environment has limited CPU availability, use -cpu=1,2,4 or -cpu=1,2 instead. Values above the CPU capacity actually available to the process can still be informative, but do not treat them as additional physical cores. Keep the code, machine conditions, benchmark duration, and Go toolchain the same throughout the sweep. Do not compare these uninstrumented timings with a -race run.
In this command, -cpu=1,2,4,8 runs the benchmark at each listed GOMAXPROCS value. The suffix on an output name—such as BenchmarkCPUMix-4—identifies that value. -count=5 gives repeated observations at each setting, while -benchtime=2s gives each observation time to perform many operations. The exact iteration count may differ across runs because the benchmark harness calibrates it.
Compare the five ns/op values at each setting, looking at both their typical value and their spread. For a parallel benchmark, ns/op is elapsed wall time divided by all completed operations. It is an aggregate throughput measure, not the latency of one call to mix. You can express the same result as approximate operations per second:
For example, if your typical result were 800 ns/op at -cpu=1 and 250 ns/op at -cpu=4, the throughput speedup would be , not fourfold. These are illustrative numbers, not predicted results. If results at -cpu=4 and -cpu=8 overlap substantially across repeats, avoid claiming that one is reliably faster from a small difference in their best runs.
More capacity can help independent CPU work run concurrently, but improvement need not be linear. Available CPUs, competing processes, frequency changes under sustained load, and benchmark-worker overhead can all affect the measurements. A plateau tells you what happened in this environment; it does not, by itself, identify the cause. That distinction will matter when you begin investigating blocked goroutines and profiling delays later in this module.
Takeaways
An explicit -cpu sweep lets you measure how a CPU-bound Go benchmark behaves at different GOMAXPROCS settings. Here, each worker has independent arithmetic work, so a shared mutex is not the intended bottleneck. Read RunParallel’s ns/op as aggregate wall-time throughput, compare repeated results, and remember that -cpu changes both available Go execution capacity and this benchmark’s default worker count.
Next, you’ll shift from throughput measurements to a goroutine dump: when concurrent work is not progressing, you’ll look for goroutines blocked on locks, channels, or I/O.