← All articles
Go·August 28, 2026·8 min read

Goroutines and Channels: Go's Concurrency Model

By Eduardo Dionisio

Go's concurrency story is its defining feature. Goroutines give you lightweight concurrent execution; channels give you a safe way to pass data between them. The philosophy is summed up in one line: don't communicate by sharing memory, share memory by communicating.

Goroutines are cheap

go
go func() {
    fmt.Println("running concurrently")
}()
// the program continues immediately — it does not wait

A goroutine is a few kilobytes of stack that grows as needed, multiplexed onto OS threads by the runtime. Spawning thousands is routine. This is different from an OS thread, which is heavy and expensive.

Channels: the safe hand-off

go
ch := make(chan int)      // unbuffered: send blocks until a receive is ready
buf := make(chan int, 10) // buffered: sends block only when the buffer is full

go func() { ch <- 42 }()   // send
timeout := <-ch            // receive

An unbuffered channel synchronizes: the send blocks until a receiver is ready, and vice versa. A buffered channel decouples the two until the buffer fills. Knowing which you want is the first design decision.

select: waiting on many things at once

go
select {
case v := <-results:
    fmt.Println("got", v)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
default:
    fmt.Println("nothing ready")
}

select lets one goroutine wait on multiple channels, timeouts, and even a non-blocking default. This single construct replaces a whole class of manual synchronization bugs. Combined with context.Context for cancellation, it is how idiomatic Go programs stay correct under load.