Background pattern
New

A Simple Guide to Goroutines and Channels in Go

Goroutines and channels are Go's powerful concurrency primitives that make writing concurrent programs elegant, efficient, and safe. This guide explains how to use them effectively.

golangconcurrencygoroutineschannelsparallelism

Concurrency is one of Go's standout features, and goroutines with channels are the tools that make concurrent programming in Go both powerful and approachable. Unlike threads in many other languages, goroutines are lightweight, managed by the Go runtime, and make it easy to run functions concurrently.

NOTE: This post assumes you have a basic understanding of Go and programming concepts. We'll explore both goroutines and channels, and see how they work together to build concurrent programs.

## What Are Goroutines?

A goroutine is a lightweight thread of execution managed by the Go runtime. They're incredibly cheap to create—you can run thousands or even millions of goroutines without significant overhead.

### Creating a Goroutine

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

basic-goroutine.go

import (
	"fmt"
	"time"
)

func sayHello(name string) {
	fmt.Printf("Hello, %s!\n", name)
}

func main() {
	// Regular function call (runs synchronously)
	sayHello("Alice")

	// Goroutine call (runs concurrently)
	go sayHello("Bob")

	// Give the goroutine time to finish
	time.Sleep(time.Millisecond)
}

### Anonymous Functions with Goroutines

You can also start goroutines with anonymous functions:

anonymous-goroutine.go

import (
	"fmt"
	"time"
)

func main() {
	go func(msg string) {
		fmt.Println(msg)
	}("Hello from goroutine!")

	// Wait for goroutine to finish
	time.Sleep(time.Millisecond)
}

### Multiple Goroutines

Let's see multiple goroutines running concurrently:

multiple-goroutines.go

import (
	"fmt"
	"time"
)

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

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

	// Start 3 workers
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

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

	// Collect results
	for a := 1; a <= 5; a++ {
		<-results
	}
}

## What Are Channels?

Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values in another goroutine.

### Creating and Using Channels

basic-channel.go

import "fmt"

func main() {
	// Create a channel
	messages := make(chan string)

	// Send a value in a goroutine
	go func() {
		messages <- "ping"
	}()

	// Receive the value
	msg := <-messages
	fmt.Println(msg)
}

### Channel Directions

You can specify channel direction to make your code clearer:

channel-directions.go

import "fmt"

// Only sends to channel
func sender(ch chan<- string) {
	ch <- "Hello"
	close(ch)
}

// Only receives from channel
func receiver(ch <-chan string) {
	for msg := range ch {
		fmt.Println(msg)
	}
}

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

	go sender(ch)
	receiver(ch)
}

### Buffered Channels

By default, channels are unbuffered, meaning they only accept sends when there's a corresponding receive. Buffered channels accept a limited number of values without a receiver:

buffered-channels.go

import "fmt"

func main() {
	// Create a buffered channel with capacity 2
	ch := make(chan string, 2)

	// Sends won't block because channel is buffered
	ch <- "first"
	ch <- "second"

	// Receive values
	fmt.Println(<-ch) // "first"
	fmt.Println(<-ch) // "second"
}

## Synchronization with Channels

Channels are excellent for synchronizing goroutines:

synchronization.go

import (
	"fmt"
	"time"
)

func worker(done chan bool) {
	fmt.Print("Working...")
	time.Sleep(time.Second)
	fmt.Println("done")

	// Signal that work is done
	done <- true
}

func main() {
	done := make(chan bool)

	go worker(done)

	// Wait for worker to finish
	<-done
}

## Select Statement

The select statement lets a goroutine wait on multiple communication operations:

select-statement.go

import (
	"fmt"
	"time"
)

func main() {
	c1 := make(chan string)
	c2 := make(chan string)

	go func() {
		time.Sleep(time.Second)
		c1 <- "one"
	}()

	go func() {
		time.Sleep(2 * time.Second)
		c2 <- "two"
	}()

	// Wait for both values, handling whichever arrives first
	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-c1:
			fmt.Println("Received:", msg1)
		case msg2 := <-c2:
			fmt.Println("Received:", msg2)
		}
	}
}

### Select with Timeout

select-timeout.go

import (
	"fmt"
	"time"
)

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

	go func() {
		time.Sleep(2 * time.Second)
		c1 <- "result"
	}()

	select {
	case res := <-c1:
		fmt.Println(res)
	case <-time.After(time.Second):
		fmt.Println("Timeout!")
	}
}

## Real-World Examples

### Concurrent Web Scraping

web-scraper.go

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

func fetchURL(url string, wg *sync.WaitGroup) {
	defer wg.Done()

	start := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("Error fetching %s: %v\n", url, err)
		return
	}
	defer resp.Body.Close()

	fmt.Printf("Fetched %s (status: %d) in %v\n",
		url, resp.StatusCode, time.Since(start))
}

func main() {
	urls := []string{
		"https://go.dev",
		"https://github.com",
		"https://golang.org",
	}

	var wg sync.WaitGroup

	for _, url := range urls {
		wg.Add(1)
		go fetchURL(url, &wg)
	}

	wg.Wait()
	fmt.Println("All URLs fetched!")
}

### Worker Pool Pattern

worker-pool.go

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

type Job struct {
	ID        int
	Payload   string
	Processed bool
}

type Result struct {
	JobID  int
	Output string
	Error  error
}

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()

	for job := range jobs {
		fmt.Printf("Worker %d processing job %d\n", id, job.ID)

		// Simulate processing
		time.Sleep(time.Millisecond * 100)

		results <- Result{
			JobID:  job.ID,
			Output: fmt.Sprintf("Processed job %d", job.ID),
			Error:  nil,
		}
	}
}

func main() {
	numJobs := 10
	numWorkers := 3

	jobs := make(chan Job, numJobs)
	results := make(chan Result, numJobs)

	var wg sync.WaitGroup

	// Start workers
	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	// Send jobs
	for j := 1; j <= numJobs; j++ {
		jobs <- Job{
			ID:      j,
			Payload: fmt.Sprintf("Job payload %d", j),
		}
	}
	close(jobs)

	// Wait for workers to finish
	go func() {
		wg.Wait()
		close(results)
	}()

	// Collect results
	for result := range results {
		if result.Error != nil {
			fmt.Printf("Job %d failed: %v\n", result.JobID, result.Error)
		} else {
			fmt.Printf("Job %d succeeded: %s\n", result.JobID, result.Output)
		}
	}
}

### Pipeline Pattern

pipeline.go

import "fmt"

// First stage: generate numbers
func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// Second stage: square numbers
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

// Third stage: multiply by 2
func multiplyByTwo(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * 2
		}
		close(out)
	}()
	return out
}

func main() {
	// Set up the pipeline
	numbers := generate(1, 2, 3, 4, 5)
	squared := square(numbers)
	doubled := multiplyByTwo(squared)

	// Consume the output
	for result := range doubled {
		fmt.Println(result) // 2, 8, 18, 32, 50
	}
}

### Fan-Out/Fan-In Pattern

fanout-fanin.go

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

func producer(id int, out chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 3; i++ {
		out <- id*10 + i
	}
}

func consumer(in <-chan int, done chan<- bool) {
	for value := range in {
		fmt.Printf("Consumed: %d\n", value)
		time.Sleep(time.Millisecond * 50)
	}
	done <- true
}

func main() {
	// Fan-out: distribute work to multiple goroutines
	source := make(chan int)
	var wg sync.WaitGroup

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

	// Close source channel when producers finish
	go func() {
		wg.Wait()
		close(source)
	}()

	// Fan-in: collect results from multiple goroutines
	done := make(chan bool)
	go consumer(source, done)

	<-done
	fmt.Println("All done!")
}

## Context for Cancellation

Go's context package is essential for managing goroutine lifecycles:

context-cancellation.go

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

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("Worker %d: shutting down\n", id)
			return
		default:
			fmt.Printf("Worker %d: working\n", id)
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	// Start workers
	for i := 1; i <= 3; i++ {
		go worker(ctx, i)
	}

	// Let them work for a bit
	time.Sleep(time.Second * 2)

	// Cancel all goroutines
	fmt.Println("Cancellation signal sent!")
	cancel()

	// Wait for clean shutdown
	time.Sleep(time.Second)
}

### Context with Timeout

context-timeout.go

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

func task(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Task cancelled:", ctx.Err())
			return
		default:
			fmt.Println("Task working...")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	// Create context with 2 second timeout
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	go task(ctx)

	// Wait for timeout
	time.Sleep(3 * time.Second)
}

## Best Practices

### 1. Always Close Channels

The sender should close the channel when done:

close-channel.go

import "fmt"

func producer(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch) // Always close when done
}

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

	go producer(ch)

	// Range automatically stops when channel is closed
	for value := range ch {
		fmt.Println(value)
	}
}

### 2. Never Close from Receiver

Only the sender should close the channel, never the receiver:

wrong-close.go
func consumer(ch <-chan int) {
	// Error: cannot close receive-only channel
	close(ch)
}

### 3. Use WaitGroups for Multiple Goroutines

waitgroup.go

import (
	"fmt"
	"sync"
)

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

func main() {
	var wg sync.WaitGroup

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

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

### 4. Avoid Goroutine Leaks

Always ensure goroutines can exit:

goroutine-leak.go
func bad() {
	ch := make(chan int)
	go func() {
		value := <-ch // Will block forever if nothing is sent
		fmt.Println(value)
	}()
	// Function returns, goroutine blocked forever
}

// GOOD: Goroutine can exit
func good() {
	ch := make(chan int)
	go func() {
		value, ok := <-ch
		if !ok {
			return // Channel closed, exit
		}
		fmt.Println(value)
	}()
	close(ch) // Signal goroutine to exit
}

## Common Pitfalls

### 1. Data Races

data-race.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // Data race!
		}()
	}

	wg.Wait()
	fmt.Println(counter) // Unpredictable result
}

Fix with mutex:

data-race-fix.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup
	var mu sync.Mutex

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println(counter) // Correct result: 1000
}

### 2. Forgetting to Wait

forgotten-wait.go
func bad() {
	go func() {
		fmt.Println("Goroutine running")
		time.Sleep(time.Second)
		fmt.Println("Goroutine done")
	}()
	// Program exits immediately
}

// GOOD: Wait for goroutine
func good() {
	done := make(chan bool)
	go func() {
		fmt.Println("Goroutine running")
		time.Sleep(time.Second)
		fmt.Println("Goroutine done")
		done <- true
	}()
	<-done // Wait for signal
}

## Conclusion

Goroutines and channels are Go's answer to concurrency, making it easy to write concurrent programs that are both efficient and correct. Remember:

  • Goroutines are lightweight threads for concurrent execution
  • Channels are for communication and synchronization between goroutines
  • Use select for waiting on multiple operations
  • Always close channels from the sender side
  • Use context for managing goroutine lifecycles
  • Be careful of data races and goroutine leaks

With these tools and patterns, you can build highly concurrent applications that are both performant and maintainable.

Published on January 19, 2026

10 min read

Found an Issue!

Find an issue with this post? Think you could clarify, update or add something? All my posts are available to edit on Github. Any fix, little or small, is appreciated!

Edit on GitHub

Last updated on