Skip to content
gohacks.dev
Concurrency

Share Memory by Communicating and Where It Breaks Down

What the Go proverb about sharing memory by communicating really asks for, a working ownership hand-off, the race when you keep the pointer, and its limits.

6 min read
Summarize in AI
Flat illustration of a grid of heap cells with the garbage collector sweeping across it.

"Do not communicate by sharing memory; instead, share memory by communicating." It is the most quoted line in Go concurrency, it appears in Effective Go, and it is routinely used to justify decisions that the line does not support. Read quickly, it sounds like "avoid mutexes". Read slowly, it is a statement about ownership: arrange the program so that at any moment a piece of data belongs to exactly one goroutine, and move it between goroutines by sending it. Then no two goroutines ever touch it at the same time, and no lock is needed because nothing is shared.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article takes that reading and tests it. It shows the pattern working, shows the one mistake that quietly undoes it, and lists the places where it is the wrong tool. It belongs to the concurrency patterns cluster, goes together with channels or mutexes, and relies on the ordering rules in happens-before. Everything ran on Go 1.25.6.

What the proverb is really about

The sentence comes from the Go blog and Effective Go, where the full thought is that a goroutine can hand a value to another over a channel and the two never access it simultaneously. The channel is the transfer point. Before the send the sender owns the value and may do whatever it likes; after the send the receiver owns it. The memory model makes this safe, because a send happens before the corresponding receive completes, so everything the sender wrote is visible to the receiver.

That is a concurrency design principle disguised as a slogan. It says the first question about shared data is not "which lock?" but "can this data have one owner at a time?" When the answer is yes, ownership transfer is simpler than locking, and the program is easier to reason about, because the question "who can modify this?" has one answer at every point.

The pattern working

A concrete case is a batch builder. One goroutine collects log lines into a slice; when the batch is full it sends the slice to a writer goroutine and starts a fresh one. The slice is never touched by two goroutines at once:

main.goGo
package main

import (
	"fmt"
	"strings"
	"sync"
)

func main() {
	batches := make(chan []string)
	var wg sync.WaitGroup

	// The writer owns each batch once it arrives.
	wg.Go(func() {
		n := 0
		for batch := range batches {
			n++
			fmt.Printf("batch %d: %s\n", n, strings.Join(batch, ","))
		}
	})

	// The collector owns the slice it is filling.
	var current []string
	for i := 1; i <= 7; i++ {
		current = append(current, fmt.Sprintf("line%d", i))
		if len(current) == 3 {
			batches <- current // ownership moves to the writer
			current = nil      // and the collector lets go of it
		}
	}
	if len(current) > 0 {
		batches <- current
	}
	close(batches)
	wg.Wait()
}
Text
batch 1: line1,line2,line3
batch 2: line4,line5,line6
batch 3: line7

No mutex, no atomic, and no race. The important line is current = nil after the send. Setting the variable to nil is not for the garbage collector; it is the collector giving up its claim. The next append allocates a new slice instead of writing into the one the writer is reading. Remove that line and the collector's next append may write into the same backing array the writer is printing.

The mistake: send the pointer, keep using it

The channel transfers a value, and the value of a slice, map or pointer is a reference to memory that both sides can still reach. Sending it does not stop the sender from using its copy of the reference. This version looks almost identical and breaks the ownership rule:

main.goGo
package main

import "fmt"

type report struct {
	lines []string
}

func main() {
	ch := make(chan *report)
	done := make(chan struct{})

	go func() {
		r := <-ch
		fmt.Println("writer saw", len(r.lines), "lines")
		close(done)
	}()

	r := &report{}
	r.lines = append(r.lines, "first")
	ch <- r
	r.lines = append(r.lines, "second") // still using it after handing it off
	<-done
}
Text
writer saw 1 lines
==================
WARNING: DATA RACE
Write at 0x00c000152018 by main goroutine:
  main.main()
      example.test/lab/main.go:22 +0x2a4

Previous read at 0x00c000152018 by goroutine 7:
  main.main.func1()
      example.test/lab/main.go:15 +0x4c

Goroutine 7 (running) created at:
  main.main()
      example.test/lab/main.go:13 +0xe0
==================
Found 1 data race(s)
exit status 66

The program prints a plausible number of lines and the race detector flags the writer's read against the sender's write. The channel did what it promised: it ordered the writes made before the send ahead of the receive. It said nothing about the write made after. Nothing in the language marks a pointer as moved, and nothing stops you from continuing to use it, so ownership is a convention that the code has to follow. Go has no borrow checker.

Three habits keep the convention honest. Nil the variable after sending, as in the batch builder, so the compiler flags later use as a nil dereference during testing rather than a race in production. Send values, not pointers, when the value is small: a struct passed by value has no shared state. And document the transfer in the type or function comment ("the receiver owns the slice") the way io.Writer documents that it must not retain the buffer.

Where it breaks down

The slogan fails in a few recognisable places.

Fine-grained shared state. A counter that every request increments, a map of sessions looked up on every call, a feature-flag table: there is nothing to transfer, because every goroutine needs the same data continuously. Forcing it through an owner goroutine turns each lookup into a request and a reply. In the benchmark in channels or mutexes that cost about 413 ns per map operation against 33 ns for a map behind an RWMutex. A mutex is the correct tool for shared state; the proverb does not forbid it.

Data that cannot move. A database connection, a file handle, a hardware resource, an object with an identity other code points to. Somebody has to hold it, and "pass it around" is not an option. You can wrap it in an owner goroutine that serializes access, which has its uses, but it does not remove the sharing, it only moves the lock into a channel.

Broadcast. One value read by many goroutines at once cannot be owned by any of them. The idiom is to make the value immutable and share it freely, or to atomically publish a new immutable version (copy on write). Immutable data is the other way out of the problem: if nobody writes, nobody races.

Performance-critical paths. A channel send or receive costs more than an uncontended lock, as the measurements show. For a hot inner loop, the extra hand-off matters. For most code it does not, and clarity wins.

Where it works best

It shines when the program is naturally a flow: items arrive, get processed in stages, and leave. A pipeline passes each item along, and each stage owns it for its turn (pipelines). A worker pool receives a job, owns it while working, and sends back a result (worker pools). A goroutine that owns a resource with behavior, such as a rate limiter that refills on a timer, becomes easier to write as a single loop that handles one event at a time, which is the actor style. In each case the question "who owns this?" has a clean answer, and the code shows the answer in its structure.

The way I apply it: ask what moves and what stays. Things that move (jobs, results, events, buffers) go on channels, and the sender lets go. Things that stay (caches, counters, configuration) live behind a lock or an atomic. A program with both is normal. The Go blog post introducing the proverb walks through a small program built around it, and the Effective Go section on sharing by communicating is the original source.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.