Day 16: Goroutines and Channels
Core Concept: Communicating Sequential Processes
Go’s concurrency is based on CSP: goroutines are lightweight independently-executing functions; channels are typed conduits for communication between them.
“Do not communicate by sharing memory; share memory by communicating.”
Goroutines
A goroutine is started with the go keyword. It is not a thread — the Go runtime multiplexes thousands of goroutines onto a small number of OS threads.
go func() {
fmt.Println("running concurrently")
}()
// main does not wait — need to synchronise
Channels
ch := make(chan int) // unbuffered — sender blocks until receiver is ready
ch := make(chan int, 10) // buffered — sender blocks only when full
ch <- 42 // send
v := <-ch // receive
v, ok := <-ch // ok is false when channel is closed and empty
close(ch) // signal no more values
range over a channel receives until closed:
for v := range ch {
fmt.Println(v)
}
select
select waits on multiple channel operations — like a switch for channels:
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
Fan-Out / Fan-In
// Fan-out: distribute work across N workers
func fanOut(in <-chan string, n int) []<-chan string {
outs := make([]<-chan string, n)
for i := range outs {
outs[i] = worker(in)
}
return outs
}
// Fan-in: merge multiple channels into one
func merge(channels ...<-chan string) <-chan string {
out := make(chan string)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan string) {
defer wg.Done()
for v := range c { out <- v }
}(ch)
}
go func() { wg.Wait(); close(out) }()
return out
}
Done Channel (Cancellation)
done := make(chan struct{})
go func() {
select {
case <-done:
return
case result := <-work:
process(result)
}
}()
close(done) // signal all goroutines to stop
Labs
Lab 1: First Goroutine — sync.WaitGroup
What you’ll practise: Launching goroutines and using sync.WaitGroup to wait for them all.
Task:
Launch 5 goroutines, each printing its number. First observe the non-deterministic output without synchronisation. Then add a sync.WaitGroup so main waits for all goroutines to finish.
Steps:
- Launch 5 goroutines in a loop without any waiting — run it several times and notice the order changes (or goroutines may not print at all if main exits first)
- Add
var wg sync.WaitGroup, callwg.Add(1)before eachgo,defer wg.Done()inside, andwg.Wait()in main - Run again — all 5 numbers appear every time, order may still vary
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("goroutine %d running\n", n)
}(i)
}
wg.Wait()
fmt.Println("all done")
Expected output (order varies):
goroutine 3 running
goroutine 0 running
goroutine 4 running
goroutine 1 running
goroutine 2 running
all done
Checkpoint: Every run prints all 5 goroutine lines before “all done”.
Lab 2: Unbuffered Channel — Synchronous Handshake
What you’ll practise: Sending and receiving on an unbuffered channel to observe blocking behaviour.
Task:
Create an unbuffered chan int. Launch a producer goroutine that sends 1..10. In main, receive and print each value. Add fmt.Println before and after the send to see that the producer blocks until main is ready.
Steps:
- Create
ch := make(chan int)(no second argument) - Launch a goroutine that sends
1..10then closes the channel - In main, receive with
v := <-chin a loop - Add log lines around the send to visualise the blocking handshake
ch := make(chan int)
go func() {
for i := 1; i <= 10; i++ {
fmt.Printf("sending %d\n", i)
ch <- i
fmt.Printf("sent %d\n", i)
}
close(ch)
}()
for v := range ch {
fmt.Printf("received %d\n", v)
}
Expected output:
sending 1
received 1
sent 1
sending 2
received 2
...
Checkpoint: “sending N” and “received N” always appear together — the sender never races ahead.
Lab 3: Buffered Channel — Producer Runs Ahead
What you’ll practise: Understanding how buffer capacity decouples sender and receiver timing.
Task:
Change the channel from Lab 2 to make(chan int, 5). Observe that the producer can send up to 5 values before blocking. Add a time.Sleep in the receiver to exaggerate the effect.
Steps:
- Change to
ch := make(chan int, 5) - Add
time.Sleep(100 * time.Millisecond)in the receiver loop - Run and observe the producer fires off 5 sends rapidly, then blocks waiting for room
- Remove the sleep and compare the timing
ch := make(chan int, 5)
go func() {
for i := 1; i <= 10; i++ {
ch <- i
fmt.Printf("queued %d\n", i)
}
close(ch)
}()
for v := range ch {
time.Sleep(100 * time.Millisecond)
fmt.Printf("processed %d\n", v)
}
Expected output:
queued 1
queued 2
queued 3
queued 4
queued 5
processed 1
queued 6
processed 2
...
Checkpoint: The first 5 “queued” lines appear before the first “processed” line.
Lab 4: Range over Channel — Clean Producer/Consumer
What you’ll practise: Using close(ch) and for v := range ch for a clean, panic-free consumer.
Task:
Write a generate(nums ...int) <-chan int function that sends numbers on a channel and closes it when done. In main, consume with for v := range ch. Show that ranging over a closed channel terminates naturally.
Steps:
generatereturns a receive-only<-chan int- Inside
generate, launch a goroutine that sends each number then callsclose(ch) - In main:
for v := range generate(1, 2, 3, 4, 5) { fmt.Println(v) } - Demonstrate that no sentinel value or separate done channel is needed
func generate(nums ...int) <-chan int {
ch := make(chan int)
go func() {
for _, n := range nums {
ch <- n
}
close(ch)
}()
return ch
}
func main() {
for v := range generate(1, 2, 3, 4, 5) {
fmt.Println(v)
}
}
Expected output:
1
2
3
4
5
Checkpoint: No goroutine leaks — the producer goroutine exits after close(ch).
Lab 5: Select — Multiplexing Channels with Timeout
What you’ll practise: Using select to multiplex two channels and handle timeouts.
Task:
Create two ticker channels firing at different rates (200ms and 500ms). Use select in a loop to print which fired. After 2 seconds, use time.After in the select to break out of the loop.
Steps:
- Create
fast := time.NewTicker(200 * time.Millisecond)andslow := time.NewTicker(500 * time.Millisecond) - Add
timeout := time.After(2 * time.Second) selecton all three channels; on timeout,returnorbreak- Add a
defaultcase, observe it spins; removedefault, observe it blocks cleanly
fast := time.NewTicker(200 * time.Millisecond)
slow := time.NewTicker(500 * time.Millisecond)
timeout := time.After(2 * time.Second)
for {
select {
case t := <-fast.C:
fmt.Println("fast tick at", t.Format("15:04:05.000"))
case t := <-slow.C:
fmt.Println("slow tick at", t.Format("15:04:05.000"))
case <-timeout:
fmt.Println("done")
return
}
}
Expected output:
fast tick at 00:00:00.200
fast tick at 00:00:00.400
slow tick at 00:00:00.500
fast tick at 00:00:00.600
...
done
Checkpoint: The loop exits at approximately 2 seconds and prints roughly 10 fast ticks and 4 slow ticks.
Lab 6: Done Channel Pattern — Graceful Shutdown
What you’ll practise: Using a done chan struct{} to signal goroutines to stop.
Task:
Start a worker goroutine that processes items in an infinite loop, printing each item. After 1 second, close the done channel and verify the worker exits cleanly — no goroutine leak.
Steps:
- Create
done := make(chan struct{})andjobs := make(chan int, 10) - Launch a worker that
selects onjobsanddone - Send 5 jobs, sleep 1 second, then
close(done) - Use a
sync.WaitGroupto confirm the worker exited
done := make(chan struct{})
jobs := make(chan int, 10)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
fmt.Println("worker shutting down")
return
case j := <-jobs:
fmt.Printf("processing job %d\n", j)
}
}
}()
for i := 1; i <= 5; i++ { jobs <- i }
time.Sleep(time.Second)
close(done)
wg.Wait()
fmt.Println("clean exit")
Expected output:
processing job 1
...
processing job 5
worker shutting down
clean exit
Checkpoint: wg.Wait() returns and “clean exit” prints — no goroutine remains.
Lab 7: Fan-Out — Distribute Work Across N Workers
What you’ll practise: The fan-out pattern: one jobs channel consumed by multiple workers.
Task:
Create a jobs channel and results channel. Launch N=4 worker goroutines that each read from jobs, square the number, and send to results. Send 20 jobs, collect all results, and print them.
Steps:
- Create
jobs := make(chan int, 20)andresults := make(chan int, 20) - Launch 4 workers, each reading from
jobsand writing toresults - Send integers 1..20 on
jobs, thenclose(jobs) - Use
sync.WaitGroupto closeresultsafter all workers finish - Collect and print all results
const numWorkers = 4
jobs := make(chan int, 20)
results := make(chan int, 20)
var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}()
}
for i := 1; i <= 20; i++ { jobs <- i }
close(jobs)
go func() { wg.Wait(); close(results) }()
for r := range results {
fmt.Println(r)
}
Expected output (order varies):
1
4
9
...
400
Checkpoint: All 20 squared values appear in the output (in any order). The program exits cleanly.
Final Lab (Project): Parallel File Hasher
What you’ll practise: Applying goroutines, channels, sync.WaitGroup, and a semaphore pattern to a real concurrent program.
Task: Write a program that walks a directory and hashes every file concurrently using SHA-256, collecting results through a channel and printing them sorted by filename.
Steps:
- Accept a directory path from
os.Args[1] - Walk with
filepath.WalkDir, skip directories - Use a semaphore (
make(chan struct{}, N)) to cap concurrency atruntime.NumCPU()workers - Launch a goroutine per file: acquire semaphore, hash with
crypto/sha256, release semaphore, send result on channel - Use
sync.WaitGroupto close the results channel after all goroutines finish - Collect results into a slice, sort by filename, print
filename: hexhash
sem := make(chan struct{}, runtime.NumCPU())
var wg sync.WaitGroup
results := make(chan result)
go func() { wg.Wait(); close(results) }()
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { return err }
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
data, _ := os.ReadFile(path)
results <- result{path, fmt.Sprintf("%x", sha256.Sum256(data))}
}()
return nil
})
Expected output:
day-15/main.go: 3b4c9f...
day-15/README.md: a1f203...
Checkpoint: Output contains one line per file in the directory, sorted alphabetically by path, with no goroutine leak (program exits cleanly).
Day Project: Parallel File Hasher
Write a program that:
- Accepts a directory path from
os.Args[1] - Walks the directory with
filepath.WalkDir - Hashes each file concurrently using goroutines and
crypto/sha256 - Collects results through a channel
- Prints each
filename: hashsorted by filename
Use a semaphore (buffered channel) to limit concurrency to N workers.
sem := make(chan struct{}, runtime.NumCPU())
Extension ideas: add a progress bar using \r escape; support MD5/SHA512 via a flag; detect duplicate files by hash.
Official Documentation
sync— WaitGroup for goroutine coordinationruntime—NumCPU()for worker pool sizingpath/filepath—WalkDirfor directory traversalcrypto/sha256— SHA-256 file hashingos—os.Args, file readingfmt— formatted output- Language Spec: Go statements — goroutine launch syntax
- Language Spec: Channel types — channel declaration and direction
- Language Spec: Select statements — multi-channel select
- Effective Go: Concurrency — goroutines and channels
- Go Blog: Go Concurrency Patterns: Pipelines — fan-out/fan-in patterns
- Go Tour: Concurrency — interactive goroutines and channels tour