The shared counter problem
You are building a web service that tracks active connections. Every time a request arrives, the counter goes up. When the request finishes, it goes down. You spawn a goroutine per request because handling them sequentially would bottleneck the server. Suddenly the counter shows negative numbers. Or it jumps from 5 to 100 in a single tick. The goroutines are racing to read and write the same memory location, and the CPU scheduler is interleaving their instructions in unpredictable ways.
This is the fundamental tension in concurrent programming. You have multiple threads of execution touching the same data. You need a way to coordinate them without corrupting state or freezing the program. Go gives you two primary tools: mutexes and channels. They solve the same problem from opposite directions. One locks the data in place. The other moves the data to a single owner.
Two ways to handle concurrency
A mutex is a lock. Think of it as a single key to a storage room. Only the goroutine holding the key can enter. Everyone else waits at the door until the key is returned. The room contains shared state. The lock guarantees that only one goroutine reads or writes that state at a time.
A channel is a pipe. Think of it as a conveyor belt or a drop box. Goroutines do not share the data directly. They send values into the channel or receive values out of it. The runtime handles the synchronization. When a goroutine sends on an unbuffered channel, it pauses until another goroutine receives. When it receives, it pauses until a sender arrives. The data travels through the pipe. No two goroutines ever touch the same variable simultaneously.
Go's founding team famously wrote: do not communicate by sharing memory. Instead, share memory by communicating. That proverb points toward channels as the default mental model. It does not mean mutexes are forbidden. It means you should reach for channels first, then fall back to locks when the communication pattern becomes awkward or too slow.
Goroutines are cheap. Channels are not magic.
The mutex approach
Here is the simplest way to protect a shared counter with a lock.
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex // zero value is ready to use; no make() needed
count int // shared state protected by mu
)
func increment() {
mu.Lock() // acquire exclusive access to the critical section
count++ // read, modify, and write back atomically relative to other goroutines
mu.Unlock() // release the lock so waiting goroutines can proceed
}
func main() {
for i := 0; i < 100; i++ {
go increment() // spawn concurrent workers
}
// In production you would wait for completion with sync.WaitGroup
fmt.Println(count) // prints a value close to 100, but timing varies
}
The runtime implements sync.Mutex using atomic CPU instructions and a small amount of kernel assistance. When you call Lock(), the goroutine checks a flag. If the flag is free, it flips it and continues. If the flag is taken, the runtime parks the goroutine on a wait queue. When Unlock() runs, the runtime wakes one waiting goroutine and hands it the flag.
You will almost always see defer mu.Unlock() in production code. Deferring the unlock guarantees that the lock releases even if a panic occurs or an early return triggers. The convention exists because manual unlocks are easy to forget when error handling branches multiply.
Mutexes are fast when contention is low. They become expensive when many goroutines hammer the same lock simultaneously. The runtime has to park and wake goroutines, which involves scheduler bookkeeping and cache line bouncing across CPU cores. High contention turns a lock into a bottleneck.
Locks protect data in place. They do not move it.
The channel approach
Here is how you coordinate the same work without shared variables.
package main
import (
"fmt"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs { // receive until the sender closes the channel
// simulate work
results <- j * 2 // send result back; blocks if receiver is not ready
}
}
func main() {
jobs := make(chan int, 5) // buffered so main can queue work without blocking
results := make(chan int, 5)
// start three workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send work
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // signal that no more work is coming
// collect results
for r := range results {
fmt.Println(r)
}
}
Channels carry type information and directionality. The <- operator points in the direction data flows. chan<- int means send-only. <-chan int means receive-only. The compiler enforces these directions at compile time. If you try to receive from a send-only channel, the compiler rejects the program with a type mismatch error.
The runtime schedules channel operations similarly to mutexes. An unbuffered send parks the sender until a receiver arrives. A buffered send succeeds immediately if space exists, otherwise it parks. Receivers park until data exists. The runtime pairs them up and moves the value directly from sender memory to receiver memory. No intermediate copy is needed.
Channels excel at coordination. They make data flow explicit. You can see exactly where values enter and leave a goroutine. The trade-off is that channels introduce allocation overhead for the channel structure and the buffered queue. They also require careful lifecycle management. Forgetting to close a channel that a range loop depends on will hang the receiver forever.
Channels move data to a single owner. They eliminate shared state by design.
Real-world trade-offs
Consider a caching layer in an HTTP handler. You want to store computed results so repeated requests do not recompute them. A map works well for the cache. Multiple handlers will read and write it concurrently. You could wrap every read and write in a channel send/receive pair. That would work, but it adds allocation, scheduling overhead, and awkward syntax for a simple key lookup. A sync.RWMutex is the standard choice here. It allows multiple concurrent readers while blocking writers. The lock stays local to the cache struct. The code reads naturally.
Now consider a background job processor. Requests arrive, get validated, and need to be processed by a pool of workers. You want to limit concurrency to ten workers. You want to distribute jobs fairly. You want to shut down gracefully when the server stops. A channel pipeline fits perfectly. The HTTP handler sends jobs to a buffered channel. Ten goroutines range over that channel. When the server shuts down, you close the channel. The workers finish their current jobs and exit. No shared state. No locks. The flow is explicit.
Go developers follow a few conventions that make these patterns reliable. The receiver name in methods is usually one or two letters matching the type. A cache method looks like (c *Cache) Get(key string), not (this *Cache). Public names start with a capital letter. Private start lowercase. There are no access modifier keywords. Visibility is purely lexical. Interfaces are accepted as parameters. Structs are returned. This convention keeps dependencies loose and implementations swappable.
Context is plumbing. Run it through every long-lived call site.
Where things go wrong
Mutexes and channels are simple individually. They become dangerous when combined carelessly or misused.
The most common mutex mistake is holding a lock across a network call or disk I/O. The lock blocks all other goroutines while the I/O waits. The program appears frozen. The fix is to shrink the critical section. Lock only around the memory mutation. Release before the I/O.
Another mutex trap is calling Lock() twice on the same goroutine without unlocking. The runtime detects this and panics with sync: Lock of unlocked mutex or sync: Unlock of unlocked mutex. The panic is intentional. It surfaces deadlocks during development instead of letting them hide in production.
Channel mistakes usually involve lifecycle mismatches. Sending on a closed channel panics with panic: send on closed channel. Receiving from a closed channel returns the zero value immediately, which can silently corrupt logic if you do not check the second return value. The idiom value, ok := <-ch tells you whether the channel is still open. Dropping the ok value with _ is acceptable when you intentionally ignore the state, but doing it with errors hides failures. Use _ sparingly.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed or never receives a value. The goroutine stays parked in the runtime scheduler. Memory grows. The program eventually runs out of resources. Always design a cancellation path. Pass a context.Context as the first parameter to long-running functions. Check ctx.Done() alongside channel receives using a select statement.
The worst goroutine bug is the one that never logs.
Picking the right tool
Use a mutex when you need high-frequency, low-latency access to a small piece of shared state. Use a read-write mutex when readers vastly outnumber writers and the data structure supports concurrent reads. Use a channel when you need to coordinate work across goroutines, build pipelines, or fan out and fan in results. Use a buffered channel when you want to decouple producer and consumer rates without blocking. Use a single goroutine with a loop when one task naturally feeds another in a strict sequence. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
Trust the runtime. Measure contention before optimizing.