Goroutines and channels: the foundation of concurrency in Go
1. Overview
Go is designed to write concurrent programs simply, without needing callback hell or complex thread pools. The two core building blocks are goroutines (lightweight execution units) and channels (communication channels between goroutines). After reading this article, you’ll know how to launch goroutines, synchronize them using sync.WaitGroup, communicate safely over channels, use select to handle multiple channels at once, and avoid common errors like goroutine leaks or deadlocks.
All examples use Go 1.22+, no external libraries needed — you just need to run go run main.go.
2. What is a Goroutine
A goroutine is a function that runs independently, managed by the Go runtime rather than the operating system. Creating a goroutine is very cheap (a few KB stack, auto-scaling), so you can spawn tens of thousands of goroutines without worrying about exhausting system resources like OS threads.
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
fmt.Println("Hello,", name)
}
func main() {
go sayHello("Order Service")
go sayHello("Payment Service")
time.Sleep(100 * time.Millisecond) // wait for goroutines to finish
fmt.Println("Main done")
}
The go keyword before a function call will run that function in a new goroutine, without blocking main. Since main might finish before the two goroutines get a chance to print, we must time.Sleep temporarily to wait — this is an unreliable approach, used only for illustration. The output might be:
Hello, Payment Service
Hello, Order Service
Main done
The output order is not fixed because the scheduler decides which goroutine runs first. This is the essence of concurrency: multiple tasks happen “simultaneously” but without guaranteed order.
Note: Using
time.Sleepto synchronize goroutines in real code is an anti-pattern. Section 4 will introducesync.WaitGroupas a replacement.
3. Channels: Communication Between Goroutines
Channels are type-safe pipes for goroutines to send and receive data. Go’s philosophy is: “Don’t communicate by sharing memory; share memory by communicating.”
package main
import "fmt"
func calculateTotal(amount float64, result chan<- float64) {
tax := amount * 0.1
result <- amount + tax
}
func main() {
result := make(chan float64)
go calculateTotal(1_000_000, result)
total := <-result
fmt.Printf("Total order amount: %.0f\n", total)
}
make(chan float64)creates an unbuffered channel of typefloat64.chan<- float64in the function parameter means the channel is send-only, allowing the compiler to catch errors if you accidentally read from it.result <- amount + taxsends a value into the channel.<-resultinmainwill block until data is available, serving as natural synchronization — no need forSleep.
Total order amount: 1100000
3.1. Unbuffered vs buffered channel
An unbuffered channel (make(chan T)) requires sender and receiver to “meet” at the same time — sending will block until someone receives. A buffered channel (make(chan T, n)) allows up to n values to be sent without requiring the receiver to be ready.
package main
import "fmt"
func main() {
orders := make(chan string, 3)
orders <- "Order-1"
orders <- "Order-2"
orders <- "Order-3"
close(orders)
for order := range orders {
fmt.Println("Processing:", order)
}
}
Since the buffer has capacity 3, the three sends don’t block. close(orders) signals that there’s no more data, allowing the range loop to exit naturally instead of blocking indefinitely waiting for the next value.
Processing: Order-1
Processing: Order-2
Processing: Order-3
Note: Only the sender can
closea channel. Sending on a closed channel causes a panicsend on closed channel; reading from a closed channel is still valid, returning the zero value andok == false.
4. Synchronizing Multiple Goroutines with WaitGroup
When you need to wait for multiple goroutines to finish without caring about their return values, sync.WaitGroup is a cleaner choice than channels.
package main
import (
"fmt"
"sync"
)
func processOrder(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Processed Order-%d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go processOrder(i, &wg)
}
wg.Wait()
fmt.Println("All orders processed")
}
wg.Add(1)increments the counter before the goroutine runs — must be called beforego, not inside the goroutine, to avoid race conditions.defer wg.Done()decrements the counter when the goroutine finishes.wg.Wait()blocks main until the counter reaches 0.
Processed Order-2
Processed Order-1
Processed Order-3
All orders processed
The first three lines can print in any order, but the last line always prints last — that’s what WaitGroup guarantees.
4.1. Testing Concurrent Behavior
You can write tests to verify that all goroutines complete, using an atomic counter instead of relying on print order:
package orderpkg
import (
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProcessOrders_AllCompleted(t *testing.T) {
var wg sync.WaitGroup
var processed int32
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
atomic.AddInt32(&processed, 1)
}(i)
}
wg.Wait()
assert.Equal(t, int32(5), processed)
}
atomic.AddInt32 prevents race conditions when multiple goroutines increment the counter — if you use plain processed++, go test -race will report a data race.
Dependency for the test (add to go.mod):
go get github.com/stretchr/testify
5. Select: Handling Multiple Channels at Once
select is like switch but for channels — it picks whichever case is ready first to execute. Very useful when you need to wait for multiple data sources or set a timeout.
package main
import (
"fmt"
"time"
)
func fetchFromCache(ch chan<- string) {
time.Sleep(50 * time.Millisecond)
ch <- "data from cache"
}
func fetchFromDB(ch chan<- string) {
time.Sleep(200 * time.Millisecond)
ch <- "data from database"
}
func main() {
cacheCh := make(chan string)
dbCh := make(chan string)
go fetchFromCache(cacheCh)
go fetchFromDB(dbCh)
select {
case res := <-cacheCh:
fmt.Println("Received:", res)
case res := <-dbCh:
fmt.Println("Received:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("Timeout, using default data")
}
}
select blocks until one of the cases has data ready. Since cache returns after 50ms and the database after 200ms, the cacheCh case wins:
Received: data from cache
time.After creates a channel that receives a value after a specified duration, used to implement a timeout for the entire select.
6. Canceling Goroutines Properly with Context
A common mistake is spawning a goroutine with no way to stop it, leading to goroutine leaks. The context package solves this by sending cancellation signals throughout the call chain.
package main
import (
"context"
"fmt"
"time"
)
func monitorPayment(ctx context.Context, orderID string) {
for {
select {
case <-ctx.Done():
fmt.Println("Stopped monitoring order:", orderID, "-", ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Println("Checking order status:", orderID)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel()
go monitorPayment(ctx, "Order-99")
<-ctx.Done()
time.Sleep(50 * time.Millisecond) // wait for goroutine to print the last line
}
context.WithTimeoutcreates a context that automatically callsDone()after 1200ms.- In
monitorPayment,selectlistens to bothctx.Done()and periodic ticks. When the context expires, the goroutine exits cleanly instead of running forever. defer cancel()ensures context resources are freed even if the function exits early.
Checking order status: Order-99
Checking order status: Order-99
Stopped monitoring order: Order-99 - context deadline exceeded
Note: Always call
cancel()(viadefer) whether usingWithTimeoutorWithCancel. Forgetting this doesn’t cause immediate errors but leaks context in long-running loops.
7. Common Mistakes
Deadlock from an unread channel. Sending on an unbuffered channel with no goroutine to receive will freeze the program, and the Go runtime will detect it:
func main() {
ch := make(chan int)
ch <- 1 // no one to receive → deadlock
}
fatal error: all goroutines are asleep - deadlock!
Goroutine leak. A goroutine waits to read a channel forever because no one sends and there’s no cancellation mechanism — memory stays allocated until the program exits. Always pair context or a “done” channel when launching long-running goroutines.
Incorrect loop variable capture (before Go 1.22). If you use Go < 1.22, the loop variable i in for is shared across iterations, causing all goroutines to see the same final value. From Go 1.22 onwards, each loop iteration creates a new i, so this issue is gone — but if your project uses an older version, pass the variable as a parameter like the example in section 4.1.
Race conditions from shared variables. Two goroutines reading/writing the same variable without going through channels or sync/atomic is a classic race condition. Always run go test -race or go run -race during development to catch these early.
When NOT to use goroutines: for short tasks, the cost of creating and synchronizing goroutines may outweigh the benefits. If the work takes only a few microseconds, sequential execution is often faster and more readable.
8. Conclusion
Goroutines give you a way to create lightweight execution threads at minimal cost, while channels provide a safe communication mechanism instead of direct memory sharing. Combined with sync.WaitGroup for synchronization, select for handling multiple sources, and context for proper cancellation, you have all the tools to build common concurrency patterns in Go — from worker pools and pipelines to fan-in/fan-out. Next, practice with go test -race to build the habit of detecting race conditions early.