Channels explained
Channels are Go’s answer to the question “how do goroutines talk to each other?”
In Node, concurrent operations communicate through callbacks, promises, or shared variables (which is safe because there’s only one thread). In Go, goroutines communicate by sending values through channels. The Go proverb is: “Don’t communicate by sharing memory; share memory by communicating.”
That sounded abstract to me until I built the worker pool experiment. Now it makes sense.
What a channel actually is
Section titled “What a channel actually is”A channel is a typed pipe. You create it for a specific type, send values into one end, and receive them from the other end.
// Create a channel that carries integersch := make(chan int)
// Send a value into the channel (in a goroutine)go func() { ch <- 42 // send}()
// Receive a value from the channelvalue := <-ch // receive, value is now 42The <- operator does double duty. ch <- value sends. <-ch receives. The arrow always points left, toward where the data goes.
The critical property: sends and receives block. If you send to a channel and nobody is receiving, the sending goroutine pauses. If you receive from a channel and nobody has sent anything, the receiving goroutine pauses. This blocking behavior is what makes channels safe for coordination without explicit locks.
The TypeScript equivalent (sort of)
Section titled “The TypeScript equivalent (sort of)”The closest thing in TypeScript would be a queue that two async functions share:
// TypeScript: manual coordination with a shared queueconst queue: number[] = [];
// Producer adds to the queueasync function produce() { queue.push(42);}
// Consumer takes from the queueasync function consume() { while (queue.length === 0) { await sleep(10); // poll... ugly } const value = queue.shift();}This is clunky. You have to manually poll, manage the shared array, and there’s no built-in backpressure. Channels handle all of this.
Buffered vs unbuffered
Section titled “Buffered vs unbuffered”This distinction matters and I got it wrong initially.
Unbuffered channel: make(chan Task). Every send blocks until a receiver is ready. Every receive blocks until a sender is ready. It’s a handshake: both sides must be present.
Buffered channel: make(chan Task, 10). Sends only block when the buffer is full. Receives only block when the buffer is empty. It’s a mailbox: the sender can drop off up to 10 items without waiting.
In my worker pool, the task channel is buffered:
tasks := make(chan Task, bufferSize)This buffer is the backpressure mechanism. The producer can queue up bufferSize tasks ahead of the workers. Once the buffer fills, the producer blocks until a worker finishes a task and opens up a slot. Without the buffer, the producer could only send one task at a time, waiting for a worker to grab it before sending the next one.
When I first built the pool with an unbuffered channel, the output was fine but throughput was slightly lower. The producer spent time blocked between every task, even though workers were ready. Switching to a buffered channel of 10 gave the producer room to stay ahead, keeping workers from ever going idle.
How the worker pool uses channels
Section titled “How the worker pool uses channels”The pool has two channels:
tasks chan Task // producer -> workersresults chan Result // workers -> collectorThe flow:
- The main function sends tasks into the
taskschannel - N worker goroutines read from
tasks, process each one, and send results intoresults - A collector goroutine reads from
resultsand records them
// Producer: send tasks into the channelfor _, task := range taskList { tasks <- task // blocks if buffer is full (backpressure!)}close(tasks) // signal: no more tasks coming
// Worker: read tasks until the channel is closedfor task := range tasks { result := task.Process() results <- result}
// Collector: read results until the channel is closedfor result := range results { allResults = append(allResults, result)}The for task := range tasks loop is idiomatic Go. It reads values from the channel until the channel is closed, then exits. No “is there more data?” check needed. When you call close(tasks), every goroutine ranging over that channel finishes its current item, sees the channel is closed, and exits the loop.
The close() pattern
Section titled “The close() pattern”Closing a channel confused me at first. In Node, closing a stream means “clean up resources.” In Go, closing a channel means “no more values will be sent on this channel.” It’s a signal, not a cleanup.
The rules:
- Only the sender should close a channel. Never the receiver.
- Closing a channel that’s already closed panics.
- Sending to a closed channel panics.
- Receiving from a closed channel returns the zero value immediately.
In the worker pool, the sequence is:
- Producer finishes sending all tasks ->
close(tasks) - Workers see the closed channel, finish processing, exit their loops -> call
wg.Done() wg.Wait()unblocks when all workers are done ->close(results)- Collector sees the closed channel, finishes recording, exits its loop
Getting this sequence wrong causes deadlocks. If you close results before all workers are done, a worker trying to send a result panics. If you forget to close tasks, workers wait forever for more tasks that never come.
The mistake I made
Section titled “The mistake I made”My first version had a subtle bug. I started the workers, sent all tasks, and closed the tasks channel. But I forgot to wait for the workers before closing the results channel.
// BROKEN: closing results before workers finishclose(tasks)close(results) // some workers are still processing!wg.Wait() // too late, collector already exitedThis panicked: a worker tried to send a result to a closed channel. The fix was adding wg.Wait() between closing tasks and closing results:
// CORRECT: wait for workers, then close resultsclose(tasks)wg.Wait() // all workers doneclose(results) // now safe, no more results comingThe Go race detector (go run -race ./cmd/worker-pool) would have caught this. Lesson: always run with -race during development.
select: multiplexing channels
Section titled “select: multiplexing channels”The select statement lets a goroutine wait on multiple channels at once. It’s like switch but for channels. Whichever channel is ready first wins.
I use this in the worker to check for cancellation before processing a task:
select {case <-ctx.Done(): // Context was cancelled (Ctrl+C). Stop working. returndefault: // Context is fine. Process the task.}ctx.Done() returns a channel that gets closed when the context is cancelled. The default case means “if nothing else is ready, do this.” Without the default, the select would block waiting for cancellation, which isn’t what we want. We want to check quickly and move on.
A more interesting use of select is in the producer, where I check cancellation while sending tasks:
select {case <-ctx.Done(): fmt.Println("cancelled, stopping") returncase tasks <- task: // task was sent successfully}This means: either the task gets sent, or cancellation happened. Whichever occurs first. If Ctrl+C happens while the tasks buffer is full and the producer is blocked waiting to send, the cancellation path fires immediately instead of waiting for a worker to free up space.
When to use channels vs mutexes
Section titled “When to use channels vs mutexes”Go has both channels and traditional mutexes (sync.Mutex). The rule of thumb:
Channels when you’re passing ownership of data from one goroutine to another. “Here’s a task, you own it now.” The sender stops using the data after sending.
Mutexes when multiple goroutines need to access the same data in place. “We all read and write this counter.” The data stays shared.
In the worker pool, I use both:
- Channels for the task and result flow (ownership transfer)
- A mutex to protect the
collectedResultsslice (shared state that multiple goroutines append to)
Using a channel where a mutex fits (or vice versa) works but feels awkward. If I replaced the results channel with a mutex-protected slice, workers would lock, append, unlock. Functional but it loses the “pipeline” feel. If I replaced the mutex-protected slice with a channel, the collector would need to drain a channel into a slice anyway, adding a layer for no benefit.
The key insight
Section titled “The key insight”Channels aren’t just a concurrency primitive. They’re a design tool. They force you to think about data flow: who produces data, who consumes it, and how many items can be in flight at once.
I didn’t think about data flow this explicitly in Node. The event loop handles it all behind the scenes, which is convenient until you need to control it. Channels put the flow in front of you. That took some getting used to, but once the worker pool was wired up, I could trace every task from producer to worker to collector just by following the channel types. Try doing that with a Promise.all over a shared array.