Go Runtime GMP — Hands-on Tasks¶
Practical exercises that build intuition for the Go scheduler. Each task says what to build, what success looks like, and a hint. Solutions or sketches at the end.
Easy¶
Task 1 — Inspect runtime constants¶
Write a small program that prints runtime.NumCPU(), runtime.GOMAXPROCS(0), and runtime.NumGoroutine().
- Run it as
go run main.go. - Run it with
GOMAXPROCS=2 go run main.goand observe. - Run it inside a Docker container with
--cpus=1and observe (depending on Go version).
Goal. Get familiar with runtime introspection.
Task 2 — Schedule trace¶
Run any Go program (yours, or one that has some I/O) with GODEBUG=schedtrace=500 go run main.go. Observe the output. Note gomaxprocs, runqueue, per-P queues.
Goal. Read scheduler trace output.
Task 3 — Force preemption visibility¶
Write a program with one goroutine doing tight CPU work and another printing a heartbeat every 100 ms. With Go 1.14+, the heartbeat should keep firing despite the busy loop. Confirm.
go func() {
for {
time.Sleep(100 * time.Millisecond)
fmt.Println("heartbeat:", time.Now())
}
}()
go func() {
for { _ = math.Sqrt(rand.Float64()) }
}()
select {}
Goal. Observe async preemption in action.
Task 4 — Disable async preemption¶
Run Task 3 with GODEBUG=asyncpreemptoff=1. Observe whether the heartbeat still fires. (On modern multi-core systems it may still work because the heartbeat runs on a different P.) Try GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 to single-thread it and see the heartbeat starve.
Goal. Understand the role of async preemption.
Task 5 — Goroutine count under load¶
Write a program that spawns 100 goroutines, each sleeping for 1 second. Before, during, and after spawning, print runtime.NumGoroutine().
Goal. See goroutine counts change over time.
Task 6 — GOMAXPROCS and CPU-bound parallelism¶
Write a CPU-bound benchmark: sum integers from 1 to 1 billion, split across N goroutines. Measure with N = 1, 2, 4, 8, 16. Run with default GOMAXPROCS and with GOMAXPROCS=1. Compare timings.
Goal. See GOMAXPROCS effect on parallel speedup.
Medium¶
Task 7 — Detect goroutine leak¶
Write a function that spawns a goroutine blocked on a chan int send with no receiver. Call it 100 times. Then print runtime.NumGoroutine(). Observe ~100+ leaked goroutines.
Use pprof to view their stacks:
Then curl http://localhost:6060/debug/pprof/goroutine?debug=1 > leaks.txt and inspect.
Goal. Practice detecting goroutine leaks.
Task 8 — LockOSThread demonstration¶
Write a goroutine that calls runtime.LockOSThread, prints the current OS thread ID (use syscall.Gettid on Linux), does some work, then unlocks. Verify it stays on the same thread.
Compare to a goroutine without LockOSThread: log the thread ID at various points and observe it changing.
Goal. See thread pinning in action.
Task 9 — Scheduler under syscall load¶
Write a program that spawns 100 goroutines, each doing a sequence of file reads (os.ReadFile) on different files. Print runtime.NumGoroutine() and the M count (you cannot read M count directly; use GODEBUG=schedtrace=500 scheddetail=1 and grep for "threads=").
Observe that the M count rises beyond GOMAXPROCS during syscall storms.
Goal. See M-pool growth under blocking syscalls.
Task 10 — go tool trace¶
Run any Go program with tracing:
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... your concurrent code ...
Then go tool trace trace.out. Open the web view. Look at goroutine timelines, scheduler events, GC events.
Goal. Become comfortable with go tool trace.
Task 11 — runtime/metrics¶
Use the runtime/metrics package to read scheduler metrics:
import "runtime/metrics"
samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/latencies:seconds"},
}
metrics.Read(samples)
for _, s := range samples {
fmt.Printf("%s: %v\n", s.Name, s.Value)
}
Read every second; observe values change as your program runs.
Goal. Use the stable runtime metrics API.
Task 12 — automaxprocs in a container¶
Set up a small Docker container running a Go program with --cpus=2. Run it without automaxprocs and check runtime.GOMAXPROCS(0). Then add automaxprocs and re-run; verify it now reports 2.
(If you are on Go 1.21+, native cgroup detection may already do this. Test both behaviours.)
Goal. Understand container GOMAXPROCS problems.
Hard¶
Task 13 — Spinlock vs channel benchmark¶
Write two implementations of a counter shared by 8 goroutines:
- Atomic increment.
- Channel-mediated owner goroutine.
Measure with testing.B.RunParallel. Compare ns/op. Atomics should win by 10–50x.
Goal. See the cost of CSP for simple shared state.
Task 14 — Custom GOMAXPROCS adjustment¶
Write a function that monitors load (e.g., runtime.NumGoroutine or actual CPU usage) and dynamically adjusts runtime.GOMAXPROCS. Verify it adapts.
Caveat: do not deploy this to production. It is an exercise.
Goal. Understand GOMAXPROCS as a tunable.
Task 15 — Simulated long syscall¶
Use syscall.Syscall to make a goroutine call into the kernel with a long-running operation (e.g., syscall.Pause or a deliberate slow read). Observe via scheddetail that the P is detached during the syscall and reattached after.
Goal. See the syscall handoff mechanism.
Task 16 — Build a worker pool that scales with GOMAXPROCS¶
Write a worker pool whose worker count is runtime.GOMAXPROCS(0). Process 10 000 CPU-bound jobs. Compare to a pool with 1 worker. Speedup should approach GOMAXPROCS.
Then change GOMAXPROCS at runtime and observe the pool adapting (or not — your design decides).
Goal. Design a pool that matches scheduler capacity.
Task 17 — Diagnose a scheduling issue¶
Take any moderately-complex concurrent program. Add intentional bugs:
- A long sleep in one goroutine.
- A blocking send on an unbuffered channel.
- A goroutine leak.
For each, use pprof goroutine, GODEBUG=schedtrace, or go tool trace to diagnose.
Goal. Develop diagnostic intuition.
Task 18 — Implement a poor-man's scheduler trace¶
Write code that periodically (every 100 ms) prints:
runtime.NumGoroutine()- A summary of
runtime/metricssamples for scheduler-related metrics. - Memory stats from
runtime.ReadMemStats.
Format as a one-line summary. This is your in-process schedule trace.
Goal. Build operational tooling.
Task 19 — Reproduce the GOMAXPROCS-vs-container issue¶
Run a Go service in Docker with --cpus=2 on a host with many cores. Measure throughput. Then add automaxprocs (or upgrade to Go 1.21+) and re-measure. Quantify the improvement.
Goal. Experience the container problem firsthand.
Task 20 — Read the scheduler source¶
Open src/runtime/proc.go. Find:
schedule()function.findrunnable()function.runqsteal()function.sysmon()function.
Read each and write a short summary (2–4 sentences) in your own words.
Goal. Build courage to read the runtime.
Solutions and hints¶
Task 7 sketch¶
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // blocks forever
}()
}
func main() {
fmt.Println("before:", runtime.NumGoroutine())
for i := 0; i < 100; i++ {
leak()
}
time.Sleep(time.Second)
fmt.Println("after:", runtime.NumGoroutine())
}
Task 8 sketch¶
package main
import (
"fmt"
"runtime"
"syscall"
"time"
)
func main() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for i := 0; i < 5; i++ {
fmt.Println("tid:", syscall.Gettid())
time.Sleep(100 * time.Millisecond)
}
}
The TID should be the same across all iterations.
Task 13 atomic version¶
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomic.AddInt64(&counter, 1)
}
})
Task 13 channel version¶
inc := make(chan int, 100)
go func() {
var n int
for d := range inc {
n += d
}
}()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
inc <- 1
}
})
close(inc)
The channel version will be much slower under contention.
Task 14 sketch¶
go func() {
t := time.NewTicker(5 * time.Second)
for range t.C {
if shouldScaleUp() {
runtime.GOMAXPROCS(runtime.GOMAXPROCS(0) + 1)
} else if shouldScaleDown() {
runtime.GOMAXPROCS(runtime.GOMAXPROCS(0) - 1)
}
}
}()
Wrap-up¶
After these tasks you should:
- Read scheduler traces and pprof goroutine profiles.
- Understand how
GOMAXPROCSinteracts with cores and containers. - See the effect of
LockOSThreadand syscalls on the M count. - Detect goroutine leaks systematically.
- Use
runtime/metricsfor production observability. - Have read at least a few hundred lines of
runtime/proc.go.
The next file (find-bug.md) tests your scheduler debugging skills.