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

Mar 31, 2025 - 19:27
 0
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 < 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!