Go Concurrency Basics
Go concurrency is about structuring programs so multiple tasks can make progress independently using goroutines (lightweight threads) and channels (safe communication pipes).
Goroutines
A goroutine is a lightweight thread managed by the Go runtime; use it when you want tasks to run concurrently, but avoid spawning thousands without control as it can exhaust memory.
package main
import (
"fmt"
"time"
)
func main() {
items := []string{"apple", "banana", "cherry"}
go printItems(items) // starts concurrently, doesn't wait
time.Sleep(100 * time.Millisecond) // without this, main exits before goroutine finishes
}
func printItems(items []string) {
for _, item := range items {
fmt.Println(item)
}
}
WaitGroup: Waiting for Goroutines
A WaitGroup blocks until a set of goroutines finish; use it when you need to wait for concurrent work to complete, but avoid it when goroutines need to return values (use channels instead).
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry"}
var wg sync.WaitGroup
wg.Add(1) // "I'm starting 1 goroutine"
go printItems(items, &wg)
wg.Wait() // blocks until printItems calls Done
}
func printItems(items []string, wg *sync.WaitGroup) {
defer wg.Done() // "This goroutine is done"
for _, item := range items {
fmt.Println(item)
}
}
Multiple Goroutines in a Loop
Use wg.Add(len(items)) before the loop when spawning one goroutine per item; always pass loop variables as function arguments to avoid the closure capture bug.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry"}
var wg sync.WaitGroup
wg.Add(len(items)) // Add BEFORE the loop, matching count
for _, item := range items {
go func(v string) {
defer wg.Done()
fmt.Println(v)
}(item) // pass item as argument to avoid closure bug
}
wg.Wait()
}
Channels: Communication Between Goroutines
A channel is a typed pipe for sending values between goroutines; use it when goroutines need to communicate or synchronize, but avoid it for simple “wait for completion” cases where WaitGroup is simpler.
package main
import "fmt"
func main() {
items := []string{"apple", "banana", "cherry"}
ch := make(chan string) // unbuffered channel
go func() {
for _, item := range items {
ch <- item // send (blocks until received)
}
close(ch) // sender closes when done
}()
for msg := range ch { // receive until channel closes
fmt.Println(msg)
}
}
Buffered Channel
A buffered channel holds a fixed number of values without blocking the sender; use it when producer and consumer run at different speeds, but avoid arbitrary buffer sizes—set them based on actual throughput needs.
package main
import "fmt"
func main() {
ch := make(chan string, 2) // buffered channel (capacity 2)
ch <- "apple" // doesn't block (buffer has space)
ch <- "banana" // doesn't block (buffer has space)
// ch <- "cherry" // would block (buffer full, no receiver)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
WaitGroup + Channel Pattern
Combine WaitGroup with channels when multiple goroutines produce values and you need to know when all are done; the WaitGroup signals completion so the channel can be safely closed.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry"}
var wg sync.WaitGroup
results := make(chan string)
wg.Add(len(items))
for _, item := range items {
go func(v string) {
defer wg.Done()
results <- v
}(item)
}
go func() {
wg.Wait() // wait for all senders
close(results) // then close channel
}()
for v := range results {
fmt.Println(v)
}
}
Worker Pool
A worker pool is a fixed set of goroutines processing jobs from a shared channel; use it for CPU or IO-bound tasks where you want controlled parallelism, but avoid it for simple one-off concurrent tasks.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry", "date", "elderberry"}
jobs := make(chan string)
var wg sync.WaitGroup
// start 3 workers
limit := 3
wg.Add(limit)
for range limit {
go worker(i, jobs, &wg)
}
// send jobs
for _, item := range items {
jobs <- item
}
close(jobs)
wg.Wait()
}
func worker(id int, jobs <-chan string, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("worker %d: %s\n", id, job)
}
}
Worker Pool with WaitGroup.Go (Go 1.24+)
Go 1.24 introduced WaitGroup.Go() which combines Add(1), spawning a goroutine, and defer Done() into a single call; use it for cleaner worker pool code when targeting Go 1.24+.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry", "dog"}
noItems := len(items)
workers := noItems
// pipe to send jobs
jobs := make(chan string, noItems)
// pipe for jobs to send result
result := make(chan string, noItems)
// start workers
var wg sync.WaitGroup
// assign job for each worker
for range workers {
wg.Go(func() {
for job := range jobs {
// we can do something with this job
// for now, let's just send this back to result
result <- job
}
})
}
// Send work to jobs channel
for _, fruit := range items {
jobs <- fruit
}
close(jobs)
// Wait for workers and close channel
go func() {
wg.Wait()
close(result)
}()
for res := range result {
fmt.Println(res)
}
}
Semaphore: Limit Concurrency
A semaphore uses a buffered channel to limit how many goroutines run simultaneously; use it to protect resources like API rate limits or database connections, but prefer worker pools when jobs are uniform.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
items := []string{"apple", "banana", "cherry", "date", "elderberry"}
sem := make(chan struct{}, 2) // max 2 concurrent goroutines
var wg sync.WaitGroup
wg.Add(len(items))
for _, item := range items {
go func(v string) {
defer wg.Done()
sem <- struct{}{} // acquire slot (blocks if full)
doExpensiveWork(v)
<-sem // release slot
}(item)
}
wg.Wait()
}
func doExpensiveWork(item string) {
fmt.Printf("processing %s\n", item)
time.Sleep(100 * time.Millisecond)
}
Common Mistakes
These bugs cause race conditions or unexpected behavior; the closure capture bug was fixed in Go 1.22, but passing loop variables as arguments remains the safest portable pattern.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"apple", "banana", "cherry"}
// BUG: Closure captures loop variable (pre-Go 1.22)
var wg1 sync.WaitGroup
wg1.Add(len(items))
for _, item := range items {
go func() {
defer wg1.Done()
fmt.Println(item) // prints last item multiple times
}()
}
wg1.Wait()
fmt.Println("---")
// FIX: Pass as argument
var wg2 sync.WaitGroup
wg2.Add(len(items))
for _, item := range items {
go func(v string) {
defer wg2.Done()
fmt.Println(v) // correct
}(item)
}
wg2.Wait()
}