I spent a significant amount of time this month binge watching GopherCon talks on YouTube. I stumbled upon Rob Pike’s talk on Go Proverbs. One of the proverbs is “Do not communicate by sharing memory, share memory by communicating”. At the time, I didn’t give it much thought, but it stuck with me.

Not long after, Interstellar was re-released in theaters in IMAX. The first time I saw it, back in 2014, I was too young to understand it. But this time, I noticed that the communication between Cooper and Murph is quite similar to the communication between goroutines in Go.

There’s this scene toward the end, where Cooper is inside the Tesseract. It’s this infinite, five-dimensional space built by “them” to help him communicate with his daughter Murph across time. But, he doesn’t talk to her directly. He doesn’t hand her a note or call her on the phone. He manipulates gravity to send messages through the ticking of the second hand of her watch.

Cooper and Murph as Goroutines

Think of it like this:

If this were C++ or Java, maybe they’d be updating some shared variable with a mutex. But Go encourages goroutines talk to each other through channels.

So in our analogy:

That’s what Rob Pike meant. Instead of locking access to shared memory, just pass the data around using channels.

The Shared Memory Implementation

package main

import (
	"fmt"
	"sync"
)

var counter = 0
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
	defer wg.Done()
	mu.Lock()
	counter++
	mu.Unlock()
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go increment(&wg)
	}
	wg.Wait()
	fmt.Println("Counter:", counter)
}

Yes, this works as expected. But its easy to mess up. If someone forgets to lock or unlock the mutex, a race condition is introduced.

The Go Way

package main

import (
	"fmt"
)

func main() {
	ch := make(chan int)

	// Producer
	go func() {
		for i := 0; i < 1000; i++ {
			ch <- 1
		}
		close(ch)
	}()

	// Consumer
	counter := 0
	for val := range ch {
		counter += val
	}

	fmt.Println("Counter:", counter)
}

Here, there’s no shared memory to protect. Only one goroutine updates counter, and the others send data to it via the channel.

Tesseract Analogy


package main

import (
	"fmt"
	"time"
)

// Cooper is the sender goroutine. He's inside the Tesseract.
func cooper(watch chan<- string) {
	time.Sleep(2 * time.Second) // Simulate falling into a black hole
	watch <- "..-. ..- -.-. -.-. .... .-.-.-" // Morse code for "FUTURE."
	fmt.Println("Cooper: Sent data to the watch.")
}

// Murph is the receiver goroutine. She’s back on Earth.
func murph(watch <-chan string) {
	fmt.Println("Murph: Waiting for signal from the ghost...")
	msg := <-watch
	fmt.Printf("Murph: Decoded message from watch: %s\n", msg)
}

func main() {
	watch := make(chan string)
	go cooper(watch)
	go murph(watch)
	time.Sleep(3 * time.Second) // Wait for everything to finish
}

Hence, when building concurrent systems:-

This doesn’t mean you should never use mutexes. There are cases when they are the right choice, especially when performance is critical or you’re doing low level system programming.