Concurrent programming in Go can feel like trying to coordinate a team where everyone talks at once. You have these lightweight goroutines that are incredibly powerful, but without rules, they trip over each other, corrupting data and causing chaos. I remember my first few programs where I’d launch a dozen goroutines to process data, only to get different results each run. The problem wasn’t the logic; it was the lack of coordination. That’s where synchronization comes in. It’s the set of rules that lets your team of goroutines work together without stepping on each other’s toes.
Go provides this rulebook in the sync package. It’s not magic—it’s a collection of straightforward tools. When you learn to use them, you move from writing fragile, unpredictable concurrent code to building robust systems that behave correctly every single time. Let’s walk through these essential patterns. Think of them as the basic moves you need to choreograph your concurrent programs.
The most fundamental tool is the mutex. The name comes from “mutual exclusion.” Its job is simple: it ensures only one goroutine can access a piece of code or data at a time. Imagine a single bathroom key. If a goroutine wants to use the “bathroom” (the protected data), it must take the key (lock the mutex). While it has the key, no one else can enter. When it’s done, it returns the key (unlocks the mutex).
Here’s a classic example: safely incrementing a counter from multiple goroutines.
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var mu sync.Mutex
var wg sync.WaitGroup
// Launch 1000 goroutines
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
mu.Lock() // Take the key
count++ // Safely use the bathroom
mu.Unlock() // Return the key
wg.Done()
}()
}
wg.Wait() // Wait for all goroutines to finish
fmt.Println("Final count:", count) // This will reliably print 1000
}
Without that mu.Lock() and mu.Unlock(), the final count would be a random number less than 1000. Multiple goroutines would read the old value, increment it, and write it back simultaneously, overwriting each other’s work. The mutex prevents that race condition. A good rule I follow is to keep the code between the lock and unlock—the “critical section”—as short as possible. Hold the key only for as long as you absolutely need it, so others aren’t waiting unnecessarily.
Sometimes, the rules can be a bit more relaxed. What if many goroutines just want to look at the data, but only one ever needs to change it? Using a full mutex forces all those readers to wait in line, one after the other, which is slow. This is where the read-write mutex, or sync.RWMutex, comes in. It’s like a library. Many people can be in the library reading books at the same time, but if someone needs to edit a book (write), they get exclusive access. All readers must leave, the editor does their work, and then the readers can come back in.
type ConfigStore struct {
config map[string]string
rw sync.RWMutex
}
// Get is used by hundreds of goroutines constantly
func (c *ConfigStore) Get(key string) string {
c.rw.RLock() // Multiple readers can lock here
defer c.rw.RUnlock() // Ensure the lock is always released
return c.config[key]
}
// Update is called rarely, perhaps on admin command
func (c *ConfigStore) Update(key, value string) {
c.rw.Lock() // Exclusive lock for one writer
defer c.rw.Unlock()
c.config[key] = value
}
The RLock() and RUnlock() methods are for readers. The Lock() and Unlock() are for the single writer. This pattern gives a massive performance boost in situations where reads outnumber writes by a large margin, like a cached configuration or a live dashboard displaying metrics.
Often, you’ll start several worker goroutines and need to wait for them all to finish before proceeding. You could track them with channels, but there’s a cleaner way: sync.WaitGroup. It’s a simple counter. You tell it how many goroutines you’re starting, each goroutine signals when it’s done, and you wait for the counter to hit zero.
I use this constantly. It’s perfect for fan-out patterns.
func fetchAllURLs(urls []string) ([]string, error) {
var wg sync.WaitGroup
results := make([]string, len(urls))
errs := make(chan error, len(urls))
for i, url := range urls {
wg.Add(1) // Tell the WaitGroup: +1 worker
go func(idx int, u string) {
defer wg.Done() // Tell the WaitGroup: -1 worker (when done)
resp, err := http.Get(u)
if err != nil {
errs <- err
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
results[idx] = string(body)
}(i, url)
}
// A dedicated goroutine to close the channel when workers are done
go func() {
wg.Wait() // Blocks here until counter is 0
close(errs)
}()
// Check for any errors
for err := range errs {
return nil, err
}
return results, nil
}
The pattern is always Add before launching the goroutine, Done inside the goroutine (using defer is foolproof), and Wait where you need to block. It neatly collects concurrent work.
What about something you only want to set up once, no matter how many goroutines ask for it? For example, opening a database connection or parsing a heavy configuration file. sync.Once is your tool. It guarantees a function is called one single time, and all callers receive the result of that one call.
I use this to implement thread-safe lazy initialization.
var (
connection *sql.DB
initOnce sync.Once
)
func GetConnection() *sql.DB {
initOnce.Do(func() {
var err error
// This complex, slow setup runs only a single time
connection, err = sql.Open("driver", "connection_string")
if err != nil {
log.Fatal(err)
}
connection.SetMaxOpenConns(25)
// ... more configuration
})
return connection
}
No matter if ten or ten thousand goroutines call GetConnection() simultaneously, the expensive setup inside Do happens exactly once. All goroutines then get the same, ready-to-use connection pointer. It’s cleaner and safer than checking a “loaded” flag inside a mutex.
Creating and destroying certain objects can be expensive. Think of byte buffers for building strings, or temporary structs for encoding. The garbage collector handles them, but constant allocation can slow things down. sync.Pool provides a temporary holding pen for these objects. Goroutines can take an object from the pool, use it, and put it back for reuse.
The pool manages the lifecycle. When you Get(), it returns a previously used item if available, or calls its New function to create one. When you Put() an item back, it’s stored for later. The runtime may decide to clear pooled items during garbage collection.
Here’s how I use it for bytes.Buffer, which is very common in serialization.
var bufPool = sync.Pool{
New: func() interface{} {
// Called if the pool is empty
return &bytes.Buffer{}
},
}
func LogMessage(components ...string) string {
buf := bufPool.Get().(*bytes.Buffer) // Type assertion after Get
buf.Reset() // Crucial: clear old data from reuse
defer bufPool.Put(buf) // Return to pool when done
for _, comp := range components {
buf.WriteString(comp)
}
return buf.String()
}
The key steps are Get, immediately Reset the object (because it contains old state), use it, and Put it back with defer. This pattern drastically reduces allocation overhead in high-throughput servers. I’ve seen latency improvements of 20% or more just by pooling heavy-use objects.
Go’s built-in maps are not safe for concurrent use. A sync.Mutex around a regular map is the standard solution. However, the Go team also provides sync.Map, a concurrent map built for specific use-cases. It’s not a general replacement. It shines when you have a map where keys are mostly stable, each key is written once (or very rarely) but read many times, like in a caching scenario. In these cases, it can outperform a mutex-protected map by reducing lock contention.
var userCache sync.Map // key: userID (string), value: *User
func GetUser(id string) (*User, error) {
// Load is safe for concurrent use
if val, ok := userCache.Load(id); ok {
return val.(*User), nil
}
// Cache miss: fetch from database
user, err := fetchUserFromDB(id)
if err != nil {
return nil, err
}
// Store the fetched user
userCache.Store(id, user)
return user, nil
}
// You can also range over a sync.Map
func PrintAllUsers() {
userCache.Range(func(key, value interface{}) bool {
fmt.Printf("Key: %v, User: %v\n", key, value)
return true // returning false stops the iteration
})
}
Its API uses interface{} (or any in Go 1.18+), so you need type assertions. For most general-purpose maps, I still start with a simple map guarded by a sync.RWMutex. I reach for sync.Map when profiling shows high contention on that mutex and the access pattern fits.
When you have a simple shared integer or flag, using a full mutex can feel heavy. The sync/atomic package provides lightweight, low-level operations that are safe for concurrent use. These are your tools for things like counters, status flags, or simple state indicators.
type ServerStatus struct {
totalRequests int64 // Must use int64 for atomic on 64-bit systems
isLive int32 // Use as a boolean (0 = false, 1 = true)
}
func (s *ServerStatus) IncrementRequests() {
atomic.AddInt64(&s.totalRequests, 1)
}
func (s *ServerStatus) GetRequestCount() int64 {
return atomic.LoadInt64(&s.totalRequests)
}
func (s *ServerStatus) SetLive(status bool) {
var val int32 = 0
if status {
val = 1
}
atomic.StoreInt32(&s.isLive, val)
}
func (s *ServerStatus) IsLive() bool {
return atomic.LoadInt32(&s.isLive) == 1
}
Atomic operations are faster than mutexes for these single-variable cases because they often use processor-level instructions. Use them for simple, frequent updates. The moment you need to coordinate changes between two or more variables (like “withdraw money and update ledger”), you need a mutex.
Sometimes, a goroutine shouldn’t proceed until some condition is true. You could loop and sleep, but that’s wasteful. A sync.Cond (condition variable) ties a condition check to a mutex and allows goroutines to wait efficiently. One goroutine can broadcast a change to all waiters, or signal just one.
It’s ideal for producer-consumer queues.
type MessageQueue struct {
messages []string
cond *sync.Cond
}
func NewMessageQueue() *MessageQueue {
mq := &MessageQueue{}
mq.cond = sync.NewCond(&sync.Mutex{}) // Cond needs a Locker (usually a Mutex)
return mq
}
// Producer
func (mq *MessageQueue) Send(msg string) {
mq.cond.L.Lock()
mq.messages = append(mq.messages, msg)
mq.cond.L.Unlock()
mq.cond.Signal() // Wake up one waiting consumer
}
// Consumer
func (mq *MessageQueue) Receive() string {
mq.cond.L.Lock()
// Wait in a loop for the condition. Spurious wakeups are possible.
for len(mq.messages) == 0 {
mq.cond.Wait() // This unlocks the mutex while waiting, then re-locks it
}
msg := mq.messages[0]
mq.messages = mq.messages[1:]
mq.cond.L.Unlock()
return msg
}
The Wait() method is special. It temporarily unlocks the mutex, puts the goroutine to sleep, and re-locks the mutex when it’s awakened. This allows other goroutines to acquire the lock and change the condition (like adding a message). Always wait in a for loop to re-check the condition after waking up. Condition variables are a more advanced primitive, but they are perfect for building efficient blocking queues or resource managers.
While not in the sync package, the semaphore is a classic concurrency pattern easily built in Go. It controls access to a finite number of resources, like limiting database connections or concurrent API calls. You can build one using a buffered channel.
// A semaphore implemented with a channel
type Semaphore struct {
tokens chan struct{}
}
func NewSemaphore(n int) *Semaphore {
return &Semaphore{tokens: make(chan struct{}, n)}
}
// Acquire a token (block if none are free)
func (s *Semaphore) Acquire() {
s.tokens <- struct{}{}
}
// Release a token
func (s *Semaphore) Release() {
<-s.tokens
}
// TryAcquire attempts to get a token without blocking
func (s *Semaphore) TryAcquire() bool {
select {
case s.tokens <- struct{}{}:
return true
default:
return false // No token available immediately
}
}
// Example: Limit to 3 concurrent heavy operations
func ProcessTasks(tasks []string) {
sem := NewSemaphore(3)
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t string) {
defer wg.Done()
sem.Acquire() // Wait for a free slot
defer sem.Release() // Ensure slot is returned
heavyProcessing(t)
}(task)
}
wg.Wait()
}
The channel holds n “tokens.” To acquire access, you send a value into the channel (which blocks if it’s full). To release, you receive a value out. This neatly limits concurrency. I use this pattern all the time to prevent overloading external services or exhausting local resources.
These patterns are your foundation. They are not abstract concepts but practical tools I reach for daily. Start with the simplest tool that solves your problem. Often, a mutex and a wait group are all you need. As your program grows and you profile it, you might introduce a pool for performance or an RWMutex for better read scaling.
The goal is not to use the most advanced pattern, but to write code that is clear, correct, and efficient. Concurrency in Go is a powerful feature, and these synchronization primitives from the sync package are what allow you to harness that power safely. They turn potential chaos into coordinated, predictable execution.