Welcome back to our in-depth series on Golang Goroutines! If you’ve been following along, Part 1 introduced the basics of goroutines and simple synchronization, while Part 2 delved into channels for communication and patterns like worker pools. Now, in Part 3, we’re tackling advanced topics to make your concurrent code more robust and production-ready.
We’ll explore mutexes and other sync primitives for handling shared state, the context package for cancellation and timeouts, error handling with groups, and common pitfalls to avoid. As always, we’ll include detailed code examples. This part builds on the previous ones, so if you’re new, start from the beginning. Let’s level up your Go concurrency skills!
Mutexes and Protecting Shared State
While channels encourage sharing by communicating, sometimes you need to access shared mutable state directly (e.g., a counter or map). For that, the sync package provides mutexes to prevent race conditions.
What is a Mutex?
A Mutex (mutual exclusion) is a lock that ensures only one goroutine accesses a critical section at a time. Use sync.Mutex for basic locking or sync.RWMutex for read-write scenarios (multiple readers, single writer).
Basic Usage:
package main
import (
"fmt"
"sync"
)
var (
counter int
mu sync.Mutex
)
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
counter++ // Critical section
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Counter:", counter) // Should be 1000
}
Without the mutex, the counter might end up less than 1000 due to races. Always pair Lock with Unlock, and use defer mu.Unlock() after Lock for safety.
RWMutex for Better Performance
For read-heavy scenarios:
Go
var (
data = make(map[string]int)
rwmu sync.RWMutex
)
func read(key string) int {
rwmu.RLock()
defer rwmu.RUnlock()
return data[key]
}
func write(key string, value int) {
rwmu.Lock()
defer rwmu.Unlock()
data[key] = value
}
Multiple goroutines can read simultaneously, but writes exclusive-lock everything.
Alternatives to Mutexes
- Atomic Operations: For simple types like integers, use sync/atomic (e.g., atomic.AddInt64)—faster than mutexes.
- Channels: Prefer them over mutexes when possible for idiomatic Go.
- Avoid: Global mutexes; pass them as parameters.
Tip: Use go run -race to detect race conditions during development.
Using Context for Cancellation and Timeouts
The context package is essential for managing goroutine lifecycles, especially in long-running operations like API calls or database queries. It carries deadlines, cancellation signals, and values across API boundaries.
Core Concepts
- Background Context: context.Background()—root for all contexts.
- TODO Context: context.TODO()—placeholder when unsure.
- Derived Contexts: Use WithCancel, WithTimeout, WithDeadline, or WithValue.
Example: Cancelling a goroutine.
Go
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
fmt.Println("Task completed")
case <-ctx.Done():
fmt.Println("Task cancelled:", ctx.Err())
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go longRunningTask(ctx)
time.Sleep(2 * time.Second)
cancel() // Signal cancellation
time.Sleep(1 * time.Second) // Give time to print
}
Output:
Task cancelled: context canceled
Timeouts and Deadlines
Use context.WithTimeout for automatic cancellation:
Go
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // Always defer cancel to release resources
go someTask(ctx)
Propagate contexts through your functions: func doWork(ctx context.Context) {…}.
Carrying Values
Store request-scoped data:
Go
ctx = context.WithValue(ctx, "userID", 123)
userID := ctx.Value("userID").(int)
Use sparingly—types should be comparable for safety.
Contexts prevent goroutine leaks by allowing clean shutdowns, crucial for servers.
Error Handling with ErrGroup
When running multiple goroutines, collecting errors is key. The golang.org/x/sync/errgroup package (import it) simplifies this.
Install if needed: go get golang.org/x/sync/errgroup
Example: Run tasks and wait with error propagation.
Go
package main
import (
"context"
"errors"
"fmt"
"golang.org/x/sync/errgroup"
)
func task1(ctx context.Context) error {
return nil // Success
}
func task2(ctx context.Context) error {
return errors.New("task2 failed")
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return task1(ctx) })
g.Go(func() error { return task2(ctx) })
if err := g.Wait(); err != nil {
fmt.Println("Error:", err) // Prints task2 failed
}
}
ErrGroup cancels the context on the first error, stopping other tasks. Great for parallel operations like fetching multiple APIs.
Common Concurrency Pitfalls and How to Avoid Them
Even with tools like channels and mutexes, bugs lurk. Here are real-world gotchas:
1. Deadlocks
- Cause: Goroutines waiting on each other (e.g., two mutexes locked in opposite order).
- Fix: Use consistent lock ordering, or better, redesign with channels. Debug with GODEBUG=schedtrace=1 or tools like dlv.
2. Goroutine Leaks
- Cause: Goroutines blocked forever (e.g., on a channel with no sender).
- Fix: Use contexts for cancellation, timeouts, and ensure channels are closed.
Example Leak:
Go
ch := make(chan int)
go func() { <-ch }() // Blocks forever if nothing sent
Fix: Close ch or use select with timeout.
3. Starvation
- Cause: Scheduler favors some goroutines, others wait indefinitely.
- Fix: Use fair mutexes or balance workloads.
4. Over-Concurrency
- Cause: Too many goroutines overwhelm the system.
- Fix: Limit with semaphores (sync.WaitGroup + bounded channels) or worker pools.
5. Incorrect Closure Captures
- As mentioned in Part 1, pass loop variables explicitly.
Debugging Tip: Use pprof for CPU/memory profiles and trace for execution traces.
Best Practices for Advanced Concurrency
- Propagate contexts everywhere.
- Handle errors gracefully—don’t let panics propagate.
- Test concurrently: Use testing.T with -race and fuzzing.
- Monitor in production: Metrics for goroutine count, channel depths.
- Libraries: Explore sync/once, sync/cond, or third-party like golang.org/x/sync/semaphore.
Conclusion
In Part 3 of our Goroutines series, we’ve covered advanced tools to handle shared state, cancellations, errors, and pitfalls. Armed with mutexes, contexts, and errgroups, you can build scalable, reliable concurrent systems in Go. In Part 4 We’ll cover Advanced Patterns, Performance Tuning, and Beyond.