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
}
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)
}()
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?
}
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)
}
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++
}()
}
}
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()
Or:
atomic.AddInt32(&count, 1)
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!
}()
}
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)
}
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!
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
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
}
}
}
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 theatomic
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)