6.2 Goroutines

Goroutines are Go's fundamental unit of concurrency. They're lightweight threads managed by the Go runtime that enable you to write concurrent programs easily and efficiently.

Creating Goroutines

Basic syntax

Creating a goroutine is simple: just prefix a function call with the go keyword:

go functionName(arguments)

The go keyword is part of Go's language syntax, not a library call. This design decision makes concurrency a first-class citizen in Go. You don't need to import threading libraries, create thread objects, manage thread pools, or configure thread parameters.

When you write go functionName(), the Go runtime creates a new goroutine, allocates a small stack (starting at 2 KB), schedules the goroutine to run, and returns immediately without waiting for the function to complete. This immediate return allows the calling code to continue executing while the goroutine runs concurrently.

Basic example

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() {
    // Launch goroutine - execution continues immediately
    go sayHello("Goroutine 1")
    go sayHello("Goroutine 2")

    // Main goroutine continues executing
    fmt.Println("Main function continues...")

    // Give goroutines time to execute
    // (In real code, use channels or sync.WaitGroup instead)
    time.Sleep(1 * time.Second)
    fmt.Println("Main function ends")
}

Output (order may vary)

Main function continues...
Hello from Goroutine 1: 0
Hello from Goroutine 2: 0
Hello from Goroutine 1: 1
Hello from Goroutine 2: 1
Hello from Goroutine 1: 2
Hello from Goroutine 2: 2
Main function ends

Anonymous function goroutines

You can also launch goroutines with anonymous functions:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Launch anonymous function as goroutine
    go func(name string) {
        for i := 0; i < 3; i++ {
            fmt.Printf("Hello from %s: %d\n", name, i)
            time.Sleep(100 * time.Millisecond)
        }
    }("Anonymous Goroutine")

    time.Sleep(500 * time.Millisecond)
    fmt.Println("Main function")
}

Anonymous functions are convenient when the function is only used once, when you need to capture variables from the surrounding scope, or when you want to keep related code together.

Capturing variables in closures

package main

import (
    "fmt"
    "time"
)

func main() {
    for i := 0; i < 3; i++ {
        // Each goroutine captures its own copy of i
        go func(id int) {
            fmt.Printf("Goroutine %d started\n", id)
            time.Sleep(100 * time.Millisecond)
            fmt.Printf("Goroutine %d finished\n", id)
        }(i)  // Pass i as argument to capture current value
    }

    time.Sleep(500 * time.Millisecond)
}

Note (Go 1.23) In Go 1.23, loop variables in for loops have improved scoping behavior. However, when launching goroutines in loops, it's still best practice to pass loop variables as function arguments to ensure each goroutine gets its own copy.

Goroutine Lifecycle

Understanding the goroutine lifecycle helps you write correct concurrent programs.

Lifecycle stages

  1. Creation: Goroutine is created with go keyword
  2. Runnable: Goroutine is ready to execute (waiting for CPU time)
  3. Running: Goroutine is actively executing
  4. Blocked: Goroutine is waiting (I/O, channel operation, etc.)
  5. Termination: Goroutine completes execution

The lifecycle follows this pattern:

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // Signal completion when function returns

    fmt.Printf("Worker %d: Starting\n", id)

    // Simulate work
    time.Sleep(time.Duration(id) * 100 * time.Millisecond)

    fmt.Printf("Worker %d: Finished\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)  // Increment counter before starting goroutine
        go worker(i, &wg)
    }

    fmt.Println("Main: Waiting for workers to finish...")
    wg.Wait()  // Block until counter reaches zero
    fmt.Println("Main: All workers finished")
}

The main goroutine doesn't wait for other goroutines by default. If the main goroutine exits, the program terminates, even if other goroutines are still running. sync.WaitGroup provides a way to wait for goroutines to complete.

WaitGroup works through three methods: Add(n) increments the internal counter by n, Done() decrements the counter by 1 (typically called with defer), and Wait() blocks until the counter reaches zero.

Goroutine termination

A goroutine terminates when:

Example showing termination

package main

import (
    "fmt"
    "time"
)

func shortTask() {
    fmt.Println("Short task: Starting")
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Short task: Done")
}

func longTask() {
    fmt.Println("Long task: Starting")
    time.Sleep(2 * time.Second)
    fmt.Println("Long task: Done")  // May not execute if main exits
}

func main() {
    go shortTask()
    go longTask()

    // Main exits after 500ms
    time.Sleep(500 * time.Millisecond)
    fmt.Println("Main: Exiting (program will terminate)")
    // longTask may not complete
}

Goroutine Scheduling

Go's runtime includes a sophisticated scheduler that manages goroutine execution efficiently.

Operating systems schedule threads, but goroutines are lighter than threads. Go's scheduler manages thousands or millions of goroutines efficiently, minimizes context switching overhead, and automatically utilizes multiple CPU cores.

Go uses an M:N scheduler (also called a "work-stealing scheduler") where M goroutines are multiplexed onto N OS threads. The scheduler uses three key components: G (Goroutine) represents a goroutine, M (Machine) represents an OS thread, and P (Processor) represents a logical processor (typically one per CPU core).

The scheduling model

CPU Core 1          CPU Core 2          CPU Core 3
    |                   |                   |
   M1                  M2                  M3  (OS Threads)
    |                   |                   |
   P1                  P2                  P3  (Processors)
    |                   |                   |
  G1 G2 G3            G4 G5 G6            G7 G8 G9  (Goroutines)

Goroutines are scheduled as follows:

  1. Local run queue: Each P maintains a local queue of runnable goroutines
  2. Global run queue: A global queue for goroutines that don't have a P assigned
  3. Work stealing: If a P's local queue is empty, it can "steal" work from other P's queues
  4. Preemption: The scheduler can preempt long-running goroutines to ensure fairness

When scheduling happens

The scheduler switches goroutines when:

Example demonstrating scheduling

package main

import (
    "fmt"
    "runtime"
    "time"
)

func cpuIntensiveTask(id int) {
    sum := 0
    for i := 0; i < 1000000; i++ {
        sum += i
    }
    fmt.Printf("Task %d: Completed (sum: %d)\n", id, sum)
}

func ioTask(id int) {
    fmt.Printf("IO Task %d: Starting\n", id)
    time.Sleep(100 * time.Millisecond)  // Simulates I/O
    fmt.Printf("IO Task %d: Completed\n", id)
}

func main() {
    // Show number of CPU cores
    fmt.Printf("CPU cores: %d\n", runtime.NumCPU())

    // CPU-intensive tasks (may be preempted)
    for i := 0; i < 3; i++ {
        go cpuIntensiveTask(i)
    }

    // I/O tasks (will yield to scheduler)
    for i := 0; i < 3; i++ {
        go ioTask(i)
    }

    time.Sleep(1 * time.Second)
}

Explicit scheduling control

You can influence scheduling with runtime.Gosched():

package main

import (
    "fmt"
    "runtime"
)

func greedyTask(id int) {
    for i := 0; i < 5; i++ {
        fmt.Printf("Task %d: %d\n", id, i)
        runtime.Gosched()  // Yield to other goroutines
    }
}

func main() {
    go greedyTask(1)
    go greedyTask(2)

    runtime.Gosched()  // Give other goroutines a chance
    fmt.Println("Main: Done")
}

Go's scheduler is preemptive in modern versions, so you rarely need runtime.Gosched(). However, it can be useful for cooperative multitasking in tight loops, testing concurrent behavior, or ensuring fairness in specific scenarios.

Best Practices

Following best practices helps you write correct, efficient concurrent programs.

1. Always wait for goroutines to complete

The main goroutine doesn't wait for others. Use sync.WaitGroup or channels:

// ❌ BAD: Main may exit before goroutines complete
func main() {
    go doWork()
    // Program may exit here
}

// ✅ GOOD: Wait for completion
func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        doWork()
    }()
    wg.Wait()
}

2. Avoid goroutine leaks

Goroutines consume memory. If they never terminate, memory usage grows:

// ❌ BAD: Goroutine may never terminate
func leakyFunction() {
    ch := make(chan int)
    go func() {
        for {
            // This loop never exits
            select {
            case <-ch:
                // Handle message
            }
        }
    }()
    // If ch is never closed, goroutine leaks
}

// ✅ GOOD: Provide a way to exit
func goodFunction() {
    ch := make(chan int)
    done := make(chan bool)

    go func() {
        for {
            select {
            case <-ch:
                // Handle message
            case <-done:
                return  // Exit goroutine
            }
        }
    }()

    // Later: close(done) to signal exit
}

3. Don't launch goroutines in loops without care

Loop variables can cause issues (though improved in Go 1.23):

// ⚠️ CAUTION: May have issues in older Go versions
for i := 0; i < 10; i++ {
    go func() {
        fmt.Println(i)  // All may print 10
    }()
}

// ✅ GOOD: Pass variable as argument
for i := 0; i < 10; i++ {
    go func(id int) {
        fmt.Println(id)  // Each prints different value
    }(i)
}

4. Limit the number of goroutines

While goroutines are lightweight, unlimited goroutines can still cause problems:

// ❌ BAD: May create too many goroutines
func processItems(items []Item) {
    for _, item := range items {
        go processItem(item)  // Could be millions!
    }
}

// ✅ GOOD: Use worker pool pattern
func processItems(items []Item) {
    const numWorkers = 10
    jobs := make(chan Item, len(items))
    var wg sync.WaitGroup

    // Start workers
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                processItem(item)
            }
        }()
    }

    // Send jobs
    for _, item := range items {
        jobs <- item
    }
    close(jobs)

    wg.Wait()
}

5. Handle errors in goroutines

Errors in goroutines are easy to miss. Use channels or error groups:

// ✅ GOOD: Collect errors from goroutines
func processWithErrors(items []Item) error {
    errChan := make(chan error, len(items))
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            if err := processItem(it); err != nil {
                errChan <- err
            }
        }(item)
    }

    go func() {
        wg.Wait()
        close(errChan)
    }()

    // Collect errors
    var errors []error
    for err := range errChan {
        errors = append(errors, err)
    }

    if len(errors) > 0 {
        return fmt.Errorf("processing failed: %v", errors)
    }
    return nil
}

6. Use context for cancellation

Context provides a standard way to cancel goroutines:

// ✅ GOOD: Use context for cancellation
func processWithContext(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()  // Cancelled
        default:
            if err := processItem(item); err != nil {
                return err
            }
        }
    }
    return nil
}

These practices help you write robust, efficient concurrent Go programs.