Concurrency is one of Go’s biggest strengths. The language was designed from the ground up to make concurrent programming simpler and safer than in most other languages.
In this post we will explore the three core building blocks of concurrency in Go:
- Goroutines
- Channels
- The
selectstatement
By the end you will understand not only how they work, but when and how to use them effectively in real backend systems.
1. Goroutines — Lightweight Concurrent Functions
A goroutine is a lightweight thread managed by the Go runtime. Starting a new goroutine is as simple as putting the go keyword in front of a function call:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from a goroutine!")
}
func main() {
go sayHello() // starts a new goroutine
time.Sleep(100 * time.Millisecond) // give the goroutine time to run
fmt.Println("Hello from main")
}
Key characteristics of goroutines
- Extremely cheap to create (you can easily have tens of thousands of them).
- Scheduled by the Go runtime, not by the operating system.
- They share the same address space (be careful with shared memory!).
Important: When the
mainfunction returns, the entire program exits — even if other goroutines are still running. That is why we often usesync.WaitGroup, channels, orcontextto coordinate completion.
2. Channels — Safe Communication Between Goroutines
Go follows the philosophy:
Do not communicate by sharing memory; instead, share memory by communicating.
Channels are the primary way goroutines talk to each other.
Creating a channel
// Unbuffered channel
ch := make(chan int)
// Buffered channel (capacity 5)
buffered := make(chan string, 5)
Sending and receiving
ch <- 42 // send value to channel
value := <-ch // receive value from channel
Unbuffered vs Buffered channels
| Type | Behavior | Use when… |
|---|---|---|
| Unbuffered | Sender blocks until receiver is ready (and vice-versa) | You want strong synchronization |
| Buffered | Sender blocks only when the buffer is full | You want some decoupling / throughput |
Closing a channel
close(ch)
Receiving from a closed channel is safe and returns the zero value. You can also check if the channel is closed:
value, ok := <-ch
if !ok {
fmt.Println("Channel is closed")
}
3. The select Statement — Multiplexing Channels
select lets a goroutine wait on multiple channel operations at the same time. It is similar to a switch, but for channels:
select {
case msg := <-ch1:
fmt.Println("Received from ch1:", msg)
case ch2 <- 42:
fmt.Println("Sent to ch2")
case <-time.After(1 * time.Second):
fmt.Println("Timeout")
default:
fmt.Println("No communication ready")
}
Common patterns with select
Timeouts
select {
case result := <-work:
fmt.Println(result)
case <-time.After(2 * time.Second):
fmt.Println("operation timed out")
}
Non-blocking operations
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("no message available")
}
4. Practical Patterns You Should Know
Worker Pool
One of the most useful patterns in backend services:
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("worker %d processing job %d\n", id, job)
time.Sleep(time.Second) // simulate work
results <- job * 2
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// collect results
for a := 1; a <= numJobs; a++ {
fmt.Println("result:", <-results)
}
}
Fan-out / Fan-in
Useful when you want to process data in parallel and then combine the results.
5. Best Practices & Common Pitfalls
Do
- Prefer channels over shared memory + mutexes when possible.
- Always think about who is responsible for closing a channel (usually the sender).
- Use
context.Contextfor cancellation and timeouts in real services. - Use
sync.WaitGroupwhen you just need to wait for a group of goroutines to finish.
Don’t
- Never close a channel from the receiver side.
- Avoid leaking goroutines (always have a way to stop them).
- Don’t use buffered channels as a way to “fire and forget” without understanding the consequences.
- Be careful with unbounded goroutine creation under load.
6. When Should You Reach for Concurrency?
Concurrency is powerful, but it is not free. Ask yourself:
- Am I waiting on I/O (network, disk, database)?
- Can independent pieces of work run at the same time?
- Will the added complexity be worth the performance gain?
If the answer is yes, goroutines + channels are usually the right tool.
Conclusion
Goroutines, channels, and select form the foundation of concurrent programming in Go. Mastering them will make you a much more effective backend engineer — especially when building services that need to handle many concurrent requests, background jobs, or real-time workloads.
In future posts we will explore:
- The powerful
contextpackage - Graceful shutdown patterns
- Advanced concurrency patterns and common concurrency bugs
Until then, try rewriting a small sequential program using goroutines and channels. The best way to learn concurrency is by writing concurrent code.
Happy coding!