Course outline

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.

About 5 min readContent reviewed
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.

go A producer with one closing owner
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)
}
Expected output
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.

go Observe a closed and drained buffer
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)
}
Expected output
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.

Official references