Closing Channels — Tasks & Exercises¶
Hands-on exercises grouped by difficulty. Each task has a goal, hints, and an extended solution. Try to solve before reading the solution.
How to use this file¶
- Read the task statement.
- Attempt the solution before reading the hint.
- Compile and run with
go runandgo test -race. - Compare to the model solution.
- Some tasks have multiple acceptable solutions; the one shown is idiomatic.
Each task is self-contained. Working directory: /tmp/close-tasks.
Junior¶
Task 1: simple generator with close¶
Goal. Write a function count(n int) <-chan int that returns a channel emitting the integers 0..n-1 and then closes.
Hint. Spawn one goroutine inside the function, use defer close, return the channel.
Solution.
package main
import "fmt"
func count(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < n; i++ {
out <- i
}
}()
return out
}
func main() {
for v := range count(5) {
fmt.Println(v)
}
}
Expected output.
Why it works. The defer close(out) runs after the loop completes. The consumer's for range exits when it sees the close.
Task 2: read a closed channel five times¶
Goal. Demonstrate that a closed-drained channel can be received from any number of times without blocking, always returning the zero value.
Hint. Close the channel, then loop receiving and printing.
Solution.
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
for i := 0; i < 5; i++ {
v, ok := <-ch
fmt.Println(i, v, ok)
}
}
Expected output.
Key takeaway. Receive on closed never blocks, always returns zero value with ok = false.
Task 3: distinguish closed from real zero¶
Goal. Send three values, one of them being zero. Close. Drain. Print whether each received value was a real send or post-close zero.
Hint. Use comma-ok in a for-loop.
Solution.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 0
ch <- 20
close(ch)
for i := 0; i < 5; i++ {
v, ok := <-ch
if ok {
fmt.Println("got value:", v)
} else {
fmt.Println("closed")
return
}
}
}
Expected output.
Note. The second value 0 is a real zero send (ok = true); the fourth iteration sees the closed-drained channel (ok = false).
Task 4: broadcast cancellation¶
Goal. Spawn 5 worker goroutines. Each prints "stopping" when a done channel is closed. Close the done channel from main and verify all 5 print.
Hint. chan struct{} + close; use sync.WaitGroup to wait for them.
Solution.
package main
import (
"fmt"
"sync"
)
func main() {
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-done
fmt.Println("worker", id, "stopping")
}(i)
}
close(done)
wg.Wait()
}
Expected output. All 5 workers stop, in any order.
Task 5: detect missing close¶
Goal. Write a program that demonstrates the deadlock when for range is used over an un-closed channel. Run it, observe the runtime's deadlock detector.
Solution.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
// intentionally no close
}()
for v := range ch {
fmt.Println(v)
}
}
Expected output.
Key takeaway. Always close a channel that the consumer iterates with for range.
Task 6: trigger send-on-closed panic¶
Goal. Write a minimal program that panics with "send on closed channel."
Solution.
Expected output.
Key takeaway. Close is a one-way state transition; sends are forbidden after close.
Middle¶
Task 7: multi-sender close with synchronising closer¶
Goal. Three goroutines each send 10 integers on a shared channel. The channel must close exactly once after all senders are done. Consumer prints all 30 values.
Hint. sync.WaitGroup + a separate closer goroutine that calls Wait then close.
Solution.
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
ch <- id*100 + j
}
}(i)
}
go func() {
wg.Wait()
close(ch)
}()
total := 0
for v := range ch {
_ = v
total++
}
fmt.Println("received", total)
}
Expected output.
Key takeaway. Single-closer pattern via Wait + close in a coordinator goroutine.
Task 8: idempotent close with sync.Once¶
Goal. Build a SafeChannel type with a Close() method that may be called any number of times safely. Verify by calling Close 5 times.
Hint. Embed a sync.Once; wrap the close call.
Solution.
package main
import (
"fmt"
"sync"
)
type SafeChannel struct {
Ch chan int
once sync.Once
}
func New() *SafeChannel { return &SafeChannel{Ch: make(chan int)} }
func (s *SafeChannel) Close() {
s.once.Do(func() { close(s.Ch) })
}
func main() {
s := New()
for i := 0; i < 5; i++ {
s.Close() // safe
}
_, ok := <-s.Ch
fmt.Println("ok =", ok)
}
Expected output.
Key takeaway. sync.Once.Do guarantees the close runs at most once.
Task 9: pipeline with cascading close¶
Goal. Build a 3-stage pipeline: source emits 1..10, square squares them, sum accumulates. Run cleanly; close cascades.
Solution.
package main
import "fmt"
func source(nums []int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func sum(in <-chan int) int {
total := 0
for n := range in {
total += n
}
return total
}
func main() {
s := source([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
q := square(s)
fmt.Println(sum(q))
}
Expected output.
Key takeaway. Each stage closes its own output via defer close. The cascade is natural.
Task 10: fan-in with merge¶
Goal. Merge three input channels into one output channel. The output closes when all three inputs close.
Hint. One goroutine per input copying to output; WaitGroup + closer for the output.
Solution.
package main
import (
"fmt"
"sync"
)
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}
wg.Add(len(cs))
for _, c := range cs {
go output(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func gen(start, n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < n; i++ {
out <- start + i
}
}()
return out
}
func main() {
a := gen(0, 5)
b := gen(100, 5)
c := gen(1000, 5)
count := 0
for v := range merge(a, b, c) {
_ = v
count++
}
fmt.Println("received", count)
}
Expected output.
Task 11: cancellable generator¶
Goal. Build a generator that produces an infinite stream but exits cleanly when a context.Context is cancelled. Demonstrate by cancelling after 100 ms.
Solution.
package main
import (
"context"
"fmt"
"time"
)
func infinite(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
i := 0
for {
select {
case <-ctx.Done():
return
case out <- i:
i++
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
count := 0
for range infinite(ctx) {
count++
}
fmt.Println("emitted", count)
}
Expected output. A large variable count; the channel closes cleanly within ~100 ms.
Key takeaway. Cancellable generator: select on ctx.Done() + out <- v; defer close(out).
Task 12: done channel without closing data channel¶
Goal. Three senders share a data channel. A separate done channel signals shutdown. After done closes, all senders return, but the data channel is never closed (only drained). Demonstrate.
Solution.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
data := make(chan int)
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; ; j++ {
select {
case <-done:
return
case data <- id*100 + j:
}
}
}(i)
}
received := 0
go func() {
for range data {
received++
}
}()
time.Sleep(50 * time.Millisecond)
close(done)
wg.Wait()
fmt.Println("received", received)
}
Expected output. A large variable number; senders exited cleanly after done closed.
Note. data is intentionally never closed. The reader goroutine is leaked (intentionally for this demo). In production, you would close data after wg.Wait() and pair with a final drainer.
Task 13: error result via wrapped struct¶
Goal. A worker emits results with possible errors. Use a Result struct; close the channel after the last result.
Solution.
package main
import (
"errors"
"fmt"
)
type Result struct {
Value int
Err error
}
func work(items []int) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for _, it := range items {
if it < 0 {
out <- Result{Err: errors.New("negative")}
return
}
out <- Result{Value: it * 2}
}
}()
return out
}
func main() {
for r := range work([]int{1, 2, 3, -1, 4}) {
if r.Err != nil {
fmt.Println("error:", r.Err)
return
}
fmt.Println("value:", r.Value)
}
}
Expected output.
Senior¶
Task 14: graceful HTTP server shutdown¶
Goal. Build a tiny HTTP server with a /wait endpoint that takes 5 seconds to respond. On SIGTERM, the server should close the listener, wait for in-flight handlers, and exit cleanly.
Hint. Use http.Server.Shutdown with a timeout.
Solution.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/wait", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(5 * time.Second):
fmt.Fprintln(w, "done")
case <-r.Context().Done():
// client disconnected
}
})
srv := &http.Server{Addr: ":8080", Handler: mux}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
log.Println("shutting down...")
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Println("shutdown error:", err)
}
log.Println("server exited")
}
Test. Run, hit curl http://localhost:8080/wait, then Ctrl-C the server. Observe it waits for the in-flight request.
Key takeaway. http.Server.Shutdown is the canonical "close listener, wait for handlers" pattern. Internally it uses close on a done channel.
Task 15: bounded broadcaster¶
Goal. Build a broadcaster that supports up to 100 subscribers. Each subscriber receives every published event via its own channel. Slow subscribers drop messages.
Solution.
package main
import (
"fmt"
"sync"
"time"
)
type Broadcaster struct {
mu sync.Mutex
subs map[chan int]struct{}
closed bool
}
func New() *Broadcaster {
return &Broadcaster{subs: make(map[chan int]struct{})}
}
func (b *Broadcaster) Subscribe() (<-chan int, func()) {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan int, 16)
if b.closed {
close(ch)
return ch, func() {}
}
b.subs[ch] = struct{}{}
unsub := func() {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.subs[ch]; ok {
delete(b.subs, ch)
close(ch)
}
}
return ch, unsub
}
func (b *Broadcaster) Publish(v int) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- v:
default:
}
}
}
func (b *Broadcaster) Close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
b.closed = true
for ch := range b.subs {
close(ch)
}
b.subs = nil
}
func main() {
b := New()
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
ch, _ := b.Subscribe()
go func(id int, c <-chan int) {
defer wg.Done()
for v := range c {
fmt.Println("sub", id, "got", v)
}
fmt.Println("sub", id, "exited")
}(i, ch)
}
for i := 0; i < 5; i++ {
b.Publish(i)
}
time.Sleep(50 * time.Millisecond)
b.Close()
wg.Wait()
}
Key takeaway. Each subscriber's channel is owned by the broadcaster; Close closes them all. sync.Once-style idempotence via b.closed flag.
Task 16: detect a goroutine leak with close-correctness¶
Goal. Write a test that fails if a function leaks a goroutine. Use runtime.NumGoroutine before and after.
Hint. Capture baseline, run the function, sleep briefly to let cleanup finish, check delta.
Solution.
package main
import (
"runtime"
"testing"
"time"
)
func leaky() {
ch := make(chan int)
go func() {
<-ch // never closes; leaks
}()
}
func clean() {
ch := make(chan int)
done := make(chan struct{})
go func() {
select {
case <-ch:
case <-done:
}
}()
close(done)
}
func TestLeaky(t *testing.T) {
base := runtime.NumGoroutine()
leaky()
time.Sleep(10 * time.Millisecond)
after := runtime.NumGoroutine()
if after > base {
t.Logf("leaked %d goroutines", after-base)
}
}
func TestClean(t *testing.T) {
base := runtime.NumGoroutine()
clean()
time.Sleep(10 * time.Millisecond)
after := runtime.NumGoroutine()
if after > base {
t.Errorf("leaked %d goroutines", after-base)
}
}
Key takeaway. clean uses a done channel + close to signal exit; leaky doesn't, so its goroutine is stuck.
Task 17: race-free multi-sender with done channel¶
Goal. Combine the multi-sender pattern with a done channel for cancellation. Senders observe both "done" and "data full" cases.
Solution.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
data := make(chan int)
done := make(chan struct{})
var wg sync.WaitGroup
sent := atomic.Int64{}
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; ; j++ {
select {
case <-done:
return
case data <- id*1000 + j:
sent.Add(1)
}
}
}(i)
}
received := 0
go func() {
for range data {
received++
}
}()
time.Sleep(50 * time.Millisecond)
close(done)
wg.Wait()
// drain any pending sends that may have happened between done check and our close
// (none here because all senders exit on done; but pattern shown)
close(data)
fmt.Printf("sent=%d received=%d\n", sent.Load(), received)
}
Key takeaway. After wg.Wait() confirms all senders exited, close(data) is safe. The done channel cancels; the wait barrier proves quiescence; then close.
Task 18: timeout-bounded shutdown¶
Goal. A worker may be in a long operation. Shutdown must wait up to 2 seconds; after that, give up and log.
Solution.
package main
import (
"fmt"
"sync"
"time"
)
type Worker struct {
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
func NewWorker() *Worker {
w := &Worker{done: make(chan struct{})}
w.wg.Add(1)
go func() {
defer w.wg.Done()
select {
case <-w.done:
fmt.Println("worker stopping cleanly")
case <-time.After(10 * time.Second):
fmt.Println("worker timeout (long operation)")
}
}()
return w
}
func (w *Worker) Stop(timeout time.Duration) error {
w.once.Do(func() { close(w.done) })
finished := make(chan struct{})
go func() {
w.wg.Wait()
close(finished)
}()
select {
case <-finished:
return nil
case <-time.After(timeout):
return fmt.Errorf("shutdown timed out after %v", timeout)
}
}
func main() {
w := NewWorker()
time.Sleep(20 * time.Millisecond)
if err := w.Stop(2 * time.Second); err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("stopped cleanly")
}
}
Key takeaway. Two closes: done for the signal, finished to bound the wait. The combination of close + select + timeout is the canonical bounded shutdown.
Task 19: pipeline cancellation with context¶
Goal. Build a 3-stage pipeline that respects ctx.Done() for cancellation. Cancel mid-stream; verify clean exit.
Solution.
package main
import (
"context"
"fmt"
"time"
)
func source(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
return out
}
func double(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case <-ctx.Done():
return
case out <- n * 2:
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
p := double(ctx, source(ctx))
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
count := 0
for range p {
count++
}
fmt.Println("count", count)
}
Key takeaway. Every stage has defer close(out) and selects on ctx.Done(). Cancellation cascades cleanly.
Task 20: detect "close called twice" in tests¶
Goal. Write a test that asserts a Close method is idempotent: calling it twice does not panic.
Solution.
package main
import (
"sync"
"testing"
)
type Resource struct {
ch chan int
once sync.Once
}
func New() *Resource { return &Resource{ch: make(chan int)} }
func (r *Resource) Close() { r.once.Do(func() { close(r.ch) }) }
func TestIdempotentClose(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatal("unexpected panic:", r)
}
}()
r := New()
for i := 0; i < 100; i++ {
r.Close()
}
}
Task 21: priority select with closed done¶
Goal. Write a loop that prefers to check a "done" channel before processing work. If done closes, exit immediately, even if work is ready.
Solution.
package main
import "fmt"
func loop(done <-chan struct{}, work <-chan int) {
for {
// priority: check done first, non-blocking
select {
case <-done:
return
default:
}
// then multiplexed select
select {
case <-done:
return
case v := <-work:
fmt.Println("work:", v)
}
}
}
func main() {
done := make(chan struct{})
work := make(chan int, 10)
for i := 0; i < 5; i++ {
work <- i
}
close(done)
loop(done, work)
}
Expected output. Nothing (the priority check on done exits immediately).
Bonus Challenges¶
Bonus 1: implement a "Once" using only channels¶
type Once struct {
done chan struct{}
do chan func()
}
func NewOnce() *Once {
o := &Once{
done: make(chan struct{}),
do: make(chan func()),
}
go func() {
f := <-o.do
f()
close(o.done)
}()
return o
}
func (o *Once) Do(f func()) {
select {
case o.do <- f: // first caller wins
<-o.done
case <-o.done: // already done
}
}
A goroutine runs the first f then closes done. All subsequent Do calls see done closed via select and return.
Bonus 2: implement errgroup.Wait() semantics with close¶
type Group struct {
wg sync.WaitGroup
once sync.Once
err error
done chan struct{}
}
func NewGroup() *Group { return &Group{done: make(chan struct{})} }
func (g *Group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.once.Do(func() {
g.err = err
close(g.done)
})
}
}()
}
func (g *Group) Wait() error {
g.wg.Wait()
return g.err
}
func (g *Group) Done() <-chan struct{} { return g.done }
The first error closes done. Subscribers select on Done() to know about the error.
Bonus 3: bounded subscription with auto-close on idle¶
Subscribers idle for >5s are auto-unsubscribed (their channel closed).
Self-Check¶
After completing the tasks:
- You can write a generator that closes correctly.
- You can read a closed channel and distinguish close from real zero.
- You can build a multi-sender pattern with synchronising closer.
- You can build a pipeline with cascading close.
- You can use a done channel as a broadcast signal.
- You can integrate close with
context.Context. - You can test for goroutine leaks.
- You can implement bounded shutdown with timeout.
- You can avoid double-close with
sync.Once. - You can diagnose a "send on closed" panic.