Go Concurrency: Can You Spot the Bugs Lurking in These Programs?
Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy. Concurrency in Go is a blast—goroutines, channels, and the go keyword make it feel like you’re orchestrating a symphony. But symphonies can go off-key. Bugs like race conditions, deadlocks, and sneaky goroutine leaks love to hide in concurrent code. Today, I’ve got three Go programs for you, each with a hidden flaw. Your mission? Spot the bug just by reading the code. No running it (yet)—let’s see how sharp your concurrency senses are. We’ll start simple and build up to trickier stuff. Each program is self-contained—copy and paste to test your theories later. I’ll give you space to think, drop a hint if you’re stuck, then reveal the bug and a fixed version with a beginner-friendly breakdown. Ready? Let’s dive in! Program 1: The Counter package main import ( "fmt" "sync" ) func main() { var counter int var wg sync.WaitGroup for i := 0; i

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.
Concurrency in Go is a blast—goroutines, channels, and the go
keyword make it feel like you’re orchestrating a symphony.
But symphonies can go off-key.
Bugs like race conditions, deadlocks, and sneaky goroutine leaks love to hide in concurrent code.
Today, I’ve got three Go programs for you, each with a hidden flaw.
Your mission? Spot the bug just by reading the code.
No running it (yet)—let’s see how sharp your concurrency senses are.
We’ll start simple and build up to trickier stuff.
Each program is self-contained—copy and paste to test your theories later.
I’ll give you space to think, drop a hint if you’re stuck, then reveal the bug and a fixed version with a beginner-friendly breakdown.
Ready? Let’s dive in!
Program 1: The Counter
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
counter++
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}
What’s wrong here? Take a minute. Read it line by line. What should it print? What might it print instead?
…space to think…
Hint: What happens when multiple goroutines touch the same variable at the same time?
Solution: Fixing a Race Condition
Run this, and you’ll see Final counter:
print something like 923 or 987—rarely 1000. The bug is a race condition. Here’s why: counter++
isn’t one operation—it’s three (read, increment, write). When 1000 goroutines hit it simultaneously, some increments get overwritten. One goroutine reads 5, another reads 5, both write 6—poof, a count vanishes.
Here’s the fixed version:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
var mu sync.Mutex
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
mu.Lock() // Only one goroutine at a time
counter++ // Safe increment
mu.Unlock() // Let others in
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}
Explanation: The sync.Mutex
locks counter
so only one goroutine can modify it at a time. Others wait their turn. Now you’ll always get Final counter: 1000
. For beginners: a mutex is like a “one at a time” rule—keeps the chaos in check.
You can try Go code on the web at the Go Playground.
Program 2: The Printer
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
ch <- i
}
}()
for num := range ch {
fmt.Println("Received:", num)
}
}
What’s off? Imagine the output. Will it work as expected? Stare at it for a bit.
…space to think…
Hint: How does a range
loop over a channel know when to stop?
Solution: Escaping a Deadlock
Run this, and it hangs—no output, just silence. The bug is a deadlock. The range
loop waits for more data or a signal to stop, but the sender never says “I’m done.” Without closing the channel, the main goroutine is stuck forever.
Fixed version:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch) // Signal the end
}()
for num := range ch {
fmt.Println("Received:", num)
}
}
Explanation: Adding close(ch)
tells the range
loop there’s no more data coming. Now it prints Received: 1
to Received: 5
and exits. Beginners: closing a channel is like hanging up the phone—it’s how you say “all done!”
You can try Go code on the web at the Go Playground.
Program 3: The Task Pool
package main
import (
"fmt"
"time"
)
func main() {
tasks := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 3; w++ {
go func() {
for task := range tasks {
time.Sleep(10 * time.Millisecond)
results <- task * 2
}
}()
}
for i := 0; i < 1000; i++ {
tasks <- i
}
for i := 0; i < 1000; i++ {
fmt.Println(<-results)
}
close(tasks)
close(results)
}
What’s hiding here? It processes 1000 tasks—should be fine, right? Dig in.
…space to think…
Hint: What happens to the workers after the tasks are sent?
Solution: Stopping Goroutine Leaks and Deadlocks
Run it, and it might hang or finish unpredictably. Two bugs: goroutine leaks and potential deadlock. The workers keep running, waiting on tasks
, because we close it too late. Plus, if results
fills up (buffer 100) while sending tasks, it stalls.
Fixed version:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
tasks := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
// Start workers
for w := 0; w < 3; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range tasks {
time.Sleep(10 * time.Millisecond)
results <- task * 2
}
}()
}
// Send tasks and close channel
go func() {
for i := 0; i < 1000; i++ {
tasks <- i
}
close(tasks) // Close tasks to signal workers
}()
// Collect results
for i := 0; i < 1000; i++ {
fmt.Println(<-results)
}
wg.Wait() // Wait for workers to finish
close(results) // Now safe to close
}
Explanation: The bug was a goroutine leak and potential deadlock. Workers hung on range tasks because close(tasks) came too late, and a full results buffer could block everything. Now, we close tasks right after sending (in a goroutine to avoid blocking main), letting workers exit. The WaitGroup ensures we close results only after all work is done. It prints all 1000 doubled values reliably. Beginners: always shut down your channels properly—don’t leave goroutines dangling!
You can try Go code on the web at the Go Playground.
Quick Recap: Bug Spotting Cheat Sheet
Program | Bug Type | Symptoms | Fix |
---|---|---|---|
The Counter | Race Condition | Counter < 1000 | Use sync.Mutex
|
The Printer | Deadlock | Hangs, no output | Close the channel |
The Task Pool | Goroutine Leak | Hangs or slow exit | Close tasks early |
Share your Golang Concurrency Gotchas!
Concurrency in Go is a puzzle—fun to solve, but tricky to master.
Did you catch all the bugs? Run these with go run -race
or tweak the numbers to see the chaos unfold.
Share your own concurrency horror stories below—I’m all ears.
For more, check the Go docs or Rob Pike’s Concurrency talk.
Happy debugging!