Go Idioms

Don't Communicate by Sharing Memory; Share Memory by Communicating

This idiom means you should avoid using mutexes and shared variables to coordinate goroutines; instead, pass data directly between goroutines using channels.

The origin story

You are building a background job processor. One goroutine reads tasks from a database. Another goroutine executes them. Both need to track how many tasks have finished. The obvious move is to create a shared integer variable and increment it from both sides. You run the program. It works once. You run it again. The final count is off by three. You add a mutex. The count stabilizes. You add a third goroutine to handle logging. The program hangs. You spend an afternoon untangling lock ordering.

This exact scenario is why the Go team carved a proverb into the language documentation. The phrase sounds like a tongue twister, but it describes a fundamental shift in how you structure concurrent programs. Instead of protecting shared state with locks, you move the state itself through a controlled pipe. The pipe becomes the synchronization point. The data carries the coordination.

What the proverb actually means

The first half of the proverb describes the traditional approach to concurrency. Multiple threads or goroutines point at the same memory address. They read and write it freely. To prevent corruption, you wrap every access in a mutex. The mutex acts like a bouncer at a club door. Only one person gets in at a time. Everyone else waits in line. The bouncer works, but the logic for who gets in, when they leave, and what they do inside gets scattered across the codebase. You end up reasoning about lock acquisition order, timeout handling, and deadlocks.

The second half flips the model. You stop pointing multiple goroutines at the same variable. Instead, you give the variable to exactly one goroutine at a time. That goroutine does the work, then passes the variable to the next one through a channel. The channel is the bouncer, but it is also the conveyor belt. You cannot pass the data without going through the belt. You cannot use the data without taking it off the belt. Ownership transfers explicitly. Race conditions disappear because only one goroutine ever holds the reference.

Think of a restaurant kitchen. The shared-memory approach is like having a single whiteboard where every chef scribbles orders. Chefs bump elbows. They overwrite each other. They need a strict rule about who can write when. The channel approach is like a ticket printer. The host prints an order. The ticket slides through a slot. Only the cook at the slot can grab it. The cook finishes the dish, slides a new ticket back. The flow is linear. The coordination is built into the physical path of the ticket.

A minimal example

Start with a simple counter. The mutex version requires explicit locking and unlocking around every mutation. The channel version moves the counter itself.

package main

import (
	"fmt"
	"sync"
)

// MutexCounter tracks a value using explicit locking.
type MutexCounter struct {
	mu    sync.Mutex
	value int
}

// Increment adds one to the counter while holding the lock.
func (c *MutexCounter) Increment() {
	c.mu.Lock()
	defer c.mu.Unlock() // Release the lock when the function returns
	c.value++
}

// ChannelCounter tracks a value by moving it through a pipe.
type ChannelCounter struct {
	in  chan int
	out chan int
}

// NewChannelCounter creates a goroutine that owns the counter state.
func NewChannelCounter() *ChannelCounter {
	c := &ChannelCounter{
		in:  make(chan int),
		out: make(chan int),
	}
	// The goroutine owns the local variable. No other code touches it.
	go func() {
		val := 0
		for {
			select {
			case inc := <-c.in:
				val += inc
			case c.out <- val:
				// Value is sent out. The receiver now owns the snapshot.
			}
		}
	}()
	return c
}

func main() {
	// Mutex approach requires careful locking at every call site.
	mc := &MutexCounter{}
	mc.Increment()
	fmt.Println("Mutex:", mc.value)

	// Channel approach moves data instead of locking it.
	cc := NewChannelCounter()
	cc.in <- 1
	fmt.Println("Channel:", <-cc.out)
}

The receiver name follows Go convention: one or two letters matching the type, like c for Counter. The mutex version works, but every function that touches value must remember to lock. The channel version isolates the state inside a single goroutine. The only way to change the state is to send a message into in. The only way to read the state is to pull from out. The synchronization happens automatically at the channel boundary.

How the runtime handles it

When you send on an unbuffered channel, the sending goroutine parks. It stops executing. It stays parked until another goroutine receives from that exact channel. The moment the receive happens, the runtime unparks both goroutines and resumes them. The data transfer and the synchronization are the same operation. You do not need a separate lock to guarantee that the receiver sees the updated value. The channel guarantees it.

Buffered channels add a queue. Sending only parks if the buffer is full. Receiving only parks if the buffer is empty. This decouples the timing slightly, but the ownership rule stays the same. The data lives in the channel buffer until someone pulls it out. You still cannot read the data from two places at once.

This is the hidden benefit of the proverb. Channels do not just move bytes. They enforce a timeline. When you write ch <- data, you are saying "I am done with this data, and I will wait until someone else is ready to take it." When you write data := <-ch, you are saying "I will wait until someone else is ready to give it to me." The waiting is the synchronization. You get coordination for free.

The Go scheduler manages this parking and unparking behind the scenes. It maps thousands of goroutines onto a handful of OS threads. When a goroutine blocks on a channel, the scheduler pulls another goroutine from the ready queue and puts it on the same thread. The thread never sits idle. The cost of the handoff is tiny, usually a few hundred nanoseconds. You pay for the synchronization, but you avoid the heavy context switches that come with OS threads.

Real-world pattern: the worker pool

Production code rarely uses a single counter. It processes streams of work. A worker pool distributes jobs across multiple goroutines and collects the results. The channel pattern scales naturally here.

package main

import (
	"fmt"
	"time"
)

// ProcessJob simulates work and returns a result.
// The function signature follows Go convention: context first, then parameters.
func ProcessJob(id int) string {
	time.Sleep(10 * time.Millisecond) // Simulate I/O or computation
	return fmt.Sprintf("job-%d-done", id)
}

// worker reads jobs from the input channel and writes results to the output channel.
// Directional channels enforce flow: <-chan for read-only, chan<- for write-only.
func worker(id int, jobs <-chan int, results chan<- string) {
	for j := range jobs {
		// Each worker owns the job it receives. No shared state exists.
		res := ProcessJob(j)
		results <- res
	}
}

func main() {
	jobs := make(chan int, 10)
	results := make(chan string, 10)

	// Launch three workers. They all read from the same jobs channel.
	// The runtime distributes items fairly across waiting receivers.
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// Feed ten jobs into the pipeline.
	for j := 1; j <= 10; j++ {
		jobs <- j
	}
	close(jobs) // Signal that no more jobs are coming

	// Collect results. The main goroutine waits here until all workers finish.
	for a := 1; a <= 10; a++ {
		fmt.Println(<-results)
	}
}

The range loop over jobs blocks until a value arrives. When close(jobs) runs, the channel marks itself as closed. The range loop detects the closure and exits cleanly. Each worker processes exactly the jobs it receives. They never touch each other's local variables. The results channel collects everything back into the main goroutine. You can change the worker count from three to thirty without touching the synchronization logic. The channels handle the distribution and the coordination.

When channels fall short

Channels are not a universal replacement for locks. They introduce their own failure modes. The most common is a goroutine leak. If a goroutine blocks on a channel send and no one ever receives, that goroutine stays parked forever. The program holds onto its memory and stack space. The leak happens silently until the process runs out of resources. Always design a cancellation path. Close the channel when the producer finishes, or use a context.Context to signal shutdown.

Deadlocks are another runtime trap. If two goroutines try to send to each other on unbuffered channels, neither can proceed. The runtime detects this and panics with all goroutines are asleep - deadlock!. You can avoid it by using buffered channels for temporary decoupling, or by restructuring the flow so data moves in one direction.

Compiler errors catch the obvious mistakes. Forget to import the sync package and you get undefined: sync. Try to send a string into a channel typed for integers and the compiler rejects it with cannot use "hello" (untyped string constant) as int value in send. The type system enforces channel contracts at compile time. You cannot accidentally pass the wrong shape of data.

There are legitimate cases where mutexes win. High-frequency counters that increment thousands of times per second suffer from channel overhead. Each channel send involves a runtime handoff and a context switch. A mutex with atomic operations or a simple lock stays in the same goroutine and avoids the handoff. Low-level caches, database connection pools, and C library wrappers often use sync.Mutex or sync.RWMutex because the data structure is inherently shared and the access pattern is fine-grained. The proverb does not forbid mutexes. It warns against making them the default. Use channels for flow control and data transfer. Reach for mutexes when you need fine-grained locking on a structure that cannot be split.

When you intentionally ignore a return value, use the blank identifier _. Writing result, _ := doSomething() tells the reader you considered the second return and chose to drop it. Do not use it to silence errors. Run gofmt on your code before committing. The community expects consistent formatting, and the tool removes arguments about indentation.

Picking the right tool

Use a channel when you need to pass data between goroutines and synchronize their execution. Use a buffered channel when the producer and consumer run at different speeds and you want to decouple them slightly. Use a mutex when you must protect a complex data structure from concurrent reads and writes within the same goroutine or across tightly coupled goroutines. Use atomic operations when you need a single integer or boolean to update at high speed without blocking. Use sequential code when you do not actually need concurrency, because the simplest program is the easiest to debug.

Channels turn concurrency into data flow. Mutexes turn concurrency into access control. Pick the model that matches your problem. If the data moves, use a channel. If the data stays put and many readers need to peek at it, use a lock. Trust the type system to enforce the boundaries. Let the runtime handle the scheduling.

Where to go next