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 42Sender: 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 ofnitems before it blocks.- The Nil Trap: A
nilchannel (one not initialized withmake) 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. Ifokis 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.
.jpeg)

Comments
Post a Comment