Goroutine Lifecycle — Tasks¶
Table of Contents¶
- Setup
- Task 1: Observe
NumGoroutineOver a Lifecycle - Task 2: Force Each Runtime State
- Task 3: Lifecycle Test with Baseline
- Task 4: Implement
runtime.GoexitSemantics by Hand - Task 5: Capture a Goroutine's "Creator Stack"
- Task 6: Build a Tiny Supervisor
- Task 7: Graceful Shutdown Daemon
- Task 8:
LockOSThreadLifecycle - Task 9: Lifecycle Visualization with
runtime/trace - Task 10: Finalizer Goroutine Lifecycle
- Task 11:
goleakIntegration - Task 12: Lifecycle of a Web Server
- Stretch Tasks
Setup¶
Use Go 1.22 or later. Create a workspace:
Tasks are independent. Each can live in its own subdirectory:
Task 1: Observe NumGoroutine Over a Lifecycle¶
Goal. Watch the live goroutine count change as goroutines are born, blocked, woken, and die.
Spec¶
Write a program that:
- Prints
runtime.NumGoroutine()every 100 ms in a background goroutine. - Spawns 10 workers that sleep for 1 second each.
- After 2 seconds, exits.
Expected behavior: count rises to 11, holds during the sleep, drops back to ~2 (main + monitor), then exits.
Starter¶
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func monitor(done <-chan struct{}) {
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-done:
return
case <-tick.C:
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
}
func main() {
done := make(chan struct{})
go monitor(done)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Second)
}()
}
wg.Wait()
time.Sleep(500 * time.Millisecond) // let monitor record the drop
close(done)
}
Variations¶
- Change worker count and observe.
- Spawn workers that block forever (leak) and watch count rise without falling.
Task 2: Force Each Runtime State¶
Goal. Write goroutines that, at a given moment, are in each of the major runtime states. Inspect them with runtime.Stack(buf, true).
Spec¶
Spawn:
- One goroutine that loops without function calls (forces
_Grunning). - One that calls
time.Sleep(time.Hour)(forces_Gwaitingwith reason "sleep"). - One that blocks on an empty channel receive (
_Gwaiting, "chan receive"). - One that sends on an unbuffered channel with no reader (
_Gwaiting, "chan send"). - One that locks a mutex held by another goroutine (
_Gwaiting, "sync.Mutex.Lock").
After a brief time.Sleep, dump all goroutine stacks. Identify each waiting reason in the output.
Starter¶
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// _Grunning — a busy loop
go func() {
for i := 0; ; i++ {
_ = i
}
}()
// _Gwaiting, sleep
go func() {
time.Sleep(time.Hour)
}()
// _Gwaiting, chan receive
ch1 := make(chan int)
go func() {
<-ch1
}()
// _Gwaiting, chan send
ch2 := make(chan int)
go func() {
ch2 <- 1
}()
// _Gwaiting, sync.Mutex.Lock
var mu sync.Mutex
mu.Lock()
go func() {
mu.Lock()
mu.Unlock()
}()
time.Sleep(100 * time.Millisecond) // let them settle
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
fmt.Println(string(buf[:n]))
}
Read the output. Each goroutine has a [chan receive], [chan send], [sleep], or [semacquire] tag — those are the waitreason enum values.
Task 3: Lifecycle Test with Baseline¶
Goal. Write a Test* function that asserts no goroutines are leaked by a function under test.
Spec¶
Write runWork() that spawns 5 goroutines and waits for them. Write TestNoLeak that captures runtime.NumGoroutine() before, runs runWork(), waits 50 ms, and asserts the count is back to baseline.
Starter¶
package work_test
import (
"runtime"
"sync"
"testing"
"time"
)
func runWork() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Millisecond)
}()
}
wg.Wait()
}
func TestNoLeak(t *testing.T) {
before := runtime.NumGoroutine()
runWork()
time.Sleep(50 * time.Millisecond)
after := runtime.NumGoroutine()
if after > before {
t.Fatalf("leak: before=%d after=%d", before, after)
}
}
Variation¶
Intentionally leak a goroutine inside runWork. Watch the test fail. Then fix it.
Task 4: Implement runtime.Goexit Semantics by Hand¶
Goal. Show that runtime.Goexit runs deferred functions while exiting a goroutine from arbitrary depth.
Spec¶
Write a function level1 that calls level2 that calls level3. level3 calls runtime.Goexit. Each level has a defer that prints its level. Verify all three defers run.
Starter¶
package main
import (
"fmt"
"runtime"
"sync"
)
func level1(wg *sync.WaitGroup) {
defer wg.Done()
defer fmt.Println("level1 defer")
level2()
fmt.Println("level1 after — never printed")
}
func level2() {
defer fmt.Println("level2 defer")
level3()
fmt.Println("level2 after — never printed")
}
func level3() {
defer fmt.Println("level3 defer")
runtime.Goexit()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go level1(&wg)
wg.Wait()
}
Expected output (in this order):
Variation¶
Replace runtime.Goexit with panic("x") plus a recover at level1. Compare the output. Notice: with Goexit, wg.Done() runs cleanly; with panic, the recover at level1 must be in a separate defer (otherwise the recover does not catch anything).
Task 5: Capture a Goroutine's "Creator Stack"¶
Goal. Use pprof goroutine?debug=2 to see the "created by" stack frame.
Spec¶
Write a program that:
- Spawns 100 goroutines that all wait on a single channel.
- Exposes
pprofon:6060. - Sleeps forever.
Then:
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt- Open
goroutines.txt. For each blocked goroutine, find the "created by main.main in goroutine 1" line. That isgopcdata from thegstruct.
Starter¶
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func waitForever(ch <-chan struct{}) {
<-ch
}
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
ch := make(chan struct{})
for i := 0; i < 100; i++ {
go waitForever(ch)
}
select {}
}
Variation¶
Spawn goroutines from different functions. See how the creator stack differs and helps you pinpoint the spawn site.
Task 6: Build a Tiny Supervisor¶
Goal. Build a supervisor that restarts a goroutine after panic, with backoff.
Spec¶
type Supervisor struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func New(parent context.Context) *Supervisor
func (s *Supervisor) Go(name string, work func(context.Context) error)
func (s *Supervisor) Stop()
Go runs work(ctx) in a goroutine, recovering panics and restarting after a 1-second delay. Stop cancels and joins.
Starter¶
package supervisor
import (
"context"
"fmt"
"log"
"runtime/debug"
"sync"
"time"
)
type Supervisor struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func New(parent context.Context) *Supervisor {
ctx, cancel := context.WithCancel(parent)
return &Supervisor{ctx: ctx, cancel: cancel}
}
func (s *Supervisor) Go(name string, work func(context.Context) error) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
for s.ctx.Err() == nil {
err := s.runOne(name, work)
if s.ctx.Err() != nil {
return
}
log.Printf("%s exited (%v); restarting in 1s", name, err)
select {
case <-time.After(time.Second):
case <-s.ctx.Done():
return
}
}
}()
}
func (s *Supervisor) runOne(name string, work func(context.Context) error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%s panicked: %v\n%s", name, r, debug.Stack())
}
}()
return work(s.ctx)
}
func (s *Supervisor) Stop() {
s.cancel()
s.wg.Wait()
}
Test¶
func TestSupervisor_RestartsOnPanic(t *testing.T) {
sup := New(context.Background())
defer sup.Stop()
var calls atomic.Int32
sup.Go("flaky", func(ctx context.Context) error {
calls.Add(1)
if calls.Load() < 3 {
panic("simulated")
}
<-ctx.Done()
return nil
})
deadline := time.Now().Add(5 * time.Second)
for calls.Load() < 3 && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if calls.Load() < 3 {
t.Fatalf("expected at least 3 calls, got %d", calls.Load())
}
}
Variation¶
Add exponential backoff with jitter. Add a "crash budget" that stops restarting after N crashes per minute.
Task 7: Graceful Shutdown Daemon¶
Goal. Build a daemon with:
- HTTP server (
/healthendpoint). - Background worker that prints every second.
- Shutdown on SIGINT / SIGTERM, with a 5-second hard deadline.
Starter¶
package main
import (
"context"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
srv := &http.Server{Addr: ":8080", Handler: mux}
srvErr := make(chan error, 1)
go func() {
srvErr <- srv.ListenAndServe()
}()
workerDone := make(chan struct{})
go worker(ctx, workerDone)
select {
case <-ctx.Done():
log.Println("shutdown signal")
case err := <-srvErr:
log.Printf("server failed: %v", err)
}
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
select {
case <-workerDone:
case <-shutCtx.Done():
log.Println("worker did not finish within deadline")
}
log.Println("bye")
}
func worker(ctx context.Context, done chan<- struct{}) {
defer close(done)
tick := time.NewTicker(time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-tick.C:
log.Println("tick", t.Format("15:04:05"))
}
}
}
Test¶
Run, then Ctrl-C. You should see "shutdown signal" followed by "bye" within 5 seconds.
Task 8: LockOSThread Lifecycle¶
Goal. Observe that an OS thread is destroyed when a locked goroutine exits without Unlock.
Spec¶
Write a program that:
- Spawns 100 goroutines, each locks the OS thread and exits.
- Periodically reads
/sched/threads:threadsfromruntime/metrics. - Compares against a baseline (no
LockOSThread).
Starter¶
package main
import (
"fmt"
"runtime"
"runtime/metrics"
"sync"
)
func threadCount() uint64 {
samples := []metrics.Sample{{Name: "/sched/gomaxprocs:threads"}}
metrics.Read(samples)
return samples[0].Value.Uint64()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
// intentionally no UnlockOSThread.
}()
}
wg.Wait()
fmt.Println("post-test threads:", threadCount())
}
Note: runtime/metrics does not expose total OS threads directly; the more reliable measurement is to read /proc/self/status (Linux) and inspect Threads:.
Variation: add defer runtime.UnlockOSThread() and compare.
Task 9: Lifecycle Visualization with runtime/trace¶
Goal. Generate a trace and view the lifecycle of every goroutine in go tool trace.
Spec¶
Write a program that:
- Starts a trace.
- Spawns 5 workers that each do CPU-bound work for ~10 ms, then sleep for 50 ms, then do CPU work again, alternating for a few cycles.
- Stops the trace.
Open with go tool trace. Find the lifecycle of each worker in the goroutines view.
Starter¶
package main
import (
"os"
"runtime/trace"
"sync"
"time"
)
func work() {
sum := 0
for i := 0; i < 1_000_000; i++ {
sum += i
}
}
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 3; j++ {
work()
time.Sleep(50 * time.Millisecond)
}
}()
}
wg.Wait()
}
Then:
Open "Goroutines" view; click into individual goroutines and see the running/waiting bars.
Task 10: Finalizer Goroutine Lifecycle¶
Goal. Observe that a finalizer runs in its own goroutine.
Spec¶
Define a type T with a finalizer that prints runtime.NumGoroutine(). Allocate one, drop the reference, force GC, and observe.
Starter¶
package main
import (
"fmt"
"runtime"
)
type T struct{ id int }
func (t *T) finalize() {
fmt.Printf("finalizer for T#%d (goroutines now: %d)\n",
t.id, runtime.NumGoroutine())
}
func main() {
for i := 0; i < 3; i++ {
t := &T{id: i}
runtime.SetFinalizer(t, (*T).finalize)
_ = t
}
runtime.GC() // trigger finalization
// give finalizer goroutine time to run
runtime.Gosched()
select {} // wait
}
Notice the count jumps when the finalizer goroutine runs. Without select {}, the program may exit before finalizers run.
Task 11: goleak Integration¶
Goal. Use go.uber.org/goleak to assert no leaks in your test suite.
Spec¶
Add to go.mod:
Use it in TestMain:
package mypkg_test
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Write a passing test, then intentionally leak a goroutine; observe the failure.
func TestIntentionalLeak(t *testing.T) {
ch := make(chan int)
go func() {
<-ch
}()
// never close ch
}
goleak will report this leak when the test ends.
Task 12: Lifecycle of a Web Server¶
Goal. Build a server and trace every goroutine birth/death across a request lifecycle.
Spec¶
A server with one handler that:
- Spawns a goroutine that writes a log line after 100 ms.
- The goroutine is tied to the request's
context.Context— if the client disconnects, the goroutine exits.
Measure runtime.NumGoroutine before, during, and after a request. Verify no leak under sustained load.
Starter¶
package main
import (
"fmt"
"log"
"net/http"
"runtime"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
go func() {
select {
case <-time.After(100 * time.Millisecond):
log.Println("log entry written")
case <-ctx.Done():
log.Println("request canceled; aborting log")
}
}()
fmt.Fprintln(w, "ok")
}
func main() {
go func() {
for range time.Tick(time.Second) {
log.Printf("goroutines: %d", runtime.NumGoroutine())
}
}()
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Test: for i in $(seq 1 100); do curl -s localhost:8080/ > /dev/null & done; wait. Watch the goroutine count rise and then fall back.
Variation: replace ctx.Done() with nothing. Run the load test. Watch the count rise and stay.
Stretch Tasks¶
S1. Build a goroutine.Group library¶
A reusable group abstraction with:
- Bounded concurrency.
- Per-goroutine timeout.
- Panic recovery with structured logging.
- Cancel-on-first-error or wait-for-all modes.
S2. Build a pprof diff tool¶
Capture two goroutine profiles 60 seconds apart, diff them, and report the stacks whose count grew.
S3. Reproduce all eight runtime states¶
Write code that, at a single moment, has one goroutine in each of _Grunnable, _Grunning, _Gsyscall, _Gwaiting, _Gdead (just-dead), _Gcopystack (force stack growth), _Gpreempted (long busy loop), and _Gscan (during a runtime.GC()).
Dump and label each state. Use the runtime hex constants from runtime2.go.
S4. Compare GMP with Erlang/OTP processes¶
Write a one-page comparison: BEAM processes vs Go goroutines. Lifecycle, supervisor model, mailbox model, preemption.
S5. Lifecycle of a goroutine that participates in GC¶
Trace what happens when a goroutine is _Gwaiting while the GC starts. Use the _Gscan bit. Find the relevant code paths in runtime/mgc.go.
Submission Checklist¶
- All tasks compile with
go build ./.... - All tests pass with
go test -race ./.... - No leaks (verified with
goleakor manual baseline). - Tasks 1-3 are required; the rest are optional but recommended.
- Document any unexpected behavior you observed.
See find-bug.md for debugging exercises and optimize.md for performance-oriented tasks.