Concurrency Done Right: Go’s Condition Variables

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. In Go concurrency, goroutines often need to wait for work, but doing so efficiently can be a challenge—constant checks waste CPU time, while delays slow things down. Condition variables, via sync.Cond, offer a solution by minimizing resource use and improving response times. In this post, we’ll explore how they address this issue and why understanding them can make you a more effective Go engineer. 1. The Problem: Wasting CPU Cycles Picture a worker goroutine tasked with processing items from a queue. A simple but inefficient solution is to have it constantly check the queue in a loop, burning CPU cycles while waiting for work. Case 1: Busy-Waiting Worker (Relentless Looping) package main import ( "fmt" "time" ) var queue []int var iterations int func worker() { for { iterations++ // Track each check if len(queue) == 0 { continue } break } } func main() { go worker() time.Sleep(2 * time.Second) // Let it spin fmt.Println("Busy-wait iterations:", iterations) } My Machine Reports Busy-wait iterations: 8,168,421,879 In just 2 seconds, this worker churned through over 8 billion iterations—all for nothing. That’s a staggering amount of CPU time wasted on empty checks. 2. Adding Sleep: Less Waste, Slower Response To curb the CPU hogging, a common tweak is to pause between checks using time.Sleep. package main import ( "fmt" "time" ) var sleepIterations int func worker() { queue := []int{} for { sleepIterations++ if len(queue) == 0 { time.Sleep(10 * time.Millisecond) continue } break } } func main() { go worker() time.Sleep(2 * time.Second) fmt.Println("Sleep-based iterations:", sleepIterations) } My Machine Reports Sleep-based iterations: 195 Now we’re down to ~200 checks instead of 8 billion—a huge improvement. But there’s a catch: the worker still wakes up periodically to check an empty queue, delaying its response when real work arrives. 3. Enter Condition Variables: Smart Waiting A condition variable offers a better way. It lets the worker sleep efficiently until explicitly signaled, slashing CPU waste and improving responsiveness. package main import ( "sync" "fmt" "time" ) var cond = sync.NewCond(&sync.Mutex{}) var condIterations int func worker() { queue := []int{} cond.L.Lock() for len(queue) == 0 { condIterations++ cond.Wait() // Sleep until signaled } cond.L.Unlock() } func main() { go worker() time.Sleep(2 * time.Second) fmt.Println("Condition variable wake-ups:", condIterations) } Sample Output Condition variable wake-ups: 1 Here, the worker sleeps completely, waking up just once when there’s work to do. No CPU cycles are squandered on pointless checks. 4. Scaling Up: Condition Variables with Multiple Goroutines Now let’s see condition variables in action with multiple workers sharing a queue. package main import ( "fmt" "sync" "time" ) var queue []int var cond = sync.NewCond(&sync.Mutex{}) func worker(id int) { for { cond.L.Lock() for len(queue) == 0 { cond.Wait() } // Process one item if len(queue) > 0 { item := queue[0] queue = queue[1:] fmt.Println("Worker", id, "Processing", item) // Signal after unlocking to avoid blocking others defer cond.Signal() } cond.L.Unlock() // Brief pause to let other workers run time.Sleep(10 * time.Millisecond) } } func main() { for i := 1; i

Apr 2, 2025 - 18:24
 0
Concurrency Done Right: Go’s Condition Variables

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.

In Go concurrency, goroutines often need to wait for work, but doing so efficiently can be a challenge—constant checks waste CPU time, while delays slow things down.

Condition variables, via sync.Cond, offer a solution by minimizing resource use and improving response times.

In this post, we’ll explore how they address this issue and why understanding them can make you a more effective Go engineer.

1. The Problem: Wasting CPU Cycles

Picture a worker goroutine tasked with processing items from a queue. A simple but inefficient solution is to have it constantly check the queue in a loop, burning CPU cycles while waiting for work.

Case 1: Busy-Waiting Worker (Relentless Looping)

package main

import (
    "fmt"
    "time"
)

var queue []int
var iterations int

func worker() {
    for {
        iterations++ // Track each check
        if len(queue) == 0 {
            continue
        }
        break
    }
}

func main() {
    go worker()
    time.Sleep(2 * time.Second) // Let it spin
    fmt.Println("Busy-wait iterations:", iterations)
}

My Machine Reports

Busy-wait iterations: 8,168,421,879

In just 2 seconds, this worker churned through over 8 billion iterations—all for nothing. That’s a staggering amount of CPU time wasted on empty checks.

2. Adding Sleep: Less Waste, Slower Response

To curb the CPU hogging, a common tweak is to pause between checks using time.Sleep.

package main

import (
    "fmt"
    "time"
)

var sleepIterations int

func worker() {
    queue := []int{}
    for {
        sleepIterations++
        if len(queue) == 0 {
            time.Sleep(10 * time.Millisecond)
            continue
        }
        break
    }
}

func main() {
    go worker()
    time.Sleep(2 * time.Second)
    fmt.Println("Sleep-based iterations:", sleepIterations)
}

My Machine Reports

Sleep-based iterations: 195

Now we’re down to ~200 checks instead of 8 billion—a huge improvement. But there’s a catch: the worker still wakes up periodically to check an empty queue, delaying its response when real work arrives.

3. Enter Condition Variables: Smart Waiting

A condition variable offers a better way. It lets the worker sleep efficiently until explicitly signaled, slashing CPU waste and improving responsiveness.

package main

import (
    "sync"
    "fmt"
    "time"
)

var cond = sync.NewCond(&sync.Mutex{})
var condIterations int

func worker() {
    queue := []int{}
    cond.L.Lock()
    for len(queue) == 0 {
        condIterations++
        cond.Wait() // Sleep until signaled
    }
    cond.L.Unlock()
}

func main() {
    go worker()
    time.Sleep(2 * time.Second)
    fmt.Println("Condition variable wake-ups:", condIterations)
}

Sample Output

Condition variable wake-ups: 1

Here, the worker sleeps completely, waking up just once when there’s work to do. No CPU cycles are squandered on pointless checks.

4. Scaling Up: Condition Variables with Multiple Goroutines

Now let’s see condition variables in action with multiple workers sharing a queue.

package main

import (
    "fmt"
    "sync"
    "time"
)

var queue []int
var cond = sync.NewCond(&sync.Mutex{})

func worker(id int) {
    for {
        cond.L.Lock()
        for len(queue) == 0 {
            cond.Wait()
        }
        // Process one item
        if len(queue) > 0 {
            item := queue[0]
            queue = queue[1:]
            fmt.Println("Worker", id, "Processing", item)
            // Signal after unlocking to avoid blocking others
            defer cond.Signal()
        }
        cond.L.Unlock()

        // Brief pause to let other workers run
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    for i := 1; i <= 3; i++ {
        go worker(i)
    }
    time.Sleep(1 * time.Second)

    cond.L.Lock()
    queue = append(queue, 42, 43, 44) // Add items
    cond.Broadcast() // Wake all workers
    cond.L.Unlock()

    time.Sleep(3 * time.Second)
}

Sample Output

Worker 1 Processing 42
Worker 2 Processing 43
Worker 3 Processing 44

Go’s sync.Cond enables clean coordination. Workers wait patiently:

cond.L.Lock()  // Protect the queue
for len(queue) == 0 {
    cond.Wait() // Release lock and sleep
}

When cond.Wait() runs, the goroutine:

  • Releases the mutex
  • Suspends itself
  • Reclaims the lock upon waking

A producer adds work and signals:

cond.L.Lock()
queue = append(queue, 42, 43, 44)
cond.Broadcast() // Wake all waiting workers
cond.L.Unlock()

Each worker processes an item and passes the baton:

item := queue[0]
queue = queue[1:]
fmt.Println("Worker", id, "Processing", item)
cond.Signal() // Notify the next worker

This creates a smooth handoff, ensuring work continues as long as items remain.

5. Why Condition Variables Matter

Approach CPU Checks Behavior
Busy-Waiting 5 million+ Relentless polling
Sleep Strategy 200 Periodic delays
Condition Var 1 Wakes only on need

Condition variables shine by eliminating waste and waking workers precisely when there’s something to do. For efficient, responsive waiting in Go, sync.Cond is your go-to tool.

Happy coding!