IM

ISSA MDOE

Full Stack Software Engineer

Loading0%
2025-12-15 14 min read

Mastering Concurrency in Go: Patterns and Pitfalls

#Go#Backend#Concurrency
👨🏽‍💻
Issa Ally Mdoe
Full Stack Engineer

Go was born in the cloud era, designed specifically to handle concurrent workloads efficiently. Its primitives—Goroutines and Channels—make concurrency accessible, but mastering them requires understanding patterns and avoiding race conditions.

Goroutines: Lightweight Threads

Unlike OS threads which consume ~1MB stack size, Goroutines start with just ~2KB. This means you can spin up hundreds of thousands of concurrent routines on a single machine.

go
func main() {
    go processData("A") // Runs concurrently
    processData("B")    // Runs in main thread
}

Channels: Typesafe Communication

"Don't communicate by sharing memory; share memory by communicating."

Channels allow safe data exchange between Goroutines.

Unbuffered vs Buffered

  • Unbuffered: make(chan int). The sender blocks until the receiver is ready. Guarantees synchronization.
  • Buffered: make(chan int, 100). The sender only blocks when the buffer is full. Good for bursting workloads.

Pattern: The Worker Pool

Spawning a goroutine for every HTTP request is fine, but for CPU-intensive tasks, you might want to limit parallelism to avoid resource exhaustion.

go
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("Worker %d started job %d
", id, j)
        time.Sleep(time.Second) // Simulate work
        results <- j * 2
        fmt.Printf("Worker %d finished job %d
", id, j)
    }
}

func main() { jobs := make(chan int, 100) results := make(chan int, 100)

// Start 3 workers for w := 1; w <= 3; w++ { go worker(w, jobs, results) }

// Send 5 jobs for j := 1; j <= 5; j++ { jobs <- j } close(jobs)

// Collect results for a := 1; a <= 5; a++ { <-results } } `

Pattern: Graceful Shutdown with Context

When you need to cancel long-running operations (e.g. user cancelled request), use context.

go
func operation(ctx context.Context) {
    select {
    case <-time.After(5 * time.Second):
        fmt.Println("Done")
    case <-ctx.Done():
        fmt.Println("Cancelled:", ctx.Err())
    }
}

Avoiding Race Conditions

Whenever multiple goroutines access the same variable, you risk a race condition.

  1. Use sync.Mutex: Lock access to the critical section.
  2. Use atomic package: For simple counters/flags.
  3. Use -race flag: Run tests with go test -race to detect races automatically.

Concurrency is hard, but Go's primitives make the complex patterns manageable and readable.


Enjoyed this article?
Share it with your network