Go Concurrency: Goroutines and Channels Explained
Concurrency is one of Go’s defining features. The language was designed from the ground up to make concurrent programming accessible, safe, and efficient. Unlike languages where concurrency is bolted on through libraries or platform threads, Go provides first-class primitives — goroutines and channels — that are built into the language and runtime.
This guide covers everything you need to write concurrent Go programs: goroutines, channels, the select statement, synchronization primitives, and common patterns like worker pools and fan-in/fan-out.
Goroutines
A goroutine is a lightweight thread managed by the Go runtime. Goroutines are multiplexed onto OS threads — thousands of goroutines can run in a single process with minimal overhead. Each goroutine starts with a tiny stack (a few KB) that grows and shrinks as needed.
Launch a goroutine by prefixing a function call with the go keyword:
package main
import (
"fmt"
"time"
)
func printNumbers(label string) {
for i := 1; i <= 5; i++ {
fmt.Printf("%s: %d\n", label, i)
time.Sleep(100 * time.Millisecond)
}
---
func main() {
go printNumbers("A")
go printNumbers("B")
// Give goroutines time to finish
time.Sleep(1 * time.Second)
---The go statement immediately returns — the program continues without waiting for the function to complete. Both goroutines run concurrently, interleaving their output.
Anonymous Goroutines
You can launch an anonymous function as a goroutine:
go func() {
fmt.Println("Running in a goroutine")
---()
// With parameters (evaluated immediately)
for _, url := range urls {
go func(u string) {
fetch(u)
}(url)
---Always pass loop variables as arguments to goroutine closures. Otherwise, all goroutines capture the same variable reference, which changes as the loop progresses.
Channels
Channels are typed conduits that let goroutines communicate by sending and receiving values. The channel operator is <-:
ch := make(chan int) // unbuffered channel of integers
// Send (in a goroutine)
go func() {
ch <- 42
---()
// Receive
value := <-ch
fmt.Println(value) // 42By default, sends and receives block until both the sender and receiver are ready. This synchronizing behavior makes channels safe without explicit locks.
Unbuffered vs Buffered Channels
Unbuffered channels (make(chan T)) synchronize the sender and receiver. A send blocks until another goroutine receives:
ch := make(chan string)
go func() {
ch <- "ping" // blocks until main goroutine receives
---()
msg := <-ch // receives, unblocking the sender
fmt.Println(msg)Buffered channels (make(chan T, capacity)) accept a limited number of values without a corresponding receiver:
ch := make(chan string, 3)
ch <- "a" // does not block
ch <- "b" // does not block
ch <- "c" // does not block
// ch <- "d" // would block — buffer full
fmt.Println(<-ch) // "a"
fmt.Println(<-ch) // "b"
fmt.Println(<-ch) // "c"Buffered channels decouple the sender and receiver temporarily. They are useful for bursty workloads or when you know the production rate and consumption rate differ.
Closing Channels
The sender closes a channel to signal that no more values will be sent:
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
---()
for value := range ch {
fmt.Println(value) // 0, 1, 2, 3, 4
---The range loop receives until the channel is closed. Only the sender should close a channel — closing on the receiver side causes a panic. You can test if a channel is closed with the two-value receive:
value, ok := <-ch
if !ok {
fmt.Println("channel closed")
---The Select Statement
select lets a goroutine wait on multiple channel operations simultaneously. It blocks until one of its cases can proceed:
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
select {
case msg1 := <-ch1:
fmt.Println("Received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Received from ch2:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Timeout")
}
---If multiple cases are ready, select picks one at random. The default case runs immediately if no other case is ready, making select non-blocking:
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("No message available")
---Timeout Pattern
Use select with time.After to implement timeouts:
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
result := make(chan string, 1)
go func() {
resp, _ := http.Get(url)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
result <- string(body)
}()
select {
case body := <-result:
return body, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout fetching %s", url)
}
---Synchronization Primitives
While channels handle many concurrency scenarios, the sync package provides lower-level primitives for specific use cases.
sync.WaitGroup
WaitGroup waits for a collection of goroutines to finish:
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Worker %d done\n", id)
}(i)
}
wg.Wait() // blocks until all 5 goroutines call Done()
fmt.Println("All workers finished")
---Always call wg.Add before launching the goroutine and defer wg.Done() inside the goroutine to ensure it is called even on panics.
sync.Mutex
Mutex provides mutual exclusion for protecting shared state:
type Counter struct {
mu sync.Mutex
value int
---
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
---
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
---For reads that outnumber writes, use sync.RWMutex to allow multiple concurrent readers:
type Cache struct {
mu sync.RWMutex
data map[string]string
---
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
---
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
---sync.Once
sync.Once ensures a function executes only once, even across multiple goroutines:
var (
config *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
config = loadConfig()
})
return config
---Worker Pool Pattern
A worker pool distributes work across a fixed number of goroutines, controlling concurrency and resource usage:
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
results <- job * 2 // process the job
}
---
func main() {
const numJobs = 10
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// Start workers
for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// Collect results
for r := 1; r <= numJobs; r++ {
<-results
}
---Fan-Out, Fan-In
Fan-out starts multiple goroutines to process input from a single channel. Fan-in combines multiple input channels into one:
func fanOut(input <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
ch := make(chan int)
channels[i] = ch
go func(out chan int) {
for val := range input {
out <- val * 2
}
close(out)
}(ch)
}
return channels
---
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for val := range c {
out <- val
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
---Common Pitfalls
Goroutine leaks — A goroutine blocked on sending to a channel that nobody reads will leak forever. Always ensure channels are properly drained or use buffered channels for one-shot signals.
Closing channels from the receiver — Only the sender should close a channel. Closing from the receiver or closing a closed channel causes a panic.
Capturing loop variables — Goroutines that reference loop variables capture the variable, not its value at iteration time. Pass the value as an argument to the goroutine.
Race conditions — Use the -race flag during testing (go test -race) to detect data races. The race detector is one of Go’s best debugging tools.
Advanced Patterns
Pipeline with Error Handling
A production pipeline needs to handle errors without leaking goroutines:
func gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
---
func sq(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
---Tee Channel
Split a channel’s output to two consumers:
func tee(in <-chan int) (<-chan int, <-chan int) {
out1 := make(chan int)
out2 := make(chan int)
go func() {
defer func() { close(out1); close(out2) }()
for v := range in {
out1 <- v
out2 <- v
}
}()
return out1, out2
---Summary
Go’s concurrency model is one of its greatest strengths. Goroutines make concurrent execution cheap and easy to start. Channels provide safe communication between goroutines with built-in synchronization. The select statement enables waiting on multiple channel operations with timeouts. sync.WaitGroup, sync.Mutex, and sync.Once handle the remaining synchronization needs. Together, these primitives let you build highly concurrent programs that are easy to reason about and free from the common pitfalls of thread-based concurrency.
FAQ
Q: What is the difference between a goroutine and an OS thread? A: Goroutines are user-space threads managed by the Go runtime. They start with ~4KB stacks (vs ~1MB for OS threads) and multiplex thousands onto a single OS thread.
Q: How do I choose between using a channel and a mutex? A: Use channels for communicating data and coordinating work between goroutines. Use mutexes for protecting shared state in simple read/write patterns.
Q: What causes a deadlock in Go? A: A deadlock occurs when all goroutines are blocked waiting on each other. Common causes: unbuffered channel sends without a receiver, missing channel closes, or circular waiting with mutexes.
Q: Can I use select with a nil channel?
A: Yes. A nil channel is never ready for communication, so a select case involving a nil channel is effectively disabled. This is useful for dynamically disabling cases.
Q: Should I close a channel from the sender or receiver? A: Always from the sender. Closing from the receiver causes a panic. If multiple goroutines send on the same channel, use a dedicated closer goroutine or a sync.Once.
For a comprehensive overview, read our article on Getting Started With Go.
For a comprehensive overview, read our article on Go Concurrency Guide.