Runtime Goroutine Management — Tasks¶
Hands-on exercises for each API group. Solutions are at the end of each section. Run each program; verify the behaviour matches what you expect before peeking.
Easy¶
Task 1 — Counting goroutines through a lifecycle¶
Write a program that:
- Prints
runtime.NumGoroutine()before spawning anything. - Spawns 50 goroutines, each sleeping 100 ms.
- Prints
runtime.NumGoroutine()while they are alive. - Waits for them.
- Prints
runtime.NumGoroutine()again.
Expected: numbers around 1 → 51 → 1.
Task 2 — Read GOMAXPROCS and NumCPU¶
Write a program that prints runtime.NumCPU(), then runtime.GOMAXPROCS(0), sets GOMAXPROCS to 2, and prints the new value plus the returned previous value. Run with and without GOMAXPROCS=4 go run main.go.
Task 3 — Print the current goroutine's stack¶
Write a program with a few nested function calls. From the innermost function, print the current goroutine's stack using debug.Stack().
Task 4 — Goexit with deferred prints¶
Write a goroutine that:
- Defers a print of "first defer."
- Defers a print of "second defer."
- Calls
runtime.Goexit(). - Has a print of "unreachable" after
Goexit.
Verify the output order: second defer, first defer. The unreachable line should never print.
Task 5 — Force GC and observe¶
Write a program that:
- Allocates 100 MB worth of byte slices.
- Records
/memory/classes/heap/objects:bytesfromruntime/metrics. - Nils the references.
- Calls
runtime.GC(). - Records the metric again. The drop should be visible.
Medium¶
Task 6 — LockOSThread lifecycle¶
Write a goroutine that:
- Calls
runtime.LockOSThread(). - Prints
runtime.NumGoroutine(). - Sleeps 100 ms.
- Calls
runtime.UnlockOSThread().
Spawn 5 such goroutines concurrently. Observe via /proc/self/status (Linux) how many threads the process has during the sleep. Expected: ≥ 5 OS threads.
Task 7 — Goroutine leak detector¶
Write a test-helper function:
func assertNoLeak(t *testing.T, fn func()) {
before := runtime.NumGoroutine()
fn()
for i := 0; i < 10; i++ {
runtime.GC()
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("leak: before=%d after=%d", before, runtime.NumGoroutine())
}
Use it on (a) a function that returns cleanly, and (b) a function that leaks a goroutine blocked on time.Sleep(time.Hour). Verify case (a) passes and case (b) fails.
Task 8 — Apply profiling labels to an HTTP handler¶
Write an HTTP server with a single handler /work. The handler does ~100 ms of CPU work. Wrap it with pprof.Do to tag goroutines with endpoint=/work and kind=cpu. Capture a 5-second CPU profile while hitting it with wrk or hey. Confirm via go tool pprof -tagfocus=endpoint=/work cpu.pprof that the profile is correctly tagged.
Task 9 — Print runtime metrics in a loop¶
Write a program that, every 2 seconds, reads:
/sched/goroutines:goroutines/memory/classes/heap/objects:bytes/gc/cycles/total:gc-cycles
and prints them in a single line. Allocate some bytes in a background goroutine so the metrics actually change.
Task 10 — SetMaxStack to crash early on recursion¶
Write a program that:
- Calls
debug.SetMaxStack(1 << 20)(1 MB). - Defers a recover with
fmt.Println("recovered:", r). - Calls a recursive function with no base case.
Expected: crash with "stack overflow" message, recovered.
Task 11 — Use SetMemoryLimit to control allocation¶
Write a program that allocates 10 MB chunks in a loop with a 100 ms sleep between. Set debug.SetMemoryLimit(50 << 20) (50 MB) and debug.SetGCPercent(-1) (so the only GC trigger is the memory limit). Plot heap size over time using /memory/classes/heap/objects:bytes. Expected: heap oscillates around 50 MB, not growing unbounded.
Hard¶
Task 12 — Stack dump on signal¶
Write a server that listens on SIGUSR1. On signal, dumps all goroutine stacks to a file stacks-<timestamp>.txt using runtime.Stack(buf, true). Test by sending kill -USR1 <pid> while the server has 100 goroutines blocked on a channel.
Task 13 — Adaptive GOMEMLIMIT¶
Write a controller that:
- Reads
/sys/fs/cgroup/memory.maxfor the container's hard cap. - Sets
debug.SetMemoryLimit(cap * 90 / 100). - Every 30 seconds, reads
/proc/pressure/memoryPSIsomeavg10. - If avg10 > 5.0, lowers the memory limit by 10% (floor at 60% of cap).
- If avg10 < 1.0, raises back toward 90% of cap.
- Logs every change.
Test by allocating aggressively while a memory-hungry neighbour runs on the same machine.
Task 14 — Continuous profile dispatcher¶
Write an in-process profiler that, every 60 seconds:
- Captures a 5-second CPU profile.
- Captures a heap profile snapshot.
- Captures a goroutine profile snapshot.
- Saves each to disk with a filename including timestamp and profile type.
Run it under load. Confirm the files are valid (go tool pprof <file>).
Task 15 — runtime/trace capture endpoint¶
Add a /debug/trace?seconds=N endpoint to your HTTP server. It should call trace.Start(w), sleep N seconds (default 5, max 60), then trace.Stop. Test by hitting the endpoint while serving requests, then open the result with go tool trace. Inspect the goroutine timeline.
Task 16 — Detect cgo thread storm¶
Write a program that spawns 500 goroutines, each calling a 100 ms cgo function (use time.Sleep with purego or actual import "C"). Measure the thread count via /proc/self/status. Then apply debug.SetMaxThreads(50) and verify the program crashes with a clean error.
Task 17 — Build a runtime/metrics-driven dashboard¶
Use prometheus/client_golang with collectors.NewGoCollector(collectors.WithGoCollections(collectors.GoRuntimeMetricsCollection)). Serve /metrics. Run Prometheus locally and create a Grafana dashboard with:
- Goroutine count over time.
- GC pause p99 histogram.
- Heap size over time.
- Mutex contention rate (after
SetMutexProfileFraction(5)). - Scheduler latency p99.
Solutions¶
Solution 1¶
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
fmt.Println("before:", runtime.NumGoroutine())
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
}()
}
time.Sleep(10 * time.Millisecond)
fmt.Println("during:", runtime.NumGoroutine())
wg.Wait()
fmt.Println("after:", runtime.NumGoroutine())
}
Solution 2¶
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS default:", runtime.GOMAXPROCS(0))
prev := runtime.GOMAXPROCS(2)
fmt.Println("set to 2; previous:", prev)
fmt.Println("current:", runtime.GOMAXPROCS(0))
}
Solution 3¶
package main
import (
"fmt"
"runtime/debug"
)
func main() {
a()
}
func a() { b() }
func b() { c() }
func c() {
fmt.Printf("%s", debug.Stack())
}
Solution 4¶
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer fmt.Println("first defer")
defer fmt.Println("second defer")
runtime.Goexit()
fmt.Println("unreachable")
}()
wg.Wait()
}
Output:
Solution 5¶
package main
import (
"fmt"
"runtime"
"runtime/metrics"
)
func main() {
s := []metrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}
var data [][]byte
for i := 0; i < 100; i++ {
data = append(data, make([]byte, 1<<20))
}
metrics.Read(s)
fmt.Println("with data:", s[0].Value.Uint64())
data = nil
runtime.GC()
metrics.Read(s)
fmt.Println("after GC:", s[0].Value.Uint64())
}
Solution 6¶
package main
import (
"fmt"
"os"
"runtime"
"strings"
"sync"
"time"
)
func threads() int {
b, _ := os.ReadFile("/proc/self/status")
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Threads:") {
var n int
fmt.Sscanf(l, "Threads: %d", &n)
return n
}
}
return -1
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
defer runtime.UnlockOSThread()
time.Sleep(100 * time.Millisecond)
}()
}
time.Sleep(20 * time.Millisecond)
fmt.Println("threads:", threads())
wg.Wait()
}
Solution 7¶
package mypkg
import (
"runtime"
"testing"
"time"
)
func assertNoLeak(t *testing.T, fn func()) {
t.Helper()
before := runtime.NumGoroutine()
fn()
for i := 0; i < 10; i++ {
runtime.GC()
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("leak: before=%d after=%d", before, runtime.NumGoroutine())
}
func TestNoLeak_good(t *testing.T) {
assertNoLeak(t, func() {
done := make(chan struct{})
go func() { close(done) }()
<-done
})
}
func TestNoLeak_bad(t *testing.T) {
assertNoLeak(t, func() {
go time.Sleep(time.Hour)
})
}
TestNoLeak_bad should fail. Use t.Skip or go test -run TestNoLeak_good for the good case.
Solution 8¶
package main
import (
"context"
"net/http"
_ "net/http/pprof"
"runtime/pprof"
)
func main() {
http.Handle("/work", http.HandlerFunc(handle))
http.ListenAndServe(":8080", nil)
}
func handle(w http.ResponseWriter, r *http.Request) {
labels := pprof.Labels("endpoint", "/work", "kind", "cpu")
pprof.Do(r.Context(), labels, func(ctx context.Context) {
cpuBurn(ctx)
w.Write([]byte("done"))
})
}
func cpuBurn(_ context.Context) {
x := 0
for i := 0; i < 200_000_000; i++ {
x += i
}
_ = x
}
Capture: go tool pprof http://localhost:8080/debug/pprof/profile?seconds=5. Then tagfocus.
Solution 9¶
package main
import (
"fmt"
"runtime/metrics"
"time"
)
func main() {
samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/memory/classes/heap/objects:bytes"},
{Name: "/gc/cycles/total:gc-cycles"},
}
go func() {
var keep [][]byte
for {
keep = append(keep, make([]byte, 1<<20))
if len(keep) > 100 {
keep = keep[:0]
}
time.Sleep(20 * time.Millisecond)
}
}()
for {
metrics.Read(samples)
fmt.Printf("g=%d heap=%dKB gc=%d\n",
samples[0].Value.Uint64(),
samples[1].Value.Uint64()/1024,
samples[2].Value.Uint64())
time.Sleep(2 * time.Second)
}
}
Solution 10¶
package main
import (
"fmt"
"runtime/debug"
)
func main() {
debug.SetMaxStack(1 << 20)
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
grow(0)
}
func grow(n int) {
var buf [4096]byte
_ = buf
grow(n + 1)
}
Note: recover may not catch the runtime's stack-overflow panic in all versions. If not recovered, the program crashes with a clear stack-overflow message.
Solution 11¶
package main
import (
"fmt"
"runtime/debug"
"runtime/metrics"
"time"
)
func main() {
debug.SetMemoryLimit(50 << 20)
debug.SetGCPercent(-1)
s := []metrics.Sample{{Name: "/memory/classes/heap/objects:bytes"}}
for i := 0; i < 100; i++ {
_ = make([]byte, 10<<20)
metrics.Read(s)
fmt.Println("iter", i, "heap MB:", s[0].Value.Uint64()/(1<<20))
time.Sleep(100 * time.Millisecond)
}
}
Solution 12¶
package main
import (
"fmt"
"os"
"os/signal"
"runtime"
"syscall"
"time"
)
func main() {
for i := 0; i < 100; i++ {
ch := make(chan struct{})
go func() { <-ch }() // blocked forever
_ = ch
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGUSR1)
for range sig {
fn := fmt.Sprintf("stacks-%d.txt", time.Now().Unix())
f, _ := os.Create(fn)
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
f.Write(buf[:n])
f.Close()
fmt.Println("wrote", fn)
}
}
Solution 13¶
Sketch (omitting full PSI parser):
package main
import (
"log"
"runtime/debug"
"time"
)
func runAdapter(capBytes int64) {
base := capBytes * 90 / 100
floor := capBytes * 60 / 100
cur := base
debug.SetMemoryLimit(cur)
for range time.Tick(30 * time.Second) {
psi := readPSI()
target := cur
switch {
case psi.SomeAvg10 > 5.0 && cur > floor:
target = cur * 9 / 10
case psi.SomeAvg10 < 1.0 && cur < base:
target = cur * 11 / 10
if target > base { target = base }
}
if target != cur {
log.Printf("SetMemoryLimit %d -> %d (PSI=%.2f)", cur, target, psi.SomeAvg10)
debug.SetMemoryLimit(target)
cur = target
}
}
}
type psi struct{ SomeAvg10 float64 }
func readPSI() psi {
// Implement parsing of /proc/pressure/memory
return psi{}
}
Solution 14¶
package main
import (
"fmt"
"os"
"runtime/pprof"
"time"
)
func runProfiler() {
for range time.Tick(60 * time.Second) {
ts := time.Now().Unix()
if f, err := os.Create(fmt.Sprintf("cpu-%d.pprof", ts)); err == nil {
pprof.StartCPUProfile(f)
time.Sleep(5 * time.Second)
pprof.StopCPUProfile()
f.Close()
}
if f, err := os.Create(fmt.Sprintf("heap-%d.pprof", ts)); err == nil {
pprof.Lookup("heap").WriteTo(f, 0)
f.Close()
}
if f, err := os.Create(fmt.Sprintf("goroutine-%d.pprof", ts)); err == nil {
pprof.Lookup("goroutine").WriteTo(f, 0)
f.Close()
}
}
}
Solution 15¶
package main
import (
"net/http"
"runtime/trace"
"strconv"
"time"
)
func main() {
http.HandleFunc("/debug/trace", func(w http.ResponseWriter, r *http.Request) {
sec, _ := strconv.Atoi(r.URL.Query().Get("seconds"))
if sec <= 0 { sec = 5 }
if sec > 60 { sec = 60 }
w.Header().Set("Content-Type", "application/octet-stream")
if err := trace.Start(w); err != nil {
http.Error(w, err.Error(), 500)
return
}
time.Sleep(time.Duration(sec) * time.Second)
trace.Stop()
})
http.ListenAndServe(":8080", nil)
}
Solution 16¶
package main
/*
#include <unistd.h>
void slow(void) { usleep(100000); }
*/
import "C"
import (
"fmt"
"os"
"runtime/debug"
"strings"
"sync"
)
func threads() int {
b, _ := os.ReadFile("/proc/self/status")
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Threads:") {
var n int
fmt.Sscanf(l, "Threads: %d", &n)
return n
}
}
return -1
}
func main() {
debug.SetMaxThreads(50)
var wg sync.WaitGroup
for i := 0; i < 500; i++ {
wg.Add(1)
go func() {
defer wg.Done()
C.slow()
}()
}
wg.Wait()
fmt.Println("threads:", threads())
}
Expected: program crashes with runtime: program exceeds N-thread limit.
Solution 17¶
package main
import (
"net/http"
"runtime"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
runtime.SetMutexProfileFraction(5)
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector(
collectors.WithGoCollections(collectors.GoRuntimeMetricsCollection),
))
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
http.ListenAndServe(":2112", nil)
}
Connect Prometheus, build the Grafana dashboard. Use queries like go_sched_goroutines, go_gc_pauses_seconds, go_memory_classes_heap_objects_bytes.