When goroutines collide
You write a web handler that increments a visit counter. You test it locally and it works. You deploy it and hit the endpoint with a load generator. The counter jumps from 100 to 102, then drops to 98, then settles at 45. The data is corrupted.
Or you initialize a database connection in a startup function. Two background goroutines call that function at the same time. The driver panics because it refuses to initialize twice.
Goroutines run independently. They do not coordinate by default. When multiple goroutines access the same memory, the scheduler can interleave their instructions in ways that break your logic. The sync package provides primitives to enforce order, protect shared state, and synchronize execution.
Mutex: the single key
sync.Mutex stands for mutual exclusion. It ensures that only one goroutine can execute a critical section of code at a time. If a goroutine tries to lock a mutex that is already held, it blocks until the holder unlocks it.
Think of a bathroom with one key. If someone has the key, others wait outside. When they finish, they return the key, and the next person enters.
package main
import (
"fmt"
"sync"
)
// Counter tracks visits with thread-safe increments.
type Counter struct {
mu sync.Mutex
count int
}
// Increment adds one to the counter safely.
func (c *Counter) Increment() {
// Lock prevents concurrent modifications to c.count.
c.mu.Lock()
// Unlock releases the lock when the function returns.
// Defer ensures the lock is released even if a panic occurs.
defer c.mu.Unlock()
// Read, modify, and write happen atomically.
c.count++
}
// Main demonstrates safe concurrent updates.
func main() {
var wg sync.WaitGroup
c := &Counter{}
// Launch ten goroutines to increment the counter.
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Increment()
}()
}
// Wait for all goroutines to finish.
wg.Wait()
fmt.Println("Count:", c.count)
}
The mutex protects the variable, not the goroutine. You lock before accessing shared state and unlock immediately after. The critical section should be as short as possible. Long locks force other goroutines to wait, reducing throughput.
Convention aside: receiver names in Go are usually one or two letters matching the type. Use (c *Counter) not (this *Counter). The compiler does not enforce this, but the community expects it.
Locks are for protection, not flow control. Keep critical sections short.
RWMutex: readers and writers
A standard mutex blocks everyone. If you have a cache that is read frequently but written rarely, a mutex forces readers to wait for each other even though reading does not change the data.
sync.RWMutex allows multiple readers to proceed concurrently, but only one writer at a time. Writers block all other access. Readers block only writers.
Use RLock and RUnlock for readers. Use Lock and Unlock for writers.
package main
import (
"fmt"
"sync"
)
// Cache stores key-value pairs with read-heavy access.
type Cache struct {
mu sync.RWMutex
items map[string]string
}
// Get retrieves a value from the cache.
// Multiple goroutines can call Get concurrently.
func (c *Cache) Get(key string) string {
// RLock allows concurrent reads.
c.mu.RLock()
defer c.mu.RUnlock()
return c.items[key]
}
// Set updates a value in the cache.
// Only one goroutine can call Set at a time.
func (c *Cache) Set(key, value string) {
// Lock blocks all readers and other writers.
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
// Main shows concurrent reads and exclusive writes.
func main() {
c := &Cache{items: make(map[string]string)}
// Initialize the cache.
c.Set("key", "value")
// Readers can run in parallel.
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = c.Get("key")
}()
}
wg.Wait()
}
RWMutex helps when reads dominate. If writes are frequent, the overhead of managing multiple readers can outweigh the benefit. Writers can also starve if a constant stream of readers keeps acquiring the lock. The runtime tries to prioritize writers, but starvation is possible under heavy read load.
RWMutex is an optimization. Start with a plain Mutex. Switch to RWMutex only when profiling shows read contention is the bottleneck.
WaitGroup: counting goroutines
Goroutines run asynchronously. If main exits, the program terminates and all goroutines die. You need a way to wait for a group of goroutines to finish before proceeding.
sync.WaitGroup tracks a counter of active tasks. You add to the counter before launching goroutines. Each goroutine decrements the counter when it finishes. Wait blocks until the counter reaches zero.
package main
import (
"fmt"
"sync"
)
// Main waits for a batch of tasks to complete.
func main() {
var wg sync.WaitGroup
// Add the number of goroutines to the wait group.
// This must happen before the goroutines start.
wg.Add(3)
for i := 0; i < 3; i++ {
go func(id int) {
// Decrement the counter when the goroutine returns.
defer wg.Done()
fmt.Printf("Task %d running\n", id)
}(i)
}
// Block until all goroutines call Done.
wg.Wait()
fmt.Println("All tasks finished")
}
The counter must start positive. Add takes an integer. You can add multiple tasks at once with wg.Add(n). Done is equivalent to Add(-1).
Never pass a WaitGroup by value. WaitGroup contains internal state. Copying it splits the state between the original and the copy. If you pass a WaitGroup to a function, pass a pointer *sync.WaitGroup. The compiler does not catch this error. The program will run, but Wait may return immediately or Done may panic.
The runtime panics with sync: negative WaitGroup counter if you call Done more times than Add. This usually means a goroutine finished twice or the add count was wrong.
Add before launch. Done when finished. Wait only once.
Once: run exactly once
Some initialization should happen only once. Loading a configuration file, connecting to a database, or setting up a logger. If multiple goroutines trigger initialization simultaneously, you get duplicate work or panics.
sync.Once ensures a function runs exactly once, even if called concurrently. Subsequent calls return immediately without executing the function.
package main
import (
"fmt"
"sync"
)
var config string
var once sync.Once
// LoadConfig initializes the configuration exactly once.
func LoadConfig() {
once.Do(func() {
// Simulate expensive initialization.
config = "loaded-from-file"
fmt.Println("Config initialized")
})
}
// Main demonstrates concurrent initialization.
func main() {
var wg sync.WaitGroup
// Ten goroutines try to load config simultaneously.
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
LoadConfig()
}()
}
wg.Wait()
fmt.Println("Config:", config)
}
Do takes a function with no arguments and no return values. You cannot return a value from Do. Use a closure variable to capture the result. The example above sets config inside the closure.
If the function passed to Do panics, Once marks the action as complete. Future calls return immediately without retrying. This prevents infinite panic loops, but it leaves the program in a broken state. Design your initialization to avoid panics, or handle errors inside the Do function.
Once is for initialization. Don't use it to retry failed operations.
Pitfalls and runtime errors
Sync primitives are low-level. Mistakes lead to deadlocks, panics, or silent corruption.
Deadlocks happen when goroutines wait for each other indefinitely. If goroutine A holds lock 1 and waits for lock 2, while goroutine B holds lock 2 and waits for lock 1, neither can proceed. The runtime detects this and prints fatal error: all goroutines are asleep - deadlock!. Always acquire locks in a consistent order. Never hold a lock while calling code that might acquire the same lock.
Forgetting to unlock a mutex causes other goroutines to wait forever. Use defer mu.Unlock() immediately after Lock. This ensures the lock releases even if the function panics.
The compiler rejects programs with undefined variables, but it cannot detect deadlocks or race conditions. Use the race detector to find data races. Run your tests with go test -race. The detector instruments memory accesses and reports violations at runtime.
Goroutine leaks occur when a goroutine waits on a channel that never closes or a mutex that never unlocks. The goroutine stays in memory until the program exits. The worst goroutine bug is the one that never logs. Profile your application to spot growing goroutine counts.
Sync primitives do not respect context cancellation. If you need to cancel a blocking operation, use channels and select. sync is for coordination. Channels are for communication and cancellation.
Decision matrix
Choose the right tool for the job. Sync primitives solve specific problems.
Use sync.Mutex when you need exclusive access to shared state and reads and writes occur with similar frequency.
Use sync.RWMutex when you have many concurrent readers and few writers, and profiling confirms read contention is a bottleneck.
Use sync.WaitGroup when you need to wait for a fixed set of goroutines to finish before proceeding.
Use sync.Once when you must run an initialization function exactly once, regardless of how many goroutines request it.
Use channels when goroutines need to pass data between each other or when you need cancellation support.
Use sync/atomic when you only need to increment a counter or swap a value and want the lowest possible overhead.
Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.