Goroutines on their own give you concurrent execution with no way to coordinate safely. Channels are Go's answer to the question every concurrent system eventually has to answer: how do two pieces of code running at the same time hand data to each other without stepping on one another?
3 min read
ch := make(chan string) // a channel that carries string values
go func() {
ch <- "result from goroutine" // SEND — blocks until someone receives
}()
msg := <-ch // RECEIVE — blocks until someone sends
fmt.Println(msg)A channel is a typed conduit one goroutine can send values into and another can receive values from — ch <- value sends, <-ch receives. By default, both operations block: a send waits until some goroutine is ready to receive, and a receive waits until some goroutine sends something. This blocking behavior is itself the coordination mechanism — it's what lets main safely wait for a goroutine's result without a separate locking primitive.
Go's community famously summarizes its concurrency philosophy as "don't communicate by sharing memory, share memory by communicating" — the alternative most other languages reach for (multiple threads directly reading and writing the same shared variable, protected by a lock) requires every single access to remember to acquire that lock correctly, which is exactly the kind of easy-to-miss-in-one-place mistake this curriculum's Application Security domain already named as a general pattern (a TOCTOU race is a close cousin of the same underlying issue). A channel sidesteps this by design: data ownership transfers explicitly, through the channel itself, instead of multiple goroutines reading and writing the same memory location simultaneously and hoping every access remembered to lock correctly.
ch := make(chan int, 3) // buffered — holds up to 3 values without blocking
ch <- 1 // doesn't block — buffer has room
ch <- 2 // doesn't block
ch <- 3 // doesn't block — buffer now full
ch <- 4 // BLOCKS — buffer is full, waits for a receive to free up spaceA buffered channel (given a capacity in make) lets a sender push up to that many values without an immediately-ready receiver — genuinely similar in spirit to the message-queue buffering concept this curriculum's own Message Queues domain covered, just in-process and in-memory rather than across a network. An unbuffered channel (make(chan int), capacity zero) forces a send and a receive to happen at effectively the same moment — a direct handoff, not a buffer.
select: waiting on multiple channels at onceselect {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2:
fmt.Println("from ch2:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timed out waiting for either channel")
}select waits on several channel operations simultaneously and proceeds with whichever one becomes ready first — if ch1 and ch2 are both still waiting, select blocks until one of them has something, and time.After provides a clean, idiomatic way to add a timeout to that wait rather than blocking indefinitely. This is the mechanism behind most real-world Go concurrency patterns that need to react to "whichever of several things happens first," including graceful shutdown (listening for both real work and a cancellation signal on two different channels at once).
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does Go's "share memory by communicating" proverb describe?
2. What's the difference between a buffered and unbuffered channel?
3. What does Go's `select` statement let a goroutine do?
Go