Go / Intermediate: Packages and Concurrent Work / Lesson 12
Goroutines, Channels, and Completion
Coordinate concurrent work through explicit communication, channel ownership, and observable completion rather than sleeps.
On this page
Lesson goals
- Explain goroutine launch and shared argument values.
- Distinguish buffered, unbuffered, nil, and closed channels.
- Assign one channel-closing responsibility and wait for workers.
Launching does not wait
A go statement starts a function call in a new goroutine. Arguments are evaluated in the launching goroutine and their values are passed to the call; referenced storage may still be shared. The caller continues without waiting. A goroutine is lightweight relative to an operating-system thread in common implementations, but it is not free or automatically bounded.
Returning from main terminates the program even if other goroutines remain. Define who starts each goroutine, what event ends it, and who waits for it. Sleeping for a guessed duration is not a completion protocol and does not establish safe access to shared state.
A channel carries values and coordination
An unbuffered send needs a matching receiver before it completes; a buffered channel can accept values while space remains. The first example uses an unbuffered producer and ranges until the producer closes. Because the producer is the only sender, it can know when no later send will occur and safely owns closing.
Receiving the final values and observing closure allows the consumer to finish without a sleep. Sending a slice or pointer through a channel copies that value, not all underlying data. If a sender mutates shared storage after sending, the channel operation alone does not justify overlapping unsynchronized accesses to that storage.
package main
import "fmt"
func main() {
values := make(chan int)
go func() {
defer close(values)
values <- 2
values <- 4
}()
total := 0
for value := range values {
total += value
}
fmt.Println("sum", total)
}
sum 6
Both sends are received before range observes closure, producing a sum of six.
Closing does not discard buffered data
Closing a channel announces that no more values will be sent. Previously buffered values remain receivable. After they are drained, receives return the element zero value immediately with ok false. Range over a channel ends after those buffered values have been consumed. A receive-only channel can be ranged over but cannot be closed through that restricted value.
The second example makes the drained state explicit. Sending to a closed channel panics, as does closing an already closed or nil channel. Receiving from a nil channel blocks forever; a nil case in select is disabled. These states are distinct, so nil is not an interchangeable spelling for closed.
package main
import "fmt"
func main() {
queue := make(chan int, 2)
queue <- 1
queue <- 2
close(queue)
total := 0
for value := range queue {
total += value
}
value, ok := <-queue
fmt.Println("sum", total, "drained", value, ok)
}
sum 3 drained 0 false
The buffered one and two remain available after close; the final receive returns zero and false.
Channels also express capacity policy
Directional channel parameters restrict operations a function may perform. A chan<- T parameter expresses sending access, while <-chan T expresses receiving. The direction alone does not identify the closing owner, so document that separately. With multiple senders, a coordinator must know all senders are finished before closing the output.
A buffer absorbs a bounded burst but cannot repair an indefinitely slow consumer or a missing receiver. Decide whether producers should block, reject, drop under a documented policy, or observe cancellation. Unbounded goroutine creation around blocked sends merely changes a queue into hidden memory growth.
Separate value flow from worker completion
An output channel closing can indicate producer completion only if the protocol says closure happens after all required work and cleanup. Sometimes a separate done signal or WaitGroup is clearer. select chooses among ready communications and can include cancellation, but source order does not give one ready case priority.
Test an absent consumer, an empty producer, early cancellation, and all senders finishing. Require every started goroutine to have a bounded exit path. Do not recover a send-on-closed panic as the normal shutdown protocol: that masks an ownership error and can lose values without a defined policy.
Common errors and fixes
A program sometimes exits before printing a worker result.
- Why it happens
- main returned without waiting.
- How to fix it
- Receive a completion/result or wait with synchronization.
- Prevent it next time
- Assign a joining responsibility to the starter.
A receiver closes while a sender is still active.
- Why it happens
- Closing ownership and sender completion were undefined.
- How to fix it
- Let the producer/coordinator close after every sender is finished.
- Prevent it next time
- Document one closing responsibility.
Adding a buffer only delays a hang.
- Why it happens
- The consumer or cancellation path is missing.
- How to fix it
- Repair the lifetime and backpressure protocol.
- Prevent it next time
- Test a blocked consumer with bounded shutdown.
Check your understanding
1. Three workers send to one output. Describe a safe closing arrangement.
Show worked solution
Have a coordinator track all workers with a WaitGroup whose additions occur before launching. Each worker marks completion after its final possible send. A coordinator waits and then closes the output once. Receivers only consume. If cancellation can stop receivers early, workers also need cancellation-aware sends so the coordinator can eventually finish.
2. Why can range over a never-closed channel hang even after all currently buffered values are read?
Show worked solution
Range waits for the next receive until the channel is closed and drained. An empty buffer does not mean no future values can arrive. The protocol needs a closing owner or a separate select-based cancellation/completion condition. A timeout in a test can reveal the hang, but the implementation must define the event that ends waiting.
Key takeaways
- Every goroutine needs a stopping event and a completion owner.
- Nil, open, closed, and drained channels have distinct behavior.
- Buffers bound queued values; they do not repair missing lifetime protocols.