Skip to content

Synchronization

Synchronization in Go

Synchronization ensures that goroutines coordinate properly and avoid conflicts, especially when accessing shared resources. Go provides multiple mechanisms for synchronization:


1. sync.Mutex

sync.Mutex is used to lock and unlock critical sections to ensure only one goroutine accesses shared data at a time.

Example: Mutex for Shared Counter

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    counter := 0

    var wg sync.WaitGroup
    const numGoroutines = 5

    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            mu.Lock()
            counter++
            fmt.Printf("Goroutine %d incremented counter to %d\n", id, counter)
            mu.Unlock()
        }(i)
    }

    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

2. sync.RWMutex

sync.RWMutex provides separate locks for reading and writing: - Multiple readers can acquire the lock simultaneously. - Only one writer can acquire the lock, blocking readers and other writers.

Example: RWMutex for Read and Write

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.RWMutex
    data := make(map[int]string)

    var wg sync.WaitGroup

    // Writer
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        data[1] = "GoLang"
        fmt.Println("Write: Added GoLang")
        mu.Unlock()
    }()

    // Reader
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.RLock()
        fmt.Println("Read: Value for key 1 is", data[1])
        mu.RUnlock()
    }()

    wg.Wait()
}

3. sync.WaitGroup

sync.WaitGroup waits for multiple goroutines to complete.

Example: Using WaitGroup

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Decrement counter when goroutine finishes
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second) // Simulate work
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // Increment counter for each goroutine
        go worker(i, &wg)
    }

    wg.Wait() // Block until all workers are done
    fmt.Println("All workers finished")
}

4. sync.Cond

sync.Cond allows goroutines to signal each other. Useful for scenarios like producer-consumer.

Example: Using sync.Cond

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    queue := make([]int, 0)

    const maxQueueSize = 3

    // Consumer
    go func() {
        for {
            mu.Lock()
            for len(queue) == 0 {
                cond.Wait() // Wait until there's something to consume
            }
            item := queue[0]
            queue = queue[1:]
            fmt.Println("Consumed:", item)
            cond.Signal() // Notify producers
            mu.Unlock()
        }
    }()

    // Producer
    go func() {
        for i := 1; i <= 5; i++ {
            mu.Lock()
            for len(queue) >= maxQueueSize {
                cond.Wait() // Wait until there's space
            }
            queue = append(queue, i)
            fmt.Println("Produced:", i)
            cond.Signal() // Notify consumers
            mu.Unlock()
            time.Sleep(time.Millisecond * 500)
        }
    }()

    // Let the program run for a while
    time.Sleep(time.Second * 5)
}

5. sync.Once

sync.Once ensures that a piece of code runs only once, no matter how many goroutines invoke it.

Example: Using sync.Once

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once

    initFunc := func() {
        fmt.Println("Initialization done")
    }

    for i := 0; i < 3; i++ {
        go func(id int) {
            fmt.Printf("Goroutine %d calling init\n", id)
            once.Do(initFunc) // Only the first call runs initFunc
        }(i)
    }

    // Wait for goroutines to complete
    time.Sleep(time.Second)
}

6. Atomic Operations

The sync/atomic package provides low-level primitives for atomic operations like incrementing or swapping values without locks.

Example: Atomic Counter

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var counter int64

    const numGoroutines = 10
    done := make(chan bool, numGoroutines)

    for i := 0; i < numGoroutines; i++ {
        go func() {
            for j := 0; j < 1000; j++ {
                atomic.AddInt64(&counter, 1) // Atomically increment counter
            }
            done <- true
        }()
    }

    for i := 0; i < numGoroutines; i++ {
        <-done
    }

    fmt.Println("Final Counter:", counter)
}

7. Channel-Based Synchronization

Channels are a high-level and idiomatic way to synchronize goroutines.

Example: Using Channels for Synchronization

package main

import "fmt"

func worker(done chan bool) {
    fmt.Println("Working...")
    done <- true // Notify main goroutine
}

func main() {
    done := make(chan bool)
    go worker(done)

    <-done // Wait for worker to finish
    fmt.Println("Worker finished")
}

When to Use What

Scenario Synchronization Mechanism
Mutual exclusion (read/write) sync.Mutex, sync.RWMutex
Waiting for multiple goroutines sync.WaitGroup
Triggering once-only execution sync.Once
Fine-grained atomic updates sync/atomic
Coordinating producer/consumer sync.Cond, Channels

Key Takeaways

  • Use channels for idiomatic Go synchronization.
  • Use mutexes for critical sections when channels are not suitable.
  • Avoid over-synchronizing; keep designs simple and focused on performance.