Skip to content
beginner Phase 9 · Channels, Select, and Channel Patterns

Channel Fundamentals

Send and receive values over unbuffered channels with directional operators.

40m
0 problems
Topic Progress 0%

The Handoff Main Never Saw

A data-processing script fetches a report in a goroutine and prints the result from main. The fetch takes 200ms, main reaches the end of the function in 1ms, and the process exits with the result still in flight. The output is not wrong — it is missing, and the bug only shows on machines fast enough for main to win the race. Adding time.Sleep(300 * time.Millisecond) makes it pass locally and fail in CI. This is the problem channels exist to solve: goroutines need a way to hand values to each other where the handoff itself waits until the other side is ready.

A channel is a typed conduit. make(chan string) creates one that carries strings, and from that moment a send on one side and a receive on the other are two halves of a single event:

package main

import (
	"fmt"
	"strings"
)

func main() {
	words := make(chan string)

	go func() {
		for _, w := range strings.Fields("ship it then fix it") {
			words <- w // blocks until main is waiting to receive
		}
	}()

	for i := 0; i < 3; i++ {
		fmt.Println("worker said:", <-words) // blocks until the worker sends
	}
}

Step through the timing once. The goroutine starts and hits words <- w with nobody receiving yet, so it parks. The main goroutine reaches <-words with no value ready, but that receive is exactly what the parked sender was waiting for: the value crosses, both sides resume, and the loop continues. Each iteration is a tiny agreement — the sender does not run ahead, the receiver does not read garbage, and no sleep or flag variable is involved.

Notice what is not here. There is no mutex, no shared slice, no index tracking which element is written. The value transfers completely: by the time the receive returns, w is finished being written by one goroutine and is safe to read by the other. That property becomes a memory-safety guarantee in the next chapter.

The example counts receives with i < 3, which is fragile — the worker's loop bounds and main's loop bounds are two copies of the same truth. You will replace this counting with range over a closed channel in Closing Channels and Ranging Over Them; for now, note that the handoff, not the counting, is the point. Also note that words was created with make before either side used it. A channel's zero value is nil, and a nil channel accepts no traffic at all — a trap covered at the end of this topic.

Read the Arrows Before You Read the Code

Channel code is short, which is exactly why misreading it is expensive. Two operators exist, and each has one job.

Code Operation Blocks while
ch <- v send v into ch no receiver is waiting (unbuffered)
v := <-ch receive from ch into v no value is available
<-ch receive and discard the value no value is available
v, ok := <-ch receive plus closed-channel flag no value is available

In the send, the channel sits on the left: words <- w means into the channel it goes. In the receive, the arrow attaches directly to the channel: <-words means out of the channel it comes. The direction the value travels never changes — only which side of the operator holds the channel.

The blocking rules are symmetric and worth memorizing precisely, because every later topic is built on them. On an unbuffered channel, a send completes only when a receive has begun; a receive completes only when a send has provided a value. Neither operation returns early with a placeholder. The Go memory model states the consequence directly: a receive from an unbuffered channel happens before the send on that channel completes. You cannot observe a half-transferred value because there is no window where one exists.

The discarding receive (<-ch) is not a curiosity. Sometimes the payload is irrelevant and the send's completion is the message — a goroutine waiting for a go-ahead signal reads <-ch and throws the value away. The two-value form (v, ok := <-ch) reports whether the value came from a live channel or a drained, closed one; ok stays true while data flows and flips to false when the channel is closed and empty. That mechanism gets its own topic, but knowing the flag exists keeps the single-value form from feeling arbitrary.

Two properties of channel values matter for real programs:

func serve(jobs chan<- Job) { /* ... */ } // channel passed like any other value

type Server struct {
	requests chan Request
}

A channel is a reference-like handle, the same way maps and slices are: passing requests into a function passes a handle to the same underlying queue, not a copy. Goroutines that receive the handle talk over one wire. And because the zero value is nil, declaring var ch chan int without make compiles cleanly — the first send or receive on it parks forever. If your program hangs with one goroutine sitting in a channel operation, make at the construction site is the first thing to check.

Channels also copy their payloads by value. Sending a struct copies the struct; sending a slice copies the slice header, so both sides still point at the same backing array. If a consumer must not see later mutations, copy the elements before the send or hand off ownership of a freshly built slice — the ownership rules from Value Semantics apply inside channels unchanged.

Why the Handoff Is Safe Without a Mutex

A team ports a worker pool from a mutex-protected version to a channel version and runs go test -race. The mutex version needed care — every map write wrapped in Lock/Unlock — while the channel version has none of that and still passes. The race detector is not being lenient; the channel created a synchronization edge the detector can see.

Start with the unsafe version:

var total int

for _, n := range nums {
	go func(n int) {
		total += n // concurrent read-modify-write: data race
	}(n)
}
fmt.Println(total)

Every goroutine touches total in place, so unsynchronized writes collide. Two fixes exist: serialize access with a mutex, or stop sharing the variable and transfer a finished value instead:

results := make(chan int, len(nums))

for _, n := range nums {
	go func(n int) {
		sum := 0
		for _, x := range work(n) {
			sum += x
		}
		results <- sum // all writes to sum are ordered before this send
	}(n)
}

total := 0
for i := 0; i < len(nums); i++ {
	total += <-results // reads a value ordered after its send
}

Why is the second version race-free? Each goroutine computes sum entirely inside itself — nobody else can see the intermediate values. The send publishes the finished number, and the memory model guarantees that everything written before the send is visible to whoever receives it. The receive and the send form a release/acquire pair, exactly like a mutex's Unlock/Lock. go test -race confirms it because the runtime instruments channel operations as synchronization points.

This is the practical meaning of the community's advice to share memory by communicating: give each goroutine its own data and pass results between them, rather than letting many goroutines poke one shared structure. It is not a claim that channels are faster — a mutex around a small counter will benchmark faster than a channel handoff. The claim is about reasoning: with ownership transfer there is no shared mutable state to protect, so there is no critical section to forget.

The judgment call between the two tools stays honest. Use a channel when work or results move between goroutines — a job queue, a pipeline stage, a signal that something finished. Use a mutex when many goroutines read and update one long-lived structure in place — a cache with a map[string]*Entry at its center. Passing the whole cache through a channel per lookup would serialize access nobody needed to serialize. Mutexes, RWMutex, and Atomics covers that side of the boundary; this topic's job is to make the channel side automatic. Run the comparisons under the race detector either way — it is the arbiter, not your intuition.

What a Channel Is Not

Three misconceptions cause most early channel bugs. Naming them now saves debugging sessions later.

A channel is not a fast queue. An unbuffered channel stores nothing; it is a rendezvous point where two goroutines meet and exchange a value face to face. The value exists in the sender's stack frame until the moment a receiver arrives, then it is copied across. When someone says a channel is a FIFO queue they are describing the buffered case from Buffered Channels, where make(chan T, n) adds an actual holding area of size n. Even then the buffer is fixed capacity and bounded — bursty producers fill it and block. If you need an unbounded backlog, a channel alone will not give you one, and asking for one usually means the consumer is too slow for the producer.

A channel does not fix lifetime problems by itself. The handoff synchronizes one value, not the whole program. Consider counting wrong:

ch := make(chan int)

for _, job := range jobs {
	go func(job Job) {
		ch <- process(job)
	}(job)
}

for i := 0; i < len(jobs); i++ {
	results = append(results, <-ch)
}

This works only while the receive count matches the send count exactly. If process panics in one goroutine, that send never happens and the final loop blocks forever — one failure becomes a hung program. Coordinating how many goroutines are alive is sync.WaitGroup's job; coordinating what each goroutine hands over is the channel's. Production code pairs them: WaitGroup to know all workers finished, channel to collect what they produced, and close to end the receiving loop without counting.

A channel does not order unrelated work. If two goroutines send independent events — one logs request started, another logs request finished — the channel guarantees each send is received exactly once, but nothing about which of the two arrives first. Goroutine scheduling decides that, and the scheduler gives no fairness promises across independent operations. When ordering matters, carry a sequence number or a timestamp in the payload and sort after the fact; do not infer program order from channel arrival order.

The working mental model, then: a channel is a synchronized handoff point with reference-like identity, value-copying payloads, and blocking semantics on both ends. It replaces sleeps, flag polling, and most mutex-and-shared-variable arrangements — provided you keep the counting, the lifetime, and the shared-state questions assigned to the tools built for them.

Quiz

1. A goroutine sends `ch <- 42` on an unbuffered channel, then main prints a line and returns without ever receiving. What happens?

Question 1 options

2. You need to unblock a producer goroutine but you do not care about the value it sends. Which statement does that?

Question 2 options

3. Workers increment a shared counter with no synchronization, then main reads it after a sleep. Which change makes the program race-free without adding a mutex?

Question 3 options

4. A colleague declares `var results chan int` (no `make`) and launches a goroutine that sends to it while main receives from it. What does the program do?

Question 4 options

Flashcards

Question

In `words <- w` versus `<-words`, where does the value go, and how do you tell them apart at a glance?

Answer

In `words <- w` the value goes into the channel — the channel name sits left of the arrow, so it is a send. In `<-words` the arrow is attached to the channel with nothing on the left, so it is a receive pulling a value out. The value always travels away from the side the arrow is not pointing at.

Question

What exactly happens at the moment an unbuffered send and receive rendezvous?

Answer

The value is copied from sender to receiver at the instant both sides are ready, and neither operation returns before that instant. The memory model makes the copy atomic from the outside: everything the sender wrote before the send is visible to the receiver after the receive, which is why a channel handoff can replace mutex-protected handoff of a result.

Question

Why can `var ch chan string` compile and then hang the program with no error?

Answer

The zero value of a channel is nil, and every operation on a nil channel blocks forever rather than panicking. If one goroutine sends and another receives on that nil channel, both park; when no goroutine can run, the runtime reports a fatal deadlock. The fix is always `make(chan string)` before either side uses it.

Question

When should you pick a channel handoff instead of a mutex for shared data?

Answer

When the data is moving between goroutines as a result or a unit of work — the sender is finished with it and the receiver takes over. That eliminates the shared mutable state entirely, so no lock exists to forget. Keep the mutex for a long-lived structure both sides must read and update in place, like a cache map.

Question

What does sending a slice over a channel actually copy, and what can surprise you afterward?

Answer

Sending copies the slice header — pointer, length, capacity — not the underlying elements. Receiver and sender therefore still share the same backing array, so appends or writes by one side are visible to the other. If isolation is required, copy the elements before sending or hand off a freshly built slice and stop touching the original.

Revision Notes

Key Takeaways

  • 1. An unbuffered channel stores nothing — it is a rendezvous where the value crosses only when sender and receiver are both ready
  • 2. Send is `ch <- v`, receive is `<-ch`; the same arrow, and both sides block until the other arrives
  • 3. A channel handoff is a synchronization edge: writes before the send are visible after the receive, which is why the race detector accepts it
  • 4. Channels are reference-like handles with value-copied payloads — sending a slice copies the header, not the elements
  • 5. The nil channel zero value blocks forever instead of panicking, so a missing `make` shows up as a hang, not a crash

Interview Tips

  • • "Explain what happens when a goroutine sends on an unbuffered channel with no receiver" — say it parks, not spins; the send completes only when a receive begins, and process exit can silently kill the parked goroutine
  • • "How do channels remove the need for locks?" — walk the release/acquire edge: compute in private, publish once, consume after the receive; then show judgment by naming the cache-map case where a mutex is still right
  • • "What's the difference between a channel and a queue?" — unbuffered channels queue nothing (rendezvous), buffered ones hold a fixed capacity and block the sender when full; be ready to size a buffer for a burst scenario
  • • "Race detector flags your code — when would you reach for a channel instead of a mutex?" — when the shared thing is a value in transit, not state in place; mention that you'd still confirm with `go test -race`

Cheat Sheet

Channel Fundamentals

Operation blocking rules (unbuffered):

Operation Completes when Blocks while
ch <- v (send) a receive has begun no receiver waits
v := <-ch (receive) a sender provides a value no value available
<-ch (discard receive) a sender provides a value no value available
anything on a nil channel never always

Memory edge: receive from an unbuffered channel happens after the matching send completes — publishing finished results, no mutex needed.

Ownership rule: channel moves values between goroutines; WaitGroup counts live goroutines; mutex guards in-place updates to one shared structure.

Code template:

results := make(chan Result, len(jobs))

for _, j := range jobs {
	go func(j Job) {
		results <- run(j) // compute privately, publish once
	}(j)
}

for i := 0; i < len(jobs); i++ {
	r := <-results // ordered after its send: race-free
}

Continue Learning