Goroutines

Lightweight threads, the go keyword, WaitGroups, and concurrent patterns.

Intermediate 40 min read 🐹 Go

What Are Goroutines?

A goroutine is a lightweight thread managed by the Go runtime. While OS threads use 1-2MB of stack memory, goroutines start with just 2KB. You can easily run millions of goroutines on a single machine. Start one with the go keyword:

package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello from %s (%d)\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go sayHello("goroutine 1") // Starts concurrently
    go sayHello("goroutine 2") // Starts concurrently
    sayHello("main")            // Runs in main goroutine

    time.Sleep(time.Second)     // Wait for goroutines (bad practice!)
}
Output
Hello from main (0)
Hello from goroutine 1 (0)
Hello from goroutine 2 (0)
Hello from main (1)
Hello from goroutine 2 (1)
Hello from goroutine 1 (1)
...

The output is interleaved — all three functions run concurrently. The order varies between runs.

sync.WaitGroup — Waiting for Goroutines

Using time.Sleep to wait is unreliable. Use sync.WaitGroup to wait for goroutines to finish:

import "sync"

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // Increment counter before starting goroutine
        go func(id int) {
            defer wg.Done() // Decrement when goroutine finishes
            fmt.Printf("Worker %d done\n", id)
        }(i) // Pass i as argument to avoid closure bug!
    }

    wg.Wait() // Block until counter reaches 0
    fmt.Println("All workers done")
}
Key Takeaway: Always use sync.WaitGroup or channels to synchronize goroutines. Never use time.Sleep. Call wg.Add() before launching the goroutine, and defer wg.Done() inside it.

sync.Mutex — Protecting Shared State

When multiple goroutines access shared data, you need synchronization to prevent race conditions:

type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

⚠️ Common Mistake: Loop variable capture

// WRONG: all goroutines share the same i
for i := 0; i < 5; i++ {
    go func() { fmt.Println(i) }() // Prints 5,5,5,5,5
}

// CORRECT: pass i as parameter
for i := 0; i < 5; i++ {
    go func(id int) { fmt.Println(id) }(i) // Prints 0,1,2,3,4
}
Goroutine Lifecycle
go func()
create
Runnable
in queue
Running
on CPU
Blocked
I/O or chan
Done
returned
Goroutine vs Thread
Goroutine
2KB stack, Go scheduler
OS Thread
1MB stack, OS scheduler
1M goroutines
No problem
1M threads
System crash

Practice Exercises

Medium Build a Mini Project

Combine concepts from this tutorial to build a small utility or tool.

Medium Debug Challenge

Introduce a bug in one of the code examples and practice finding and fixing it.

Hard Refactoring Exercise

Rewrite one example using a different approach and compare the tradeoffs.