The "Guardians" of the Concurrency
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() // Check out the book
defer mutex.Unlock() // Always return the book, even if panic occurs
// Critical section: only one goroutine can be here at a time
temp := counter
time.Sleep(1 * time.Millisecond) // Simulate reading the book
counter = temp + 1
}
func main() {
var wg sync.WaitGroup
// Launch 10 goroutines trying to increment
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Printf("Final counter: %d\n", counter) // Will always prints 10
}
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"
)
// A semaphore implemented with a buffered channel
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{}{} // Take a token
}
func (s *Semaphore) Release() {
<-s.tokens // Return the token
}
var sem = NewSemaphore(3) // // Limit to 3 concurrent computer users
// Simulate using a computer in the lab
func useComputer(id int) {
sem.Acquire() // Get permission to use a computer
defer sem.Release() // Return the computer when done
fmt.Printf("Student %d: Using computer...\n", id)
time.Sleep(100 * time.Millisecond) // // Simulate work time
fmt.Printf("Student %d: Finished using computer\n", id)
}
func main() {
var wg sync.WaitGroup
// Tell 10 students to use computers
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) // Limit to 3 concurrent computer users
// Simulate using a computer in the lab
func useComputer(ctx context.Context, id int) error {
if err := sem.Acquire(ctx, 1); err != nil {
return err // Context cancelled or timeout
}
defer sem.Release(1)
fmt.Printf("Student %d: Using computer...\n", id)
time.Sleep(100 * time.Millisecond) // Simulate work time
fmt.Printf("Student %d: Finished using computer\n", id)
return nil
}
func main() {
ctx := context.Background()
var wg sync.WaitGroup
// Tell 10 students to use computers
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.