Skip to content

Go Concurrency Primitives

Go provides several tools to manage complex concurrent workflows. Beyond basic goroutines and channels, you often need these primitives for synchronization and data safety.

1. WaitGroups (sync.WaitGroup)

Used to wait for a collection of goroutines to finish.

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Doing work")
    }()
}

wg.Wait() // Wait for all 5 goroutines

2. Mutexes (sync.Mutex)

Used to protect "critical sections" of code where you access shared data. A Mutex (Mutual Exclusion) ensures only one goroutine can access the data at a time.

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    c.value++
    c.mu.Unlock()
}

3. Atomic Counters (sync/atomic)

For simple numeric updates (like a global counter), the atomic package is much faster than a Mutex because it uses low-level hardware instructions.

1
2
3
4
5
6
7
8
9
import "sync/atomic"

var ops uint64

// Increment safely from multiple goroutines
atomic.AddUint64(&ops, 1)

// Read safely
currentOps := atomic.LoadUint64(&ops)

4. Worker Pools

A common pattern to limit resources by running a fixed number of goroutines that process a queue of work.

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 pieces of work
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
}

Summary Table

Primitive Use Case
Channels Passing data between goroutines.
WaitGroup Waiting for multiple tasks to complete.
Mutex Protecting complex shared objects (maps, structs).
Atomic Updating simple numbers (counters, flags).
Worker Pool limiting the amount of concurrent work.