Skip to content
AgileLogics
7 min read

sync: WaitGroup & Mutex

Prerequisites: select

Channels are one way to coordinate goroutines. The sync package offers the lower-level tools:

  • sync.WaitGroup waits for a group of goroutines to finish. Add(n) increases the counter, Done() decreases it, Wait() blocks until it reaches zero.
  • sync.Mutex (mutual exclusion lock) protects shared data. Only one goroutine can hold the lock at a time; the others wait in Lock().
  • sync.RWMutex allows many simultaneous readers (RLock) or one writer (Lock) — useful for read-heavy data.
  • sync.Once runs a function exactly once, even when called from many goroutines.

Rule of thumb: use channels to pass ownership of data between goroutines; use a mutex to protect a piece of state that several goroutines update.

Syntax

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    // work
}()
wg.Wait()

var mu sync.Mutex
mu.Lock()
// read or modify shared state
mu.Unlock()

Example

package main

import (
    "fmt"
    "sync"
)

// SafeCounter is safe to use from many goroutines.
type SafeCounter struct {
    mu     sync.Mutex
    counts map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.counts[key]++
}

func (c *SafeCounter) Get(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.counts[key]
}

func main() {
    c := SafeCounter{counts: make(map[string]int)}

    var wg sync.WaitGroup
    for range 1000 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc("hits")
        }()
    }
    wg.Wait()

    fmt.Println(c.Get("hits"))

    var once sync.Once
    for range 3 {
        once.Do(func() { fmt.Println("initialised once") })
    }
}

Expected output

1000
initialised once

Without the mutex, this program has a data race. Plain map writes from many goroutines usually crash with fatal error: concurrent map writes, and a plain int counter would often end up below 1000. go run -race . reports the race either way.

Common mistakes

  • Copying a struct that contains a sync.Mutex (for example, a value receiver on SafeCounter). Each copy has its own lock, so nothing is protected. Use pointer receivers; go vet warns about copied locks.
  • Forgetting to unlock on an early return. Put defer mu.Unlock() right after mu.Lock().
  • Locking only the writes and not the reads. Every access to shared state must hold the lock.
  • Calling Lock twice from the same goroutine. Go's mutexes are not re-entrant, so this deadlocks.

Practice

Create an Account with a mutex-protected balance int and a Deposit(amount int) method. Start 50 goroutines that each deposit 10, wait for all of them, and print the balance.

Show solution
package main

import (
    "fmt"
    "sync"
)

type Account struct {
    mu      sync.Mutex
    balance int
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
}

func (a *Account) Balance() int {
    a.mu.Lock()
    defer a.mu.Unlock()
    return a.balance
}

func main() {
    acc := &Account{}

    var wg sync.WaitGroup
    for range 50 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            acc.Deposit(10)
        }()
    }
    wg.Wait()

    fmt.Println(acc.Balance()) // 500
}