3 min read

Go Concurrency — Goroutines and Channels Explained

Concurrency is one of Go's defining features. Its approach — goroutines and channels — is both simpler and more powerful than traditional threading models. This article explains how they work and why they matter.

Key sources: "Concurrency in Go" by Katherine Cox-Buday, Go blog (go.dev/blog), Effective Go, and the Go specification.


Concurrency vs Parallelism

These terms are often used interchangeably but they describe different things.

Concurrency is about structuring a program to handle multiple tasks independently. Tasks make progress by switching between them. A single CPU core can handle concurrency.

Parallelism is about executing multiple tasks simultaneously. This requires multiple CPU cores.

Concurrency enables parallelism, but they are not the same. Go's concurrency model lets you write concurrent programs that automatically become parallel when running on multi-core hardware.


Goroutines: Lightweight Threads

A goroutine is a function that runs concurrently with other functions. It is created with the go keyword:

func main() {
    go sayHello()
    time.Sleep(100 * time.Millisecond)
}

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

Unlike OS threads, goroutines are extremely lightweight:

  • OS threads require approximately 1 MB of stack space
  • Goroutines start with approximately 2 KB of stack space
  • A typical machine can run thousands of OS threads but millions of goroutines

Goroutines are multiplexed onto OS threads by the Go runtime scheduler. When a goroutine blocks (waiting for I/O or a channel operation), the scheduler automatically moves other goroutines onto the available thread. This is more efficient than OS thread scheduling because it avoids expensive context switches.


Channels: Communicating Between Goroutines

Channels are typed conduits through which goroutines send and receive values. They solve two problems: data transfer and synchronization.

ch := make(chan int)

// Send
go func() {
    ch <- 42
}()

// Receive
value := <-ch
fmt.Println(value) // 42

Unbuffered Channels

An unbuffered channel (make(chan int)) has zero capacity. Every send blocks until a matching receive is ready. Every receive blocks until a matching send is ready. This creates a strong synchronization point: both goroutines must be ready for the communication to happen.

ch := make(chan string)
go func() {
    // This blocks until main is ready to receive
    ch <- "done"
}()
// This blocks until the goroutine sends
msg := <-ch
fmt.Println(msg)

Buffered Channels

A buffered channel (make(chan int, 3)) has capacity. Sends block only when the buffer is full. Receives block only when the buffer is empty.

ch := make(chan int, 3)
ch <- 1 // Does not block (buffer has space)
ch <- 2 // Does not block
ch <- 3 // Does not block
// ch <- 4 // This would block — buffer is full

Buffered channels decouple sender and receiver. The sender can produce values ahead of the consumer. This is useful for pipeline patterns.


The Select Statement

The select statement lets a goroutine wait on multiple channel operations simultaneously. It blocks until one of its cases can proceed:

select {
case msg1 := <-ch1:
    fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
    fmt.Println("Received from ch2:", msg2)
case <-time.After(1 * time.Second):
    fmt.Println("Timeout — no response within 1 second")
}

This enables patterns like: - Timeouts on channel operations - Non-blocking sends and receives (using default) - Multiplexing multiple data sources


Common Patterns

Worker Pool

A fixed number of goroutines process jobs from a shared channel. This controls concurrency and prevents resource exhaustion.

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

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

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

Fan-In

Multiple goroutines produce results into a single channel. The consumer reads from one place.

Fan-Out

A single goroutine distributes work across multiple channels. Workers consume in parallel.

Pipeline

Output from one goroutine feeds into the next. Each stage transforms data.

// Stage 1: Generate numbers
numbers := make(chan int)
go func() {
    for i := 1; i <= 10; i++ {
        numbers <- i
    }
    close(numbers)
}()

// Stage 2: Square numbers
squares := make(chan int)
go func() {
    for n := range numbers {
        squares <- n * n
    }
    close(squares)
}()

// Stage 3: Print results
for s := range squares {
    fmt.Println(s)
}

Key Takeaways

  1. Goroutines are lightweight concurrent functions managed by the Go runtime, not the OS.
  2. Channels provide safe communication and synchronization between goroutines.
  3. Unbuffered channels synchronize sender and receiver. Buffered channels decouple them.
  4. Select enables timeout, multiplexing, and non-blocking operations.
  5. Use worker pools to limit concurrent resource usage.

Design principle in Go: Do not communicate by sharing memory. Share memory by communicating.