The first time I ran into a race condition, I spent an entire afternoon convinced my computer was broken. My program counted things. Sometimes the count was right, sometimes it was off by a few, and there was no pattern I could see. The code looked fine. It was fine, as long as only one thing was running at a time. The trouble started when I added goroutines.
If you’ve ever had a bug that only shows up “sometimes,” there’s a decent chance you’ve met the same problem. This post walks through the two classic tools for fixing it, mutexes and semaphores, using everyday analogies and working Go code you can run yourself.
So what’s actually going wrong?
Say you have a counter set to 5, and two goroutines both want to add 1 to it. Each one reads the current value, adds 1, and writes the result back. Sounds harmless.
But here’s what can happen. Goroutine A reads 5. Before it writes anything, goroutine B also reads 5. A writes 6. B writes 6. You expected 7 and got 6. Nobody crashed, nothing threw an error, you just quietly lost an update.
That’s a race condition, and the piece of code where it happens (the read-add-write part) is called a critical section. What we need is a way to control who’s allowed into that section and when.
Mutex: one key, one person
A mutex (short for mutual exclusion) is basically a lock. Before a goroutine enters the critical section, it locks the mutex. When it’s finished, it unlocks it. If someone else tries to lock it in the meantime, they have to wait.
The analogy I like is the bathroom at a small coffee shop. There’s one bathroom and one key hanging behind the counter. You grab the key, you use the bathroom, you bring the key back. If somebody else needs to go while you have it, they wait by the counter. Simple.
There’s one detail in that analogy worth holding onto: the person who took the key is the one who returns it. You’d find it pretty strange if a random customer walked up and handed your key back for you. Mutexes work the same way. The goroutine that locks it is the one responsible for unlocking it. That’s what people mean when they say a mutex has ownership.
Here’s the counter problem fixed with a mutex in Go:
package main
import (
"fmt"
"sync"
)
var (
counter int
mu sync.Mutex
)
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock() // grab the key
defer mu.Unlock() // hand it back when we're done
counter++ // only one goroutine can be here at a time
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Final counter:", counter)
}
Run it as many times as you like and you’ll get 1000 every time. Now try deleting the mu.Lock() and mu.Unlock() lines and running it again with go run -race main.go. The race detector will yell at you, and the final number will probably be wrong. It’s a good little experiment if you want to see the bug with your own eyes.
Notice the defer mu.Unlock(). That’s a habit worth building. defer makes sure the unlock happens when the function returns, even if something panics halfway through. Forgetting to unlock is one of the easiest ways to freeze your whole program.
Semaphore: a parking lot with a counter
A mutex lets exactly one goroutine in. But sometimes one is too strict. Maybe you have a database that can handle three connections at once, or an API that lets you make five requests at a time. You don’t want to limit things to one, but you don’t want a free-for-all either.
That’s where a semaphore comes in. A semaphore is a counter that starts at some number, say N. Every time a goroutine wants in, the counter goes down by one. Every time one leaves, it goes back up. When it hits zero, anyone new has to wait.
Think of a parking lot with one of those electronic signs at the entrance showing how many spaces are free. The lot has 10 spaces, so the sign starts at 10. A car drives in, it drops to 9. A car leaves, it goes back up. When the sign reads 0, the next car sits at the gate until somebody pulls out.
Here’s the part that makes it different from a mutex: the sign doesn’t care which car leaves. It just knows a space opened up. Semaphores don’t have ownership. Any goroutine can release a slot, not just the one that took it.
Go doesn’t ship with a type called Semaphore, but it doesn’t really need one. A buffered channel does the job nicely. The channel’s capacity is the number of parking spaces. Sending a value in takes a space, and receiving a value out frees one.
package main
import (
"fmt"
"sync"
"time"
)
func queryDatabase(id int, pool chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
pool <- struct{}{} // take a parking space (waits if the lot is full)
defer func() { <-pool }() // free the space when we leave
fmt.Printf("Worker %d is running a query\n", id)
time.Sleep(500 * time.Millisecond) // pretend this is real work
fmt.Printf("Worker %d is done\n", id)
}
func main() {
pool := make(chan struct{}, 3) // a lot with 3 spaces
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1)
go queryDatabase(i, pool, &wg)
}
wg.Wait()
fmt.Println("All queries finished")
}
When you run this, watch the output. The workers come through in little bursts of three. Ten goroutines are launched right away, but only three are ever inside the “lot” at once. The rest are waiting at the gate.
If you need something fancier, the Go team maintains golang.org/x/sync/semaphore. It gives you a weighted semaphore, so one task can grab two or three slots at once if it’s heavier than the others, and it works with context so a waiting goroutine can give up after a timeout. For most everyday cases, though, the buffered channel trick is all you need.
Isn’t a semaphore of 1 just a mutex?
This trips up a lot of people, and honestly it tripped me up for a while too. A semaphore with a count of 1 is called a binary semaphore. Only one goroutine can hold it at a time, so it sounds identical to a mutex.
The behavior is similar, but the intent is different. A mutex is about protecting something: I lock it, I use the thing, I unlock it. A binary semaphore is more about signaling: one goroutine waits, and a completely different goroutine says “okay, go.”
For the analogy, forget the bathroom key and picture a doorbell. Someone in the kitchen finishes cooking and rings a bell. Someone upstairs, who’s been waiting, hears it and comes down to eat. The person who rang the bell isn’t the person who was waiting. That’s signaling.
In Go, a plain unbuffered channel is the natural way to do this:
package main
import (
"fmt"
"time"
)
func main() {
foodReady := make(chan struct{})
go func() {
fmt.Println("Kitchen: cooking dinner...")
time.Sleep(1 * time.Second)
fmt.Println("Kitchen: ringing the bell!")
foodReady <- struct{}{}
}()
fmt.Println("Upstairs: waiting for dinner...")
<-foodReady
fmt.Println("Upstairs: heading down to eat!")
}
The main goroutine sits at <-foodReady doing nothing until the kitchen goroutine rings the bell. You couldn’t do this cleanly with a mutex, because Go expects the goroutine that locked it to be the one that unlocks it.
Putting a real example together
Toy examples are fine, but it helps to see both tools working side by side. Here’s a small program that “downloads” a list of files. We only want three downloads running at once (semaphore), and every download adds its size to a shared total (mutex).
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Stats struct {
mu sync.Mutex
totalBytes int
filesDone int
}
func (s *Stats) Add(bytes int) {
s.mu.Lock()
defer s.mu.Unlock()
s.totalBytes += bytes
s.filesDone++
}
func download(file string, limiter chan struct{}, stats *Stats, wg *sync.WaitGroup) {
defer wg.Done()
limiter <- struct{}{}
defer func() { <-limiter }()
fmt.Printf("Starting %s\n", file)
time.Sleep(time.Duration(200+rand.Intn(600)) * time.Millisecond)
size := 1000 + rand.Intn(9000)
stats.Add(size)
fmt.Printf("Finished %s (%d bytes)\n", file, size)
}
func main() {
files := []string{
"photo1.jpg", "photo2.jpg", "report.pdf", "song.mp3",
"video.mp4", "notes.txt", "backup.zip", "slides.pptx",
}
limiter := make(chan struct{}, 3) // at most 3 downloads at once
stats := &Stats{}
var wg sync.WaitGroup
for _, f := range files {
wg.Add(1)
go download(f, limiter, stats, &wg)
}
wg.Wait()
fmt.Printf("\nDownloaded %d files, %d bytes total\n", stats.filesDone, stats.totalBytes)
}
The channel keeps the number of active downloads in check, and the mutex inside Stats makes sure nobody loses an update when they add to the total. Two different problems, two different tools.
Things that will bite you
Deadlock. This is the big one. It happens when two goroutines each hold something the other needs, and neither will let go. Picture two people meeting in a narrow hallway, each politely waiting for the other to step aside, forever. In code, it usually looks like goroutine A locking mutex 1 then mutex 2, while goroutine B locks mutex 2 then mutex 1. The easiest fix is to pick an order and always lock things in that order, everywhere.
Here’s a deliberately broken example so you can see what it looks like:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var muA, muB sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
muA.Lock()
time.Sleep(100 * time.Millisecond)
muB.Lock() // waits for B forever
fmt.Println("goroutine 1 got both")
muB.Unlock()
muA.Unlock()
}()
go func() {
defer wg.Done()
muB.Lock()
time.Sleep(100 * time.Millisecond)
muA.Lock() // waits for A forever
fmt.Println("goroutine 2 got both")
muA.Unlock()
muB.Unlock()
}()
wg.Wait()
}
Go is nice enough to notice when every goroutine is stuck and will crash with fatal error: all goroutines are asleep - deadlock!. In a bigger program with other goroutines still running, though, it won’t always catch it, and your app will just hang.
Forgetting to unlock or release. One missing Unlock() and everyone waiting on that mutex waits forever. Using defer right after you lock is the simplest protection.
Starvation. Sometimes a goroutine keeps losing the race to grab a lock and never gets its turn, a bit like standing at a crowded bar and watching everyone else get served. Go’s mutex has some built-in fairness to reduce this, but it’s still worth knowing the term.
Copying a mutex. This one is Go-specific. If you pass a struct that contains a sync.Mutex by value, you copy the mutex too, and now you have two separate locks that protect nothing. Pass pointers instead. go vet will warn you about this, so run it.
So which one do I use?
If you have one shared thing and only one goroutine should touch it at a time, like a counter, a map, or a cache, use a mutex.
If you have a limited number of something, like connections, worker slots, or requests per second, use a semaphore. In Go, that usually means a buffered channel.
If one goroutine needs to tell another that something is ready, use a channel as a signal.
And if you’re ever unsure, go back to the pictures. Is it a bathroom with one key? Mutex. Is it a parking lot with a sign counting free spaces? Semaphore. Is it a doorbell? Channel. It sounds almost too simple, but that little mental check has saved me from more than one late-night debugging session.