Day 21a - Introduction to Goroutines: Lightweight Concurrency in Go with examples.

Venkat Annangi
Venkat Annangi
26/04/2025 00:42 8 min read 23 views
# parallelism #goroutines #108 days of golang
Day 21a - Introduction to Goroutines: Lightweight Concurrency in Go with examples.

What Are Goroutines?

Goroutines are Go's way of running multiple functions concurrently (at the same time). Think of them as extremely lightweight threads that let your program do multiple things at once without the complexity and resource overhead of traditional threading.

Real-World Analogy

Imagine a restaurant:

  • Single-threaded program: One chef doing everything (taking orders, cooking, serving, cleaning)
  • Traditional threads: Multiple chefs, each requiring their own kitchen space and tools
  • Goroutines: Many assistant chefs sharing the same kitchen efficiently, coordinated by a head chef (the Go runtime)

How Goroutines Work Under the Hood

Goroutines are built on a concept called "multiplexing":

  1. Tiny Memory Footprint: A goroutine starts with just 2KB of stack memory (compared to megabytes for OS threads)
  2. Managed by Go's Runtime: Go uses an M:N scheduling model where:
    • M = OS threads (usually matching CPU cores)
    • N = Your goroutines (can be thousands or millions)
  3. Non-blocking: When a goroutine would block (like waiting for I/O), the Go scheduler automatically switches to another goroutine

This means you can create thousands of goroutines without exhausting system resources – something impossible with traditional threads.

Your First Goroutine

Creating a goroutine is as simple as adding the go keyword before a function call:

go
 
package main

import (
    "fmt"     "time" )

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

func main() {
    // Run in background     go sayHello()
    
    // Main continues immediately     fmt.Println("Main function is running")
    
    // Give goroutine time to complete     time.Sleep(1 * time.Second)
    fmt.Println("Main function finished")
}

What's Happening Here?

  1. The go keyword starts sayHello() in a separate goroutine
  2. The main function continues executing immediately (doesn't wait)
  3. We use time.Sleep() to prevent the program from ending before the goroutine completes

Output:

 
 
Main function is running
Hello from goroutine!
Main function finished 

Note for Beginners: Without the time.Sleep(), the program might exit before the goroutine has a chance to run! This is a common mistake.

When to Use Goroutines

Goroutines shine when your program needs to:

  1. Handle multiple tasks simultaneously (like serving web requests)
  2. Keep the UI responsive while processing data
  3. Perform I/O operations (reading files, network requests) without blocking
  4. Take advantage of multiple CPU cores for computationally intensive tasks

Real-World Example: Faster Web Scraping

Let's compare sequential vs. concurrent approaches to fetching multiple websites:

go
 
package main

import (
    "fmt"     "io"     "net/http"     "strings"     "sync"     "time" )

func fetchWebsite(url string) (int, error) {
    start := time.Now()
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return 0, err
    }
    
    elapsed := time.Since(start)
    fmt.Printf("Fetched %s in %v: %d bytes\n", url, elapsed, len(body))
    return len(body), nil }

func main() {
    websites := []string{
        "https://golang.org",
        "https://google.com",
        "https://github.com",
        "https://stackoverflow.com",
    }
    
    fmt.Println("=== Sequential approach ===")
    sequentialStart := time.Now()
    totalBytes := 0     
    for _, url := range websites {
        bytes, err := fetchWebsite(url)
        if err != nil {
            fmt.Printf("Error fetching %s: %v\n", url, err)
            continue         }
        totalBytes += bytes
    }
    
    fmt.Printf("Sequential approach took %v for %d total bytes\n\n", 
               time.Since(sequentialStart), totalBytes)
    
    fmt.Println("=== Concurrent approach with goroutines ===")
    concurrentStart := time.Now()
    var wg sync.WaitGroup
    totalBytesChannel := make(chan int)
    errorChannel := make(chan string)
    
    // Start a goroutine to sum up bytes     go func() {
        concurrentTotalBytes := 0         for bytes := range totalBytesChannel {
            concurrentTotalBytes += bytes
        }
        fmt.Printf("Concurrent approach took %v for %d total bytes\n", 
                   time.Since(concurrentStart), concurrentTotalBytes)
    }()
    
    // Start a goroutine to collect errors     go func() {
        for err := range errorChannel {
            fmt.Println(err)
        }
    }()
    
    // Launch a goroutine for each website     for _, url := range websites {
        wg.Add(1)
        go func(url string) {
            defer wg.Done()
            bytes, err := fetchWebsite(url)
            if err != nil {
                errorChannel <- fmt.Sprintf("Error fetching %s: %v", url, err)
                return             }
            totalBytesChannel <- bytes
        }(url)
    }
    
    // Wait for all fetches to complete     wg.Wait()
    close(totalBytesChannel)
    close(errorChannel)
    
    // Allow time for the summary goroutines to finish     time.Sleep(100 * time.Millisecond)
}

The concurrent version is typically 3-4x faster because it fetches all websites simultaneously instead of one after another.

Visualizing Goroutines vs Sequential Execution

Sequential Execution

 
 
Time →
Task 1 [===========] Task 2              [===========] Task 3                           [===========] Total time: Sum of all tasks

Concurrent Execution with Goroutines

 
 
Time →
Task 1 [===========] Task 2 [===========] Task 3 [===========] Total time: Approximately the longest task

Common Pitfalls for Beginners

1. Forgetting Main Function Doesn't Wait

The program below will likely print nothing because the main function exits before the goroutine has a chance to run:

go
 
package main

import "fmt" 
func printMessage() {
    fmt.Println("This might never be seen!")
}

func main() {
    go printMessage()
    // Program exits immediately! }

Solution: Use synchronization mechanisms like WaitGroup:

go
 
package main

import (
    "fmt"     "sync" )

func printMessage(wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("This will definitely be seen!")
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go printMessage(&wg)
    wg.Wait() // Wait for goroutine to finish }

2. Closure Variables in Loops

This is a classic mistake:

go
 
func main() {
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i) // Most likely prints "5" five times!         }()
    }
    time.Sleep(time.Second)
}

Solution: Pass variables as parameters or create local copies:

go
 
func main() {
    for i := 0; i < 5; i++ {
        go func(num int) {
            fmt.Println(num) // Prints 0, 1, 2, 3, 4 (in some order)         }(i)
    }
    time.Sleep(time.Second)
}

3. Race Conditions with Shared Data

This code has a race condition:

go
 
var counter int 
func main() {
    for i := 0; i < 1000; i++ {
        go func() {
            counter++ // DANGER: Concurrent writes!         }()
    }
    time.Sleep(time.Second)
    fmt.Println(counter) // Unpredictable result! }

Solution: Use mutexes or channels for synchronization:

go
 
var counter int var mu sync.Mutex

func main() {
    for i := 0; i < 1000; i++ {
        go func() {
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }
    time.Sleep(time.Second)
    fmt.Println(counter) // Reliably shows 1000 }

Communication Between Goroutines: Introducing Channels

While goroutines run code concurrently, they often need to communicate results or coordinate actions. Go provides channels for this purpose:

go
 
func main() {
    // Create a channel for integers     resultsChannel := make(chan int)
    
    // Start a goroutine that sends a value     go func() {
        time.Sleep(2 * time.Second) // Simulate work         resultsChannel <- 42 // Send value to channel     }()
    
    fmt.Println("Waiting for result...")
    result := <-resultsChannel // Receive from channel (blocks until data arrives)     fmt.Println("Got result:", result)
}

We'll explore channels in depth in tomorrow's article.

Best Practices

  1. Start Simple: Don't add concurrency prematurely - it adds complexity
  2. Use WaitGroups: To properly wait for goroutines to complete
  3. Avoid Shared State: Use channels for communication when possible
  4. Handle Errors: Propagate errors from goroutines back to the main function
  5. Limit Goroutines: Consider using worker pools for resource-intensive tasks

Conclusion

Goroutines are the foundation of Go's concurrency model - incredibly lightweight threads that make concurrent programming accessible. They allow you to write code that efficiently utilizes your hardware while maintaining readability.

In tomorrow's article, we'll dive deeper into channels - Go's mechanism for communication between goroutines.

Exercises for Practice

  1. Modify the "Hello from goroutine" example to print messages from 5 different goroutines
  2. Write a program that calculates the sum of numbers from 1 to 1,000,000 using multiple goroutines
  3. Create a simple parallel file processing program that counts words in multiple text files concurrently
  4. Implement a concurrent version of a prime number finder

Tags: Golang, concurrency, goroutines, tutorial, Go programming, performance optimization, parallelism

Comments