← Back to list

Mutexes in Go Made Easy: When Channels Aren’t Enough

You learned goroutines. You learned channels. Then you ran 500 goroutines at once and the counter you made said 417 :(

Er. Abhay Tiwari · 2026-05-27 05:35 · 0 claps · 7.2 min read
#go #golang #concurrency #mutex #software-engineering
Open on Medium ↗

Mutexes in Go Made Easy: When Channels Aren’t Enough

You learned goroutines. You learned channels. Then you ran 500 goroutines at once and the counter you made said 417 :(

Here’s how to fix that.

First of all. You did everything Right Okay?… (It’s not your fault >.o)

Why did it show 417 instead of 500?

Imagine a small cafe tracks how many times each customer orders coffee. There’s one shared notebook on the counter ok?…. Everytime someone orders a barista (still don’t know why we don’t call it coffee directly? lemme know):

  1. Reads the current number for that customer
  2. Adds 1
  3. Writes it back

Now hire 50 baristas and tell them all to update the same customer’s count at the same time.

Two baristas read 23 at the same moment. Both of them will write 24 right?? greatttt you lost an order already.

That’s not a bug in your business logic. That’s a race condition.

When two different goroutines stepping on the same data at the same time it’s called race condition.

Go’s channels are brilliant for passing work between goroutines. But the real world still has shared state: maps, counter, caches, in-memory config. For that, you need a mutex.

Quick Recap: Where we left off

If you’ve been following this seris:

  1. Goroutines: are lightweight workers ( go func() )
  2. Channels: are safe pipes between workers (“cause they don’t communicate by sharing memory”)

Channels follow a beautiful rule:

Don’t communicate by sharing memory; share memory by communicating.

And you should live by that whenever you can.

But sometimes you genuinely need multiple goroutines touching the same map or counter. When that happens, you don’t guess. You Lock.

The Broken Version (Read This First)

Let’s count how many emails we’ve “sent” per address. We’ll launch one goroutine per send — just like real concurrent traffic.

type emailCounter struct {
    counts map[string]int
}
func (ec emailCounter) inc(email string) {
    ec.counts[email] = ec.counts[email] + 1
}
func main() {
    ec := emailCounter{counts: make(map[string]int)}
    email := "norman@bates.com"
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            ec.inc(email)
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println(ec.counts[email]) // expected 100... often isn't
}

Run it ten times. Sometimes you get 100. Sometimes 97. Sometimes 94.

Nothing crashed. No red error message.

That’s what makes races nasty they feel fine in development and ruin your weekend in production.

What Is a Race Condition?

A race happens when:

  1. Two or more goroutines access the same variable
  2. At least one of them is writing
  3. And there’s no synchronisation telling Go who’s allowed to go first

The inc function looks like one line, but under the hood it's three steps:

READ  counts[email]
ADD   1
WRITE counts[email]

Goroutine A and B can interleave like this:

A: READ  → 23
B: READ  → 23
A: WRITE → 24
B: WRITE → 24   ← should be 25, but we lost A's update

Think of it like two people editing the same Google Doc cell without seeing each other’s cursor. Both save. One edit vanishes.

Catch Races Before Your Users Do: go run -race

Go ships with a race detector. Turn it on:

go run -race .

Or in tests:

go test -race ./...

It slows your program down so you use it in CI and local dev, not on every production request. But when it fires, it prints exactly which goroutines fought over which variable.

If you write concurrent Go and you’re not running -race in tests, you're flying blind man. Use the stick they gave you :) to fly haha.

Enter the Mutex: One Key, One Bathroom

A mutex (mutual exclusion) is a lock.

Analogy: a cafe bathroom with one key on a hook.

  • You take the key → you’re the only one inside
  • Someone else arrives → they wait until you hang the key back
  • You hang the key → the next person goes in

In Go, that’s sync.Mutex:

import "sync"
var mu sync.Mutex
mu.Lock()
// only ONE goroutine runs this block at a time
mu.Unlock()

Rule: every Lock() needs an Unlock(). Miss one and you've got a deadlock or a leaked lock.

The Fix: safeCounter with Lock and defer

Here’s the pattern from real exercises a counter backed by a map, protected by a mutex:

type safeCounter struct {
    counts map[string]int
    mu     *sync.Mutex
}

func (sc safeCounter) inc(key string) {
    sc.mu.Lock()
    defer sc.mu.Unlock()
    sc.slowIncrement(key)
}

func (sc safeCounter) val(key string) int {
    sc.mu.Lock()
    defer sc.mu.Unlock()
    return sc.slowVal(key)
}

Why defer sc.mu.Unlock()?

defer runs when the function returns even if you return early or panic (before recovery).

It’s like a promise: “No matter how I leave this bathroom no matter there’s shit blasted on the floor, I’m hanging the bathroom key back :)”

sc.mu.Lock()
defer sc.mu.Unlock()
// ... do work ...
// Unlock happens automatically here

Without defer, one forgotten return path leaves the lock held forever. Every other goroutine waits until the heat death of the universe.

Why slowIncrement?

func (sc safeCounter) slowIncrement(key string) {
    tempCounter := sc.counts[key]
    time.Sleep(time.Microsecond) // simulates real work
    tempCounter++
    sc.counts[key] = tempCounter
}

That tiny sleep widens the race window on purpose. In production, your “slow” might be a DB call, JSON parsing, or cache lookup. The mutex doesn’t care how slow the work is it only cares that only one goroutine does read-modify-write at a time.

Putting It Together: 453 Goroutines, One Email

The test launches hundreds of goroutines hammering the same email:

sc := safeCounter{
    counts: make(map[string]int),
    mu:     &sync.Mutex{},
}

var wg sync.WaitGroup
for i := 0; i < 453; i++ {
    wg.Add(1)
    go func(email string) {
        sc.inc(email)
        wg.Done()
    }(test.email)
}
wg.Wait()
fmt.Println(sc.val(test.email)) // 453 — every time

Remember the closure trap from the goroutines article? Same rule here:

// BROKEN all goroutines might see the same email
for i := 0; i < n; i++ {
    go func() {
        sc.inc(email)
    }()
}

// CORRECT each goroutine gets its own copy
go func(e string) {
    sc.inc(e)
}(email)

Channels vs Mutexes: When to Use What?

Worker goroutine pattern (channel-only, no mutex on the map):

type request struct { email string }

func worker(reqs <-chan request, done chan<- struct{}) {
    counts := make(map[string]int) // only THIS goroutine touches the map
    for req := range reqs {
        counts[req.email]++
    }
    close(done)
}

One goroutine owns the map. Everyone else sends messages. Zero races, zero mutex.

Use a mutex when refactoring to channels would be awkward or when you’re wrapping a library that already exposes shared state.

Don’t mix randomly. Pick a style per struct and stick to it.

Read-Heavy Workloads: sync.RWMutex

Sometimes many goroutines read but rarely write.

Example: a config map loaded once at startup, read thousands of times per second.

A regular Mutex blocks everyone during a read. That's like letting one person use the bathroom while fifty people only wanted to glance at the sign on the door.

sync.RWMutex has two modes:

type safeCounter struct {
    counts map[string]int
    mu     *sync.RWMutex
}

func (sc safeCounter) inc(key string) {
    sc.mu.Lock()         // writer exclusive
    defer sc.mu.Unlock()
    sc.slowIncrement(key)
}

func (sc safeCounter) val(key string) int {
    sc.mu.RLock()        // reader shared
    defer sc.mu.RUnlock()
    return sc.counts[key]
}

Writes still take the full lock. Reads can overlap. Perfect when reads dominate.

Don’t reach for RWMutex by default. A plain Mutex is simpler and often fast enough. Upgrade when profiling says reads are the bottleneck.

Mutex Pitfalls (The Stuff That Ruins Fridays)

1. Forgetting to unlock

mu.Lock()
if something {
    return // oops no Unlock
}
mu.Unlock()

Fix: defer mu.Unlock() right after Lock().

2. Locking the same mutex twice (deadlock)

mu.Lock()
mu.Lock() // blocks forever you're waiting on yourself

Go’s Mutex is not reentrant (google the word). One goroutine can't Lock() twice without unlocking first.

3. Copying a struct that contains a sync.Mutex

type Counter struct {
    mu sync.Mutex
    n  int
}
c2 := c1 // NEVER copies the mutex in a locked/broken state

Mutexes live on the struct you share usually via pointer (*safeCounter or mu *sync.Mutex on a shared heap object). Don't copy them.

4. Holding a lock too long

mu.Lock()
result := callSlowAPI() // every other goroutine frozen
mu.Unlock()

Fix: lock only around the shared memory access, not around network I/O.

5. Different mutexes for the same data

// Goroutine A locks mu1, wants mu2
// Goroutine B locks mu2, wants mu1
// 💀 deadlock

Fix: lock ordering discipline, or one mutex per protected resource.

The Complete Mental Model

Back to the cafe notebook:

Quick Reference

import "sync"
var mu sync.Mutex

// Basic lock
mu.Lock()

// critical section — touch shared data only here
mu.Unlock()

// Idiomatic
mu.Lock()
defer mu.Unlock()

// Wait for goroutines
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    // work
}()
wg.Wait()

// Read/write lock
var rw sync.RWMutex
rw.RLock()   // many readers
rw.RUnlock()
rw.Lock()    // one writer
rw.Unlock()

// Detect races
// go test -race ./...

Summary

What’s Next?

Mutexes fix shared memory. But production Go also needs:

  • Timeouts and cancellationcontext.Context (your API shouldn't hang forever)
  • Bounded concurrency → worker pools (channels + a fixed number of workers)
  • **select** deep-dive → if you want timeouts on multiple channels in one loop

If channels taught you how workers talk, mutexes teach you what to do when they must share one notebook.

Next up in the series: worker pools same goroutines and channels you already know, but with a ceiling so you don’t launch 10,000 goroutines at once.

All code examples in this article come from real exercises on Boot.dev. If you’re learning Go from scratch, it’s one of the best interactive courses out there.

Missed the earlier parts? Start with Goroutine Made Easy and Channels in Go, then come back here.

Found this helpful? Follow for more Go deep-dives.


메타데이터
post_id
4ff0c6ef0d99
slug
mutexes-in-go-made-easy-when-channels-arent-enough-4ff0c6ef0d99
url
https://medium.com/@abhay.tiwari.er/mutexes-in-go-made-easy-when-channels-arent-enough-4ff0c6ef0d99
canonical_url
https://medium.com/@abhay.tiwari.er/mutexes-in-go-made-easy-when-channels-arent-enough-4ff0c6ef0d99
author_url
https://medium.com/@abhay.tiwari.er
status
ok
fetched_at
2026-06-26 12:24:55