DEV Community

Moksh
Moksh

Posted on

Mastering Goroutines in Go: Common Pitfalls and How to Avoid Them

Goroutines are one of the most powerful features in Go. With just one keyword go, you can execute functions concurrently and build highly scalable systems.

But power comes with responsibility. In real world projects, careless use of goroutines can lead to memory leaks, deadlocks, race conditions, and unpredictable behavior.

In this post, we’ll explore common goroutine pitfalls and how to avoid them with practical examples you can apply in production.


Leaking Goroutines

Problem

A goroutine starts and never exits, typically because it's waiting forever on a channel or a blocking operation.

func process(ch chan int) {
    for val := range ch {
        fmt.Println(val)
    }
}

func main() {
    ch := make(chan int)
    go process(ch)
    // forgot to close the channel
}
Enter fullscreen mode Exit fullscreen mode

Why It’s Dangerous

The process() goroutine never exits, leading to memory leaks over time if this happens repeatedly.

How to Fix It

Make sure to close the channel:

go func() {
    ch <- 1
    close(ch)
}()
Enter fullscreen mode Exit fullscreen mode

For long running systems, it's better to use context.WithCancel() to gracefully exit.


2. Uncontrolled Goroutine Spawning

Problem

Spawning thousands of goroutines in a loop without any control.

for _, item := range items {
    go process(item) // What if items = 100,000?
}
Enter fullscreen mode Exit fullscreen mode

Why It’s Dangerous

It can exhaust system resources and cause performance degradation or even crashes.

How to Fix It

Use a semaphore or worker pool to limit concurrency:

sem := make(chan struct{}, 100) // max 100 goroutines
for _, item := range items {
    sem <- struct{}{}
    go func(item string) {
        defer func() { <-sem }()
        process(item)
    }(item)
}
Enter fullscreen mode Exit fullscreen mode

3. Race Conditions

Problem

Accessing shared variables from multiple goroutines without synchronization.

var count int

func main() {
    for i := 0; i < 1000; i++ {
        go func() {
            count++
        }()
    }
}
Enter fullscreen mode Exit fullscreen mode

Why It’s Dangerous

Leads to unpredictable behavior and bugs that are hard to reproduce.

How to Fix It

Use sync.Mutex or sync/atomic:

var mu sync.Mutex
mu.Lock()
count++
mu.Unlock()
Enter fullscreen mode Exit fullscreen mode

Or:

atomic.AddInt32(&count, 1)
Enter fullscreen mode Exit fullscreen mode

4. Incorrect Closure Capture

Problem

Using loop variables directly inside goroutines without capturing their value properly.

for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // Might print 5 five times!
    }()
}
Enter fullscreen mode Exit fullscreen mode

Why It’s Dangerous

The goroutine may access a variable that has already changed, leading to unexpected output.

How to Fix It

Pass the loop variable explicitly:

for i := 0; i < 5; i++ {
    go func(val int) {
        fmt.Println(val)
    }(i)
}
Enter fullscreen mode Exit fullscreen mode

5. Deadlocks

Problem

Improper use of channels causing blocking operations with no corresponding receiver.

ch := make(chan int)
ch <- 5 // Fatal error: all goroutines are asleep, deadlock!
Enter fullscreen mode Exit fullscreen mode

Why It’s Dangerous

It causes the program to freeze or crash.

How to Fix It

Ensure proper coordination of send and receive operations:

go func() { ch <- 5 }()
val := <-ch
Enter fullscreen mode Exit fullscreen mode

6. No Context Cancellation

Problem

Goroutines continue running even after a request or job has been cancelled.

Why It’s Dangerous

Wastes CPU/memory and may perform invalid work after the operation is no longer needed.

How to Fix It

Use context.Context to handle cancellation:

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // do work
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary: Key Pitfalls and How to Avoid Them

  • Goroutine Leak: Use context and close channels when done.
  • Too Many Goroutines: Limit concurrency using semaphores or worker pools.
  • Race Conditions: Use sync.Mutex or the atomic package.
  • Closure Capture Mistake: Always pass loop variables explicitly into goroutines.
  • Deadlocks: Coordinate send and receive operations correctly on channels.
  • No Context Propagation: Always pass and handle context.Context in goroutines.

Final Thoughts

Goroutines are simple to start but tricky to scale safely. As your system grows, so do the chances of hitting these subtle bugs. Following best practices with context handling, proper synchronization, and resource control will help you build robust and maintainable concurrent applications in Go.

Mastering goroutines isn’t just about using go it’s about knowing when not to.

Top comments (0)