13 — Goroutines and Channels — Communicate by Sharing, Don't Share to Communicate
The feature that made Go famous felt magical until I saw the two primitives underneath. The takeaway that clicked: a goroutine is a function running concurrently, scheduled by the Go runtime onto a small pool of OS threads, and a channel is a typed conduit that lets goroutines pass values to each other safely — the recommended way to coordinate is to send data through channels rather than lock shared variables. [1][3] The famous slogan "do not communicate by sharing memory; share memory by communicating" is not poetry, it is the design instruction for how to use these two primitives together.
Goroutines — cheap concurrent functions
A goroutine is a function or method executing concurrently with other goroutines. Launch one with the go keyword [1]:
go doWork(arg) // doWork now runs concurrently; the caller continues immediatelyThe magic is the cost. A goroutine starts with a tiny stack (around 2 KB) that grows as needed, and the Go runtime multiplexes hundreds of thousands of them onto a small number of OS threads (defaulting to one per CPU core) [1]. Spawning a goroutine is roughly the cost of a function call, not the megabyte-of-stack cost of a pthread. This is why "one goroutine per request" is the default for Go web servers — concurrency at that scale is affordable in a way OS threads are not.
The footgun: the main function does not wait for goroutines. If main returns, the program exits and every running goroutine is killed mid-flight. Coordinating completion is what channels, sync.WaitGroup, and context are for.
Channels — typed conduits between goroutines
A channel is a typed, thread-safe conduit that one goroutine sends values into and another receives from [2]. The design intent is explicit: instead of two goroutines both writing to the same variable behind a lock, one goroutine _sends_ the new value through a channel, and the goroutine that owns the variable _receives_ it. Ownership flows with the data.
ch := make(chan int) // unbuffered channel of ints
go func() {
ch <- 42 // send
}()
v := <-ch // receive — blocks until a value arrivesA send on an unbuffered channel blocks until a receiver is ready; a receive blocks until a value is available [4]. This synchronous handshake is the coordination mechanism itself — you do not need a separate signal, the channel transfer _is_ the signal. Closing a channel with close(ch) signals that no more values will arrive; a receive on a closed channel returns the zero value and a "not ok" flag, and ranging over a closed channel terminates cleanly.
Buffered vs unbuffered
The buffered/unbuffered distinction is the first channel decision [4]:
ch := make(chan int) // unbuffered — synchronous
ch := make(chan int, 5) // buffered with capacity 5 — asynchronous up to 5- Unbuffered (no capacity) — the sender blocks until the receiver takes the value. A synchronous rendezvous: the send and receive happen "at the same time." Use this for coordination, handoffs, and when you want backpressure built in.
- Buffered (capacity N) — sends do not block until the buffer is full; receives do not block until the buffer is empty. Use this to decouple producer and consumer timing, or to smooth bursts.
The common mistake is reaching for a buffer to "fix" a deadlock. A buffer does not change the synchronization design — it only delays the blocking. If your goroutines deadlock on an unbuffered channel, a buffered one with capacity 5 just delays the deadlock by 5 sends.
select — multiplexing channels
The select statement lets a goroutine wait on multiple channel operations at once, running whichever is ready first [5]:
select {
case v := <-input:
process(v)
case out <- result:
// sent a result
case <-time.After(time.Second):
return errors.New("timeout")
default:
// nothing ready — non-blocking fallback
}select is the multi-channel switch. If multiple cases are ready, one is chosen at random (preventing starvation). The default case makes the whole select non-blocking — if no case is ready, default runs immediately. The time.After channel is the idiomatic way to add a timeout: it returns a channel that fires once after the duration, and the select races it against the real work. This is how Go writes concurrent code that cannot hang forever.
Worker pools — the bread-and-butter pattern
A worker pool is a fixed number of goroutines pulling tasks off a shared channel, which controls concurrency without unbounded growth [6]:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * j
}
}
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 3; w++ { go worker(w, jobs, results) } // 3 workers
for j := 1; j <= 10; j++ { jobs <- j }
close(jobs)Three workers process the queue in parallel; the buffered channels absorb bursts. The pattern caps resource usage (you have exactly 3 concurrent tasks, not 10,000) while still parallelizing. The input channel direction <-chan (receive-only) and output chan<- (send-only) annotations make the worker's intent explicit and let the compiler catch misuse. Worker pools are the answer to "I have 10,000 things to process and do not want 10,000 goroutines touching the database at once."
How I use this
The channel-first instinct is the habit that took longest to build. When two goroutines need to coordinate, my first question is whether a channel can carry the data and the signal together, rather than reaching for a shared variable plus a mutex. For fan-out work — many independent tasks — a worker pool with a fixed concurrency level caps the blast radius and keeps the system predictable. And select with a time.After case is my default for any operation that talks to the network, because a concurrent program that can hang forever is a production incident waiting to happen. The mental shift is from "concurrency is hard and rare" to "concurrency is cheap and routine, as long as data flows through channels."
References
[1] The Go Authors, "Go Tour: Goroutines," go.dev, 2024. [Online]. Available: https://go.dev/tour/concurrency/1
[2] Golang Docs, "Channels in Golang," 2024. [Online]. Available: https://golangdocs.com/channels-in-golang
[3] GoTurkiye, "Concurrency in Go: Channels and WaitGroups," Medium, 2024. [Online]. Available: https://medium.com/goturkiye/concurrency-in-go-channels-and-waitgroups-25dd43064d1
[4] A. Mishra, "Advanced Insights into Go Channels: Unbuffered and Buffered Channels," Medium, 2024. [Online]. Available: https://medium.com/@aditimishra_541/advanced-insights-into-go-channels-unbuffered-and-buffered-channels-d76d705bcc24
[5] The Go Authors, "Go Tour: Select," go.dev, 2024. [Online]. Available: https://go.dev/tour/concurrency/5
[6] Lorain, "GO: How to Write a Worker Pool," dev.to, 2024. [Online]. Available: https://dev.to/justlorain/go-how-to-write-a-worker-pool-1h3b
Knowledge check · Question 1 of 5
Why can Go spawn hundreds of thousands of goroutines where OS threads would exhaust memory?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!