Goroutines and Channels in Go
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
go starts a goroutine and channels pass values between them. The syntax is tiny; the hard parts are who closes and when a send or receive blocks.
Goroutines and WaitGroup
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("task %d started\n", n)
}(i)
}
wg.Wait()
Three details:
wg.Add(1)must run beforego; otherwiseWaitcan return before the counter is incremented.- Pass values as parameters. Loop variables are per iteration since Go 1.22, but explicit parameters still document the intent.
- When
mainreturns, every other goroutine is killed without warning — that is why you needWait.
Channel basics
ch := make(chan int) // unbuffered
buf := make(chan int, 3) // buffered, capacity 3
ch <- 1 // send
v := <-ch // receive
v, ok := <-ch // ok == false means closed and drained
close(ch) // close: sender only
- Unbuffered: a send completes only when a receiver is ready. This is synchronisation, not just transport.
- Buffered: sends block when full, receives block when empty.
- Closed: sending panics; receiving yields the zero value immediately.
range chends when the channel is closed and drained — withoutclose, it waits forever.
go func() {
defer close(ch)
for _, v := range []int{1, 2, 3} {
ch <- v
}
}()
for v := range ch {
fmt.Println(v)
}
Closing is the sender’s job. A receiver must never close. With several senders, coordinate with a sync.WaitGroup and close after the last one finishes.
select
select waits on whichever channel operation becomes ready first:
select {
case v := <-data:
fmt.Println("got", v)
case err := <-errc:
fmt.Println("error", err)
case <-time.After(2 * time.Second):
fmt.Println("timeout")
case <-ctx.Done():
return ctx.Err()
}
default makes it non-blocking:
select {
case ch <- v:
// sent
default:
// buffer full: drop or retry later
}
time.After allocates a timer on every call, so do not use it in a loop; time.NewTimer plus Reset is the long-running pattern.
Patterns worth memorising
Worker pool
func process(ctx context.Context, jobs []Job, workers int) ([]Result, error) {
jobCh := make(chan Job)
resCh := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobCh {
resCh <- run(ctx, job)
}
}()
}
go func() {
defer close(jobCh)
for _, job := range jobs {
select {
case jobCh <- job:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(resCh)
}()
var results []Result
for res := range resCh {
results = append(results, res)
}
return results, ctx.Err()
}
The producer closes jobCh; wg.Wait() closes resCh once every worker has stopped. That is what lets the consumer’s range finish.
Fan-out / fan-in
Several goroutines read from one channel (fan-out) and results are merged into another (fan-in). The closing rules are the same: the writer closes.
Timeouts
select {
case res := <-doWork():
return res
case <-time.After(500 * time.Millisecond):
return ErrTimeout
}
The goroutine behind doWork keeps running. If it writes to a result channel, that channel needs a buffer or a cancellation path, or it leaks.
Mutex or channel
- Shared state (counters, caches, config):
sync.Mutexis more direct. - Ownership transfer, pipelines, event notification: channels fit better.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
For read-heavy data, sync.RWMutex allows concurrent RLock holders while Lock stays exclusive. Never hold a lock across I/O or a callback into unknown code.
sync.Once covers one-time initialisation:
var once sync.Once
once.Do(func() { cfg = loadConfig() })
Data races
go test -race ./...
go run -race main.go
The race detector only reports races that actually happen, so the tests have to exercise concurrent paths. Symptoms in production: results that are occasionally wrong, and a hard fatal on concurrent map writes (concurrent map writes).
// unsafe: two goroutines writing the same map
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
// safe: a mutex, sync.Map, or one goroutine owning the map
Common mistakes
1. main exiting early
go doWork()
// main returns, doWork is killed
Wait with a WaitGroup, a channel, or select {} when the program is meant to run forever.
2. Sending on a closed channel
panic: send on closed channel. Have exactly one owner close it, and never send afterwards.
3. Closing twice
Also a panic. Guard the close with sync.Once if several paths can reach it.
4. Deadlocking an unbuffered channel
ch := make(chan int)
ch <- 1 // no receiver: blocks forever
Receive concurrently, or give it a buffer.
5. Goroutine leaks
Nobody reads the channel, a select lacks a ctx.Done() branch, or a loop spawns goroutines that are never reaped. Watch runtime.NumGoroutine() or a goroutine profile for a number that keeps climbing.
6. Capturing the loop variable in a goroutine
for _, v := range items {
go func() { use(v) }() // Go 1.22+ is fine; older versions used the last value
}
Explicit parameters remove all doubt: go func(v Item) { use(v) }(v).
7. Sending large values through channels
Channels copy. Send pointers or restructure the data flow when the struct is big.
8. Believing close waits for consumers
close only marks that no more values will be sent; buffered values are still delivered.
Summary
goplusWaitGroupis the minimal unit;Addbefore starting.- Unbuffered channels synchronise, buffered ones decouple; the sender closes, the receiver uses
rangeorok. selecthandles multiple sources and timeouts; avoidtime.Afterin loops.- Worker pools are three pieces: produce and close the input, run the workers, close the output after
Wait. - Mutexes for shared state, channels for data flow, and
-racein CI.
Next: generics, for when one implementation should serve several types.