Goroutines — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solutions or solution sketches are at the end.
Easy¶
Task 1 — First goroutine¶
Write a program that prints "main start", spawns a goroutine that prints "from goroutine", and prints "main end". Make sure all three lines always print.
- Use
sync.WaitGroupto coordinate. - Run it 10 times in a row; the order of "from goroutine" relative to "main end" can vary, but all three lines must appear every time.
Goal. Learn the basic spawn-and-wait flow.
Task 2 — Spawn N workers¶
Spawn 10 worker goroutines, each printing its own ID. Use a sync.WaitGroup to wait for all of them. Verify all 10 IDs print before "main exits".
- Pass the ID by parameter, not by capture.
- Run with
GOMAXPROCS=1andGOMAXPROCS=8. Observe that atGOMAXPROCS=1the order is deterministic per Go version; at higher values it is random.
Goal. Observe scheduling behaviour and learn the parameter-passing idiom.
Task 3 — Race detection¶
Write a goroutine that increments a shared int counter 10 000 times. Spawn 10 of them. Print the final value.
- First version: no synchronisation. Run with
go run -race main.go— observe the race report. - Second version: protect with
sync.Mutex. Verify the result is 100 000. - Third version: replace the mutex with
sync/atomic.AddInt64. Compare nanosecond cost viatime.Since.
Goal. See the race detector in action and compare two synchronisation tools.
Task 4 — Capture-the-loop-variable bug¶
Write the classic for i := 0; i < 5; i++ { go func() { fmt.Println(i) }() } loop. Run it on a Go version pre-1.22 if possible (or simulate by using a for ... range over a slice of indices on the older form). Observe the "5 5 5 5 5".
Then fix it three ways:
- Pass
ias a parameter. - Make a local copy
i := iinside the loop body. - Use Go 1.22+ semantics (declare in
for i := range ...).
Goal. Internalise the captured-variable trap.
Task 5 — Goroutine count¶
Print runtime.NumGoroutine() before, during, and after spawning 100 short-lived goroutines that each call time.Sleep(100 * time.Millisecond). Use time.Sleep only for the demo (do not use this pattern for real coordination).
Goal. Learn runtime.NumGoroutine as a diagnostic.
Medium¶
Task 6 — Worker pool with channel¶
Implement a pool of 4 workers reading jobs from a chan int and writing results to chan int. Submit 100 numbers; each worker squares the input. Verify all 100 squared results come out, and close(results) cleanly signals end of stream.
- The submitter goroutine is responsible for closing
jobsafter sending the last input. - A separate goroutine
wg.Wait()s on all workers, then closesresults.
Goal. Master the canonical worker-pool template.
Task 7 — Cancellation with context.Context¶
Spawn a goroutine that prints "tick" once per second. Cancel it after 5 seconds. Verify the goroutine prints exactly 5 (or 4) times and exits cleanly.
- Use
context.WithCancel. - Use
select { case <-ctx.Done(): return ; case <-time.After(...): }for the loop.
Goal. Learn the cancellation idiom.
Task 8 — Fan-out fetcher¶
Write a function FetchAll(ctx, urls []string) (map[string][]byte, error) that:
- Spawns one goroutine per URL.
- Uses
errgroup.WithContextto coordinate. - Returns results in a map; the first error cancels all in-flight requests.
- Bounds parallelism at 8 concurrent fetches via
g.SetLimit(8).
Test with a mix of fast URLs, slow URLs, and one URL that returns 500. Confirm the slow ones are cancelled when the 500 returns.
Goal. Use errgroup for production-shape concurrent work.
Task 9 — Pipeline of three stages¶
Build a three-stage pipeline:
- Stage 1 generates integers 1..1000 onto a channel.
- Stage 2 consumes integers, squares them, sends onto a second channel.
- Stage 3 consumes squares, sums them, prints the total.
Each stage is its own goroutine. Channels carry items. Verify the sum equals 1² + 2² + ... + 1000² = 333 833 500.
Variations:
- Add 4 parallel goroutines to stage 2 (fan-out within a pipeline).
- Add cancellation: cancel halfway through and confirm clean shutdown.
Goal. Master the pipeline pattern.
Task 10 — Bounded parallelism with semaphore¶
You have a slice of 1000 URLs to fetch. The downstream service allows at most 10 concurrent requests. Implement a fetcher that respects this.
- Use
golang.org/x/sync/semaphore.NewWeighted(10). - Confirm via instrumentation that you never exceed 10 in-flight at once.
- Use
errgroupfor joining.
Goal. Learn weighted parallelism control.
Task 11 — Goroutine-safe counter¶
Build a Counter type with Inc, Add(n int), Value() int. Make it safe for concurrent use. Provide three implementations:
sync.Mutex+int.sync/atomic.Int64.- Channel-based actor goroutine.
Benchmark the three with 100 goroutines × 100 000 increments. Note the relative speeds.
Goal. Compare synchronisation primitives at a real workload.
Hard¶
Task 12 — Detect goroutine leaks in tests¶
Take any goroutine-spawning function from earlier tasks. Add a test using go.uber.org/goleak:
Intentionally introduce a leak (a goroutine that waits on a channel that nobody closes). Confirm the test fails. Fix the leak. Confirm the test passes.
Goal. Make leak detection part of your test suite forever.
Task 13 — Supervised background loop¶
Build a long-running goroutine that consumes from a channel of jobs. On panic, the goroutine should:
- Recover the panic.
- Log the panic with stack trace.
- Restart itself, with exponential backoff (1s, 2s, 4s, 8s, capped at 30s).
- Reset the backoff on a successful 60-second run.
Inject deliberate panics every few jobs. Watch the supervisor in action.
Goal. Learn the supervisor pattern for crash tolerance.
Task 14 — Backpressure-aware service¶
Build an HTTP service that wraps a worker pool of size 100. The /work endpoint:
- Submits the request body to a job channel.
- If the job channel is full (capacity 200), returns HTTP 503.
- Otherwise, waits for the result and returns it.
Load-test with 1000 concurrent requests. Observe how 503s preserve the service when overwhelmed, vs an unbounded version that would OOM.
Goal. Learn how to apply backpressure under load.
Task 15 — Goroutine labels for pprof¶
Take any non-trivial concurrent program. Tag goroutines with pprof.Labels:
ctx := pprof.WithLabels(parent, pprof.Labels("op", "fetch", "user", userID))
pprof.SetGoroutineLabels(ctx)
go work(ctx)
Run it, hit /debug/pprof/goroutine?debug=2, and confirm goroutines are grouped by label. Use this to identify which "feature" is consuming the most goroutines.
Goal. Production-grade observability of goroutines.
Task 16 — Trace a real workload¶
Wrap a non-trivial program with runtime/trace:
Open trace.out with go tool trace. Identify:
- A goroutine that runs without preemption for >10 ms.
- A goroutine that spends most of its life parked.
- A scheduler latency event (delay between runnable and running).
Goal. Use the runtime tracer to understand scheduler behaviour.
Task 17 — Implement a small pool from scratch¶
Without relying on errgroup or third-party libraries, implement a Pool type with:
New(workers int) *PoolSubmit(fn func()) error— non-blocking, returns error if pool is closed or queue full.Stop()— close to new submissions, wait for in-flight to finish, then return.- Recovers panics in worker functions and logs them, rather than crashing the program.
- Exposes
InFlight() intandQueueDepth() intfor observability.
Write tests covering: normal operation, panic recovery, graceful stop, submission to a stopped pool, queue overflow.
Goal. Build the pool you have used many times, from primitives.
Task 18 — Implement a goroutine-aware rate limiter¶
Build a rate limiter that allows at most R requests per second across all goroutines. The interface:
Two implementations:
- Token bucket with a goroutine that adds tokens at rate R.
- Mutex + timestamp of last allowed request, no goroutine.
Benchmark both at 10 000 requests/second. Compare CPU and latency distribution.
Goal. Apply concurrency primitives to a real coordination problem.
Task 19 — Race-condition Whodunnit¶
This skeleton has a subtle race. Find it, explain it, and fix it without changing the API.
type EventBus struct {
listeners []func(string)
}
func (b *EventBus) Subscribe(fn func(string)) {
b.listeners = append(b.listeners, fn)
}
func (b *EventBus) Publish(event string) {
for _, fn := range b.listeners {
go fn(event)
}
}
Hint: Subscribe and Publish may be called concurrently. The append may relocate the underlying array. Worse: even with proper locking, calling fn(event) in a new goroutine while iterating allows reordering with subsequent Publishes.
Goal. Recognise and fix realistic concurrency bugs.
Task 20 — Implement structured concurrency¶
Build a small taskgroup package with one type:
type Group struct { ... }
func New(ctx context.Context) (*Group, context.Context)
func (g *Group) Go(fn func() error)
func (g *Group) Wait() error
Requirements:
Go(fn)spawns a goroutine runningfn.- The first non-nil error cancels the derived context, so concurrent goroutines exit early.
Waitreturns the first non-nil error (or nil).- Panics in
fnare recovered and converted to errors. - A test verifies no goroutines leak after
Waitreturns.
This is essentially errgroup.Group plus panic recovery. Implement it from scratch to internalise the design.
Goal. Understand the building blocks of structured concurrency.
Solution Sketches¶
Task 1¶
package main
import (
"fmt"
"sync"
)
func main() {
fmt.Println("main start")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("from goroutine")
}()
wg.Wait()
fmt.Println("main end")
}
Task 6¶
func RunPool(jobs <-chan int, results chan<- int, workers int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs {
results <- n * n
}
}()
}
go func() {
wg.Wait()
close(results)
}()
}
Task 8¶
func FetchAll(ctx context.Context, urls []string) (map[string][]byte, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
var mu sync.Mutex
out := make(map[string][]byte)
for _, url := range urls {
url := url
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return err }
res, err := http.DefaultClient.Do(req)
if err != nil { return err }
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil { return err }
if res.StatusCode >= 500 {
return fmt.Errorf("status %d for %s", res.StatusCode, url)
}
mu.Lock()
out[url] = body
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil { return nil, err }
return out, nil
}
Task 13¶
func supervise(ctx context.Context, name string, run func(context.Context) error) {
backoff := time.Second
successWindow := time.Minute
for {
if ctx.Err() != nil { return }
start := time.Now()
err := safeRun(ctx, run)
if ctx.Err() != nil { return }
if time.Since(start) > successWindow {
backoff = time.Second // reset
}
log.Printf("supervised %q: %v; restart in %v", name, err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second { backoff *= 2 }
}
}
func safeRun(ctx context.Context, run func(context.Context) error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
return run(ctx)
}
Task 17 (skeleton)¶
type Pool struct {
queue chan func()
quit chan struct{}
wg sync.WaitGroup
inflight atomic.Int64
closed atomic.Bool
}
func New(workers int) *Pool {
p := &Pool{
queue: make(chan func(), workers*4),
quit: make(chan struct{}),
}
p.wg.Add(workers)
for i := 0; i < workers; i++ {
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
for {
select {
case <-p.quit:
return
case fn, ok := <-p.queue:
if !ok { return }
p.run(fn)
}
}
}
func (p *Pool) run(fn func()) {
p.inflight.Add(1)
defer p.inflight.Add(-1)
defer func() {
if r := recover(); r != nil {
log.Printf("pool worker panic: %v", r)
}
}()
fn()
}
func (p *Pool) Submit(fn func()) error {
if p.closed.Load() { return errors.New("pool closed") }
select {
case p.queue <- fn:
return nil
default:
return errors.New("queue full")
}
}
func (p *Pool) Stop() {
if !p.closed.CompareAndSwap(false, true) { return }
close(p.quit)
p.wg.Wait()
}
func (p *Pool) InFlight() int64 { return p.inflight.Load() }
func (p *Pool) QueueDepth() int { return len(p.queue) }
Task 19¶
The fix: copy listeners under lock before publishing, so iteration is over a stable slice.
type EventBus struct {
mu sync.RWMutex
listeners []func(string)
}
func (b *EventBus) Subscribe(fn func(string)) {
b.mu.Lock()
b.listeners = append(b.listeners, fn)
b.mu.Unlock()
}
func (b *EventBus) Publish(event string) {
b.mu.RLock()
snapshot := make([]func(string), len(b.listeners))
copy(snapshot, b.listeners)
b.mu.RUnlock()
for _, fn := range snapshot {
go fn(event)
}
}
A more refined design uses atomic.Pointer[[]func(string)] for lock-free reads:
type EventBus struct {
listeners atomic.Pointer[[]func(string)]
mu sync.Mutex
}
func (b *EventBus) Subscribe(fn func(string)) {
b.mu.Lock()
defer b.mu.Unlock()
cur := b.listeners.Load()
var next []func(string)
if cur != nil { next = append(next, *cur...) }
next = append(next, fn)
b.listeners.Store(&next)
}
func (b *EventBus) Publish(event string) {
cur := b.listeners.Load()
if cur == nil { return }
for _, fn := range *cur {
go fn(event)
}
}
Subscribers see lock-free reads; subscribers write under a mutex but take a copy-on-write strategy. Common pattern in high-throughput pub/sub.
Task 20 (skeleton)¶
type Group struct {
cancel context.CancelFunc
wg sync.WaitGroup
once sync.Once
err atomic.Pointer[error]
}
func New(parent context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(parent)
return &Group{cancel: cancel}, ctx
}
func (g *Group) Go(fn func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
if err != nil {
g.once.Do(func() {
e := err
g.err.Store(&e)
g.cancel()
})
}
}()
err = fn()
}()
}
func (g *Group) Wait() error {
g.wg.Wait()
g.cancel()
if e := g.err.Load(); e != nil { return *e }
return nil
}
A goleak test confirms no goroutines remain after Wait.
Final note¶
These tasks build progressively. By the end you should be comfortable spawning, joining, cancelling, observing, and debugging goroutines in production-shape code. Keep the solutions; they form a personal cookbook.