When to Use Channels vs Mutexes in Go

Use channels for communication between goroutines and mutexes for protecting shared data from concurrent access.

The shared whiteboard problem

You have a background worker that scrapes prices and a main routine that displays them. The scraper runs fast. The display routine runs fast. When they both touch the same slice at the same time, the program crashes with a runtime panic. You need a way to coordinate them. Go gives you two tools for this job. Channels move data between independent workers. Mutexes lock shared memory so only one worker touches it at a time.

Channels move data. Mutexes guard memory.

Think of a shared variable like a whiteboard in a busy office. If three people try to write on it at once, the text overlaps and becomes unreadable. A mutex is a single pen. Only the person holding the pen can write. Everyone else waits in line. When the writer finishes, they hand the pen back.

A channel is a pneumatic tube system. Instead of fighting over the whiteboard, each person writes their update on a slip of paper, drops it in the tube, and walks away. The person on the other end pulls the slips out one by one and updates the whiteboard safely. The data travels through the tube. The whiteboard stays private.

Here is the simplest channel pattern. Spawn one worker, send a message, close the channel.

package main

import "fmt"

func main() {
    // unbuffered channel blocks until both sides are ready
    ch := make(chan string)

    // worker runs in background and sends result
    go func() {
        ch <- "done"
    }()

    // main blocks here until the worker writes to the channel
    result := <-ch
    fmt.Println(result)
}

Here is the simplest mutex pattern. Lock the state, mutate it, unlock it.

package main

import (
    "fmt"
    "sync"
)

func main() {
    // mutex starts unlocked
    var mu sync.Mutex
    var count int

    // lock prevents other goroutines from reading or writing count
    mu.Lock()
    count++
    // unlock releases the lock so others can proceed
    mu.Unlock()

    fmt.Println(count)
}

The pen stays in one hand. The tube carries the message.

How the runtime handles the wait

Go does not map goroutines directly to operating system threads. The runtime scheduler multiplexes thousands of goroutines across a small pool of OS threads. When a goroutine blocks on a channel or a mutex, the scheduler parks it and runs something else on that thread. This is why concurrency in Go feels lightweight. You are not paying for thread creation. You are paying for scheduler bookkeeping.

Channels are typed. The compiler enforces that you send and receive the exact same type. A channel also carries a zero value. If you read from a closed channel, you get the zero value for that type. If you write to a closed channel, the program panics. The runtime tracks the open and closed state inside the channel header.

Mutexes are untyped. They do not carry data. They only track a binary state: locked or unlocked. The sync.Mutex type contains a few machine words that the runtime uses to implement a fast path for uncontended locks and a fallback queue for contended locks. When a goroutine calls Lock(), it checks the state. If free, it grabs it and continues. If taken, it adds itself to a wait queue and yields the thread.

The compiler rejects programs that violate basic type rules before runtime ever starts. If you try to send a string into an integer channel, you get cannot use "hello" (untyped string constant) as int value in send. If you forget to import the synchronization package, you get undefined: sync. These errors save you from guessing why a lock is not working.

Blocking is a feature, not a bug. It forces your code to wait for reality.

A realistic concurrent aggregator

Real code rarely updates a single counter. It usually collects results from multiple sources. Imagine a service that fetches exchange rates from three different APIs and merges them into a single map. You can solve this with a mutex, or you can solve it with channels. Both work. They express different design choices.

Here is the mutex approach. The shared map lives in memory. Every worker locks it before writing.

package main

import (
    "fmt"
    "sync"
)

// Fetcher retrieves rates and writes to a shared map.
// ctx carries cancellation deadlines for the HTTP calls.
func Fetcher(ctx context.Context, id string, rates map[string]float64, mu *sync.Mutex) {
    // simulate network call
    val := 1.0 + float64(len(id))

    // lock protects the map from concurrent writes
    mu.Lock()
    rates[id] = val
    // unlock immediately after mutation to minimize contention
    mu.Unlock()
}

func main() {
    var mu sync.Mutex
    rates := make(map[string]float64)
    var wg sync.WaitGroup

    for _, id := range []string{"USD", "EUR", "GBP"} {
        wg.Add(1)
        go func(currency string) {
            defer wg.Done()
            Fetcher(context.Background(), currency, rates, &mu)
        }(id)
    }

    wg.Wait()
    fmt.Println(rates)
}

The mutex version keeps the data structure close to the workers. It works well when the map is small and writes are fast. It also requires you to pass the mutex pointer everywhere. Go convention says receiver names should be one or two letters matching the type, so you would typically wrap this in a struct with a (a *Aggregator) Fetch(...) method. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible. You would add error handling around the network call and return early if the context is cancelled.

Here is the channel approach. Each worker sends its result back. The main routine collects them and builds the map.

package main

import (
    "fmt"
    "context"
)

// Result carries a single fetch outcome back to the collector.
type Result struct {
    Currency string
    Rate     float64
}

// Fetcher sends its result through the channel instead of mutating shared state.
func Fetcher(ctx context.Context, id string, out chan<- Result) {
    val := 1.0 + float64(len(id))
    // directional channel type enforces send-only usage in this function
    out <- Result{Currency: id, Rate: val}
}

func main() {
    // buffered to 3 so workers can return without blocking the collector
    ch := make(chan Result, 3)

    for _, id := range []string{"USD", "EUR", "GBP"} {
        go Fetcher(context.Background(), id, ch)
    }

    // close channel after all sends complete to signal completion
    close(ch)

    rates := make(map[string]float64)
    // range over channel until it is closed and drained
    for r := range ch {
        rates[r.Currency] = r.Rate
    }

    fmt.Println(rates)
}

The channel version pushes the aggregation logic to the main routine. Workers stay pure. They fetch and send. The collector reads and merges. This matches the "accept interfaces, return structs" mantra. You pass a channel interface into the worker. You return a concrete result struct. The data flows in one direction.

Lock the map, or pass the results. Don't do both.

When things go wrong

Concurrency bugs hide until production. The two most common failures are deadlocks and race conditions.

A deadlock happens when goroutines wait on each other in a circle. If you send on an unbuffered channel and no one is receiving, the sender blocks. If the receiver is also waiting on that same sender, nothing moves. The runtime detects this and aborts with fatal error: all goroutines are asleep - deadlock!. The fix is usually to add a buffer, restructure the pipeline, or add a context timeout so goroutines can bail out. Goroutine leaks happen when a worker waits on a channel that never gets closed. Always have a cancellation path.

A race condition happens when you read or write shared memory without a lock. The compiler cannot catch this at build time. You need the race detector. Run your program with go run -race main.go. If two goroutines touch the same variable without synchronization, the detector prints a stack trace showing the conflicting reads and writes. The program might appear to work on your laptop. It will corrupt data under load.

The compiler also catches obvious mistakes early. If you forget to use a variable, you get declared and not used. If you try to read from a channel of the wrong type, you get a type mismatch error. Trust the compiler. Argue logic, not formatting. Run gofmt on save. It removes style debates so you can focus on correctness.

A deadlock is just a conversation where everyone waits for the other to speak first.

Pick the tool that matches the flow

Use a channel when one goroutine produces data and another consumes it. Use a channel when you need to sequence work in a pipeline. Use a channel when you want to fan out requests and fan in results without sharing memory. Use a mutex when multiple goroutines need to read and write the same data structure in place. Use a mutex when the data is large and copying it would waste CPU cycles. Use a read-write mutex when reads vastly outnumber writes and you want to allow concurrent readers. Use sequential code when you do not need concurrency. The simplest thing that works is usually the right thing.

Context is plumbing. Run it through every long-lived call site. Pass context.Context as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This keeps your concurrent code from hanging forever when a client disconnects.

Where to go next