When diving into concurrent programming in Go, two fundamental synchronization primitives often come up: mutexes and semaphores. While they might seem similar at first glance, they serve different purposes and understanding when to use each is crucial for writing safe, efficient concurrent code.
In this post, we’ll explore both concepts through relatable analogies and practical Go examples. By the end, you’ll have a clear understanding of when to reach for a mutex versus a semaphore.
Mutex: The Library Book
A mutex (short for “mutual exclusion“) is like a popular library book with only one copy. Only one person can check it out at a time, and they must return it before the next person can borrow it. When they’re done reading, they return the book to the library, and the next person in line can check it out.
In Go, a sync.Mutex ensures that only one goroutine can access a critical section of code at a time. It’s binary: locked or unlocked. There’s no middle ground.
package main
import (
"fmt"
"sync"
"time"
)
var (
counter int
mutex sync.Mutex
)
func increment() {
mutex.Lock()
defer mutex.Unlock()
temp := counter
time.Sleep(1 * time.Millisecond)
counter = temp + 1
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Printf("Final counter: %d\n", counter)
}
The mutex is the librarian managing the book checkout. It doesn't matter how many people want to read the book, only one person can have it at a time. Simple, predictable, and perfect when you need exclusive access.
Semaphore: The Computer Lab
A semaphore, on the other hand, is like a computer lab with 5 computers available. The lab monitor keeps track of how many computers are free. When a computer becomes available, the next student can use it. If all 5 computers are in use, new students must wait.
In Go, semaphores can be implemented using buffered channels (for educational purposes) or using the official golang.org/x/sync/semaphore package (for production use). They allow a specified number of goroutines to access a resource simultaneously, not just one.
Understanding Semaphores: The Simple Way
Here's a simple semaphore implementation using a buffered channel to illustrate the concept. This example limits concurrent computer usage to manage lab resources:
package main
import (
"fmt"
"sync"
"time"
)
type Semaphore struct {
tokens chan struct{}
}
func NewSemaphore(capacity int) *Semaphore {
return &Semaphore{
tokens: make(chan struct{}, capacity),
}
}
func (s *Semaphore) Acquire() {
s.tokens <- struct{}{}
}
func (s *Semaphore) Release() {
<-s.tokens
}
var sem = NewSemaphore(3)
func useComputer(id int) {
sem.Acquire()
defer sem.Release()
fmt.Printf("Student %d: Using computer...\n", id)
time.Sleep(100 * time.Millisecond)
fmt.Printf("Student %d: Finished using computer\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
useComputer(id)
}(i)
}
wg.Wait()
fmt.Println("All students finished using computers")
}
Notice how the semaphore controls the concurrency without needing a mutex? This is because we’re limiting access to an external resource (computers), not protecting shared mutable state. Only 3 students can use computers simultaneously, while the others wait.
Production-Ready: Using the Official Package
For production code, you should use the official golang.org/x/sync/semaphore package, which provides a weighted semaphore with context support and better performance:
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/semaphore"
)
var sem = semaphore.NewWeighted(3)
func useComputer(ctx context.Context, id int) error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
fmt.Printf("Student %d: Using computer...\n", id)
time.Sleep(100 * time.Millisecond)
fmt.Printf("Student %d: Finished using computer\n", id)
return nil
}
func main() {
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if err := useComputer(ctx, id); err != nil {
fmt.Printf("Student %d: failed: %v\n", id, err)
}
}(i)
}
wg.Wait()
fmt.Println("All students finished using computers")
}
The official package offers additional benefits like context cancellation support (so you can timeout or cancel semaphore acquisition) and weighted acquisition (allowing different goroutines to request different amounts of capacity). This is perfect for rate limiting, connection pooling, or controlling access to resource pools.
The Moral of the Story
Both mutexes and semaphores are tools in your concurrency toolkit, each serving different purposes:
Mutex: “Only one at a time, please.”
Semaphore: “We have N computers available, but no more.”
Understanding when to use each is key to writing safe, efficient concurrent Go programs. The mutex is your strict bouncer, ensuring exclusive access. The semaphore is your capacity manager, controlling the flow while allowing controlled concurrency.
In the world of Go concurrency, both have their place. Choose wisely, and your programs will thank you with predictable behavior and optimal performance.