"Don't communicate by sharing memory; share memory by communicating." — Rob Pike
Go's flagship feature is its concurrency model: cheap goroutines multiplexed onto OS threads, plus channels for typed message passing.
Goroutines
A goroutine is a function call run concurrently. Start one with go:
go fmt.Println("hello from a goroutine")
Goroutines start with a small stack (~2 KiB) that grows as needed, so a single program can have hundreds of thousands of them. They are scheduled cooperatively by the Go runtime, not by the OS.
The main function is itself a goroutine. When main returns, the program
exits, even if other goroutines are still running. You typically wait for
them with channels, sync.WaitGroup, or errgroup.
Channels
Channels carry typed values between goroutines.
ch := make(chan int) // unbuffered
ch := make(chan int, 4) // buffered (capacity 4)
ch <- 42 // send
v := <-ch // receive
v, ok := <-ch // receive; ok=false when channel is closed and drained
close(ch) // sender closes; never close from the receiver side
Unbuffered channels synchronize
A send on an unbuffered channel blocks until another goroutine receives, and vice versa. This is the simplest form of synchronization in Go.
Buffered channels
A send on a buffered channel proceeds immediately if there's room. A receive proceeds immediately if there's a value. Use buffered channels for limited worker pools, batching, or to absorb bursts.
range over a channel
for v := range ch {
fmt.Println(v)
}
// loop exits when ch is closed
select
select waits on multiple channel operations. The first one that can
proceed is chosen; if multiple are ready, one is picked at random.
select {
case v := <-in:
fmt.Println("in:", v)
case out <- nextValue:
fmt.Println("sent")
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready right now")
}
The default case makes the select non-blocking.
Synchronization primitives (sync)
When you need to share memory between goroutines:
var mu sync.Mutex
mu.Lock()
shared.value = 42
mu.Unlock()
sync.Mutex— exclusive lock.sync.RWMutex— many readers or one writer.sync.WaitGroup— wait for N goroutines to finish.sync.Once— run an initializer exactly once.sync.Pool— reusable per-goroutine object cache (use only when profile says so).sync/atomic— lock-free atomic ops on integers and pointers.
WaitGroup
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println("worker", i)
}(i)
}
wg.Wait()
(In Go 1.22+ the loop variable is per-iteration, so the explicit i parameter
is no longer required for correctness — but it's still a clear style.)
context.Context
context.Context carries cancellation, deadlines and request-scoped values
across API boundaries. It is the canonical way to stop work in Go:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case <-time.After(5 * time.Second):
fmt.Println("done")
case <-ctx.Done():
fmt.Println("aborted:", ctx.Err())
}
Every long-running function in production Go takes a ctx context.Context
as its first argument and respects it.
Worker pool pattern
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 4; w++ {
go func() {
for j := range jobs {
results <- j * j
}
}()
}
go func() {
for i := 1; i <= 10; i++ {
jobs <- i
}
close(jobs)
}()
for i := 0; i < 10; i++ {
fmt.Println(<-results)
}
Pipelines
Stages are goroutines; channels connect them.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums { out <- n }
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { out <- n * n }
}()
return out
}
for v := range square(gen(1, 2, 3, 4)) {
fmt.Println(v)
}
The race detector
Always run tests with -race during development:
go test -race ./...
go run -race ./cmd/myapp
It instrumented your binary with a runtime that flags data races. If it fires, you have a bug. There are no false positives.
Common pitfalls
- Forgetting to wait for goroutines — they get killed when
mainreturns. UseWaitGroup,errgroup, or block on the result channel. - Sending on a closed channel — panics. Only the sender closes, never
the receiver. With multiple senders, coordinate via a separate "done"
channel or
sync.Once. - Leaking goroutines — a goroutine blocked on a channel that no one
will send to lives forever. Always have a path to exit:
ctx.Done(), close upstream channels, or a timeout. - Sharing slices/maps without locks — they are NOT safe for concurrent write. Use a mutex or pass copies.
Run the example
go run ./part1-language/examples/10_concurrency