The Secret Life of Go: Channels

 

The Secret Life of Go: Channels

How to fix race conditions with buffered and unbuffered channels in Go





Chapter 24: Sharing Memory by Communicating

Ethan was staring at a map of data on his screen, hitting the refresh button.

"It’s happening again," he said. "I’m running a calculation across ten goroutines to count word frequencies. Every time I run it, I get a slightly different number."

Eleanor rolled her chair over. "Let me see the code."

Ethan pointed to a global variable: var count int. Inside his loop, ten different goroutines were doing count++.

"Ah," Eleanor nodded. "The Race Condition. You have ten goroutines fighting over a single piece of memory. One reads the value, but before it can write it back, another has already changed it."

"I should use a Mutex, right?" Ethan asked. "Lock the memory, write to it, then unlock it?"

"You could," Eleanor said. "But that is communicating by sharing memory. In Go, we prefer to share memory by communicating."

"What does that mean?"

"It means we don't let the goroutines fight over the data," she replied. "We pass the data between them, like a baton in a relay race. We use Channels."

The Unbuffered Channel (The Relay Race)

Eleanor opened a new file. "A channel is a typed conduit. You can send values into it, and you can receive values from it."

She typed:

func main() {
    // Create a channel that transports integers
    // Always use make()! A nil channel blocks forever.
    ch := make(chan int)

    go func() {
        fmt.Println("Sender: Sending the value 42...")
        ch <- 42 // Send value into the channel
        fmt.Println("Sender: Sent!")
    }()

    fmt.Println("Receiver: Waiting...")
    val := <-ch // Receive value from the channel
    fmt.Println("Receiver: Got", val)
}

"Run this," she said.

Ethan ran it.
Terminal:
Receiver: Waiting...
Sender: Sending the value 42...
Receiver: Got 42
Sender: Sent!

"Notice the order," Eleanor pointed out. "This is an unbuffered channel. It has no storage. For the sender to complete the send (ch <- 42), there must be a receiver ready to take it at that exact moment."

"So they synchronize?" Ethan asked.

"Exactly. It forces the two goroutines to meet at the same point in time to hand off the data."

"Does it pass a reference?" Ethan asked. "Like a pointer?"

"No," Eleanor corrected him. "Channels pass a copy of the value. The sender gives up the data, and the receiver gets a brand new copy. This is why it is safe—they aren't looking at the same memory anymore."

The Buffered Channel (The Mailbox)

"But what if I don't want the sender to wait?" Ethan asked. "What if the sender is faster than the receiver?"

"Then we give the channel a buffer," Eleanor said. "Think of it like a mailbox. You can drop a letter in and walk away, even if the mail carrier hasn't arrived yet."

She changed the line:

// Create a channel with room for 3 items
ch := make(chan int, 3)

ch <- 1
ch <- 2
ch <- 3
fmt.Println("Buffer is full!")

// ch <- 4  <-- This would block, because the buffer is full

"Buffered channels are asynchronous," Eleanor explained. "Sending only blocks if the buffer is full. Receiving only blocks if the buffer is empty."

The Direction and The Close

"Now, let's fix your word counter," Eleanor said. "Instead of fighting over a count variable, let your workers throw their results into a channel, and have one person—the main function—collect them."

She sketched out the pattern:

func worker(id int, results chan<- int) {
    // Do some heavy work...
    result := id * 10 
    results <- result // Send to channel
}

func main() {
    results := make(chan int, 10) // Buffer for 10 results

    // Start 10 workers
    for i := 0; i < 10; i++ {
        go worker(i, results)
    }

    // Read the results
    for i := 0; i < 10; i++ {
        val := <-results
        fmt.Println("Collected:", val)
    }
}

"Notice the function signature," Eleanor pointed to chan<- int. "This arrow means the worker can only send to this channel. It cannot read from it."

"Type safety for direction," Ethan mused. "That prevents bugs."

"Precisely. Now, there is one last rule. When you are done sending data, you must close the channel."

"Why?"

"So the receiver knows no more data is coming. This allows the receiver to use a range loop."

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

    go func() {
        for i := 0; i < 5; i++ {
            ch <- i
        }
        close(ch) // "I am done sending."
    }()

    // This loop runs until the channel is closed
    for val := range ch {
        fmt.Println(val)
    }
    fmt.Println("Channel closed, loop finished.")
}

"What if I'm not using a loop?" Ethan asked. "How do I know if it's closed?"

"You use the 'comma-ok' idiom," Eleanor replied.

val, ok := <-ch
if !ok {
    fmt.Println("Channel is closed and empty!")
}

"Who closes it?" Ethan asked. "The sender or the receiver?"

"The sender always closes the channel," Eleanor stated firmly. "Sending on a closed channel causes a panic. So the person putting data in is the only one who knows when they are finished."

Ethan looked at his new code. No mutexes, no locks, just data flowing from workers to a collector.

"It feels... cleaner," Ethan said.

"It is," Eleanor smiled. "You aren't managing memory access anymore. You are designing a workflow."


Key Concepts

The Channel (chan)
A typed conduit for passing data between goroutines.

  • ch <- val: Send data (blocks if unbuffered or buffer full).
  • val := <-ch: Receive data (blocks if empty).
  • Copy Semantics: Channels pass a copy of the value, ensuring memory safety.

Unbuffered vs. Buffered

  • make(chan T): Unbuffered. Synchronous. Sender and receiver must be ready at the same time.
  • make(chan T, n): Buffered. Asynchronous. Has a capacity of n items before it blocks.
  • The Nil Trap: A nil channel (one not initialized with make) blocks forever on both send and receive.

Closing

  • close(ch): A signal sent by the sender to indicate no more data.
  • range ch: Iterates over a channel until closed.
  • val, ok := <-ch: Manual check. If ok is false, the channel is closed.
  • Panic Risk: Sending to a closed channel causes a panic.

Next Episode: The Select Statement. Ethan learns how to handle multiple channels at once without getting stuck.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Comments

Popular posts from this blog

The New ChatGPT Reason Feature: What It Is and Why You Should Use It

Insight: The Great Minimal OS Showdown—DietPi vs Raspberry Pi OS Lite

Raspberry Pi Connect vs. RealVNC: A Comprehensive Comparison