Goroutines

Shiraz Khan's Avatar

Shiraz Khan

LinkedIn

Senior Software Engineer

1 February 2025 / 7 min read

1. Goroutines

Goroutines are the foundation of concurrency in Go. They are lightweight threads managed by the Go runtime (not the operating system!). They are highly efficient and scalable.

Key Details:

Example:

package main

import (
	"fmt"
	"time"
)

func printNumbers() {
	for i := 1; i <= 5; i++ {
		fmt.Println(i)
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	go printNumbers() // Start a goroutine
	go printNumbers() // Start another goroutine

	// Wait for goroutines to finish (naive approach)
	time.Sleep(3 * time.Second)
}

2. Channels

Channels are the primary way goroutines communicate and synchronize. They allow safe data exchange between goroutines.

Key Details:

Example:

package main

import (
	"fmt"
	"time"
)

func worker(id int, ch chan string) {
	for i := 1; i <= 3; i++ {
		ch <- fmt.Sprintf("Worker %d: Task %d", id, i)
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	ch := make(chan string)

	for i := 1; i <= 2; i++ {
		go worker(i, ch)
	}

	for i := 1; i <= 6; i++ {
		fmt.Println(<-ch)
	}
}

3. Buffered Channels

Buffered channels allow sending multiple values without an immediate receiver. They have a fixed capacity, and sending blocks only when the buffer is full.

Key Details:

Example:

package main

import "fmt"

func main() {
	ch := make(chan int, 2)

	ch <- 1
	ch <- 2

	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

4. Select Statement

The select statement allows you to wait on multiple channel operations. It is similar to a switch statement but for channels.

Key Details:

Example:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- "Hello"
	}()

	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "World"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println(msg1)
		case msg2 := <-ch2:
			fmt.Println(msg2)
		}
	}
}

5. Synchronization

WaitGroup:

A sync.WaitGroup waits for a collection of goroutines to finish. It uses a counter to track the number of active goroutines.

Example:

package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	wg.Wait()
	fmt.Println("All workers done")
}

Mutex:

A sync.Mutex ensures exclusive access to shared resources. It prevents race conditions by allowing only one goroutine to access the resource at a time.

Key Details:

Example:

package main

import (
	"fmt"
	"sync"
)

var counter int
var mutex sync.Mutex

func increment(wg *sync.WaitGroup) {
	defer wg.Done()
	mutex.Lock()
	counter++
	mutex.Unlock()
}

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go increment(&wg)
	}

	wg.Wait()
	fmt.Println("Counter:", counter)
}

6. Context

The context package is used to manage the lifecycle of goroutines, such as cancellation, timeouts, and deadlines.

Key Details:

Example:

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Worker cancelled")
			return
		default:
			fmt.Println("Working...")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	go worker(ctx)

	time.Sleep(3 * time.Second)
}

7. Patterns

Fan-In:

Combining multiple input channels into a single channel.

Example:

package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- int, start int) {
	for i := start; i < start+5; i++ {
		ch <- i
		time.Sleep(100 * time.Millisecond)
	}
}

func fanIn(ch1, ch2 <-chan int, out chan<- int) {
	for {
		select {
		case msg := <-ch1:
			out <- msg
		case msg := <-ch2:
			out <- msg
		}
	}
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	out := make(chan int)

	go producer(ch1, 1)
	go producer(ch2, 10)
	go fanIn(ch1, ch2, out)

	for i := 0; i < 10; i++ {
		fmt.Println(<-out)
	}
}

Fan-Out:

Distributing work from a single channel to multiple worker goroutines.

Example:

package main

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

func worker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		fmt.Printf("Worker %d started job %d\n", id, job)
		time.Sleep(time.Second)
		fmt.Printf("Worker %d finished job %d\n", id, job)
		results <- job * 2
	}
}

func main() {
	jobs := make(chan int, 10)
	results := make(chan int, 10)

	var wg sync.WaitGroup

	// Start 3 workers
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			worker(id, jobs, results)
		}(w)
	}

	// Send 5 jobs
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	wg.Wait()
	close(results)

	for result := range results {
		fmt.Println("Result:", result)
	}
}

8. Best Practices

1. Avoid Goroutine Leaks

Always ensure goroutines terminate properly. Use context or channels to signal when a goroutine should exit.

2. Use Buffered Channels Wisely

Buffered channels can help decouple producers and consumers, but they can also hide issues like deadlocks. Use them with care.

3. Prefer select for Multiplexing

Use select to handle multiple channels, especially when dealing with timeouts or cancellations.

4. Use Context for Cancellation

Always use context to manage the lifecycle of goroutines, especially in long-running tasks.

5. Backpressure

Backpressure is a strategy to handle situations where producers are faster than consumers. Use buffered channels or explicit signaling to control the flow of data.

Example of Backpressure:

package main

import (
	"fmt"
	"time"
)

func producer(ch chan<- int) {
	for i := 0; i < 10; i++ {
		ch <- i
		fmt.Println("Produced:", i)
	}
	close(ch)
}

func consumer(ch <-chan int, done chan<- bool) {
	for i := range ch {
		fmt.Println("Consumed:", i)
		time.Sleep(500 * time.Millisecond) // Simulate slow consumer
	}
	done <- true
}

func main() {
	ch := make(chan int, 3) // Buffered channel for backpressure
	done := make(chan bool)

	go producer(ch)
	go consumer(ch, done)

	<-done
}

9. Common Pitfalls

1. Deadlocks

Deadlocks occur when goroutines are waiting on each other indefinitely. Always ensure channels are properly closed and goroutines can exit.

2. Race Conditions

Race conditions occur when multiple goroutines access shared resources without synchronization. Use sync.Mutex or channels to protect shared resources.

3. Goroutine Leaks

Goroutine leaks happen when goroutines are started but never terminated. Always use context or channels to ensure goroutines exit.