Learn Go
intermediate1 min read

Goroutines

A goroutine is a lightweight, concurrently executing function managed by the Go runtime. Goroutines are far cheaper than OS threads — you can run hundreds of thousands on a single machine.

Launching a Goroutine

Prefix any function call with go to run it concurrently:

package main
 
import (
    "fmt"
    "time"
)
 
func greet(name string) {
    fmt.Println("Hello,", name)
}
 
func main() {
    go greet("Alice")
    go greet("Bob")
    go greet("Charlie")
 
    // Without this sleep, main may exit before the goroutines run
    time.Sleep(10 * time.Millisecond)
    fmt.Println("done")
}

Idiomatic Go: Do not use time.Sleep to wait for goroutines in real code — it is a race condition. Use sync.WaitGroup or channels instead.

sync.WaitGroup

WaitGroup coordinates the completion of a known number of goroutines:

package main
 
import (
    "fmt"
    "sync"
)
 
func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // signal completion when this function returns
    fmt.Printf("worker %d starting\n", id)
    // simulate work
    fmt.Printf("worker %d done\n", id)
}
 
func main() {
    var wg sync.WaitGroup
 
    for i := 1; i <= 5; i++ {
        wg.Add(1) // increment counter before launching goroutine
        go worker(i, &wg)
    }
 
    wg.Wait() // block until counter reaches zero
    fmt.Println("all workers finished")
}

The Add(n) call must happen before the goroutine is launched (or at least before Wait is called). Done() decrements the counter; Wait() blocks until it reaches zero.

Goroutines and Closures

Goroutines capture variables by reference. A common mistake in loops:

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var wg sync.WaitGroup
 
    // Bug: all goroutines may print the same value of i
    // because i is shared by reference
    // for i := 0; i < 5; i++ {
    //     wg.Add(1)
    //     go func() { defer wg.Done(); fmt.Println(i) }()
    // }
 
    // Fix: pass i as an argument to capture its value
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n)
        }(i)
    }
 
    wg.Wait()
}

The Go Scheduler

Go uses an M:N scheduler — it multiplexes M goroutines onto N OS threads. The GOMAXPROCS environment variable (default: number of CPU cores) controls how many OS threads execute goroutines in parallel. You rarely need to change it.

package main
 
import (
    "fmt"
    "runtime"
)
 
func main() {
    fmt.Println("CPUs:", runtime.NumCPU())
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
    fmt.Println("Active goroutines:", runtime.NumGoroutine())
}

Goroutine Leaks

A goroutine that is blocked forever (waiting on a channel that will never receive) is a goroutine leak. Always ensure goroutines can exit:

package main
 
import (
    "context"
    "fmt"
    "sync"
)
 
func process(ctx context.Context, wg *sync.WaitGroup, items <-chan int) {
    defer wg.Done()
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker cancelled")
            return
        case item, ok := <-items:
            if !ok {
                return
            }
            fmt.Println("processed:", item)
        }
    }
}
 
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
 
    items := make(chan int, 5)
    var wg sync.WaitGroup
 
    wg.Add(1)
    go process(ctx, &wg, items)
 
    for i := 1; i <= 3; i++ {
        items <- i
    }
    close(items)
    wg.Wait()
}

Key Takeaways