The Pipes Between Goroutines
Goroutines running in parallel need a way to send results back to the caller or communicate with each other. The naive approach — having all goroutines write to a shared variable — requires locks and is a common source of race conditions and bugs.
Go's philosophy is: "Don't communicate by sharing memory; share memory by communicating."
Channels are typed, thread-safe pipes. One goroutine sends a value into a channel; another goroutine receives it. The act of sending and receiving also synchronizes the goroutines — a sender blocks until a receiver is ready, and vice versa (for unbuffered channels).
package main
import "fmt"
func checkServer(hostname string, results chan<- string) {
// chan<- means this function can only SEND to the channel
// Simulate a health check result
results <- hostname + ": healthy"
}
func main() {
servers := []string{"web01", "web02", "web03"}
// Create an unbuffered channel for strings
results := make(chan string)
// Start one goroutine per server
for _, server := range servers {
go checkServer(server, results)
}
// Collect exactly one result per server
for i := 0; i < len(servers); i++ {
result := <-results // Block until a result arrives
fmt.Println(result)
}
}
The chan<- string type annotation on the function parameter is a directional channel — it can only be used to send, not receive. This is a compile-time safety guarantee that prevents accidental misuse.
Buffered Channels
An unbuffered channel requires a receiver to be ready before the sender can proceed. A buffered channel has a queue — the sender can deposit up to N values without blocking, even if no receiver is ready yet.
// Buffer holds up to 10 results without blocking senders
results := make(chan string, 10)
Buffered channels are useful when you know the maximum number of results upfront and want to decouple producers from consumers slightly. However, if the buffer fills up, the sender blocks again — so buffered channels are not a substitute for proper flow control.
The fan-out / fan-in pattern
The combination of goroutines + channels enables a powerful pattern: fan-out (distribute work across many goroutines) and fan-in (collect results back into a single channel). This is the backbone of most concurrent Ops tools — parallel SSH runners, multi-region health checkers, concurrent API pollers.