DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

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)
}
Enter fullscreen mode Exit fullscreen mode

My Machine Reports

Busy-wait iterations: 8,168,421,879
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

My Machine Reports

Sleep-based iterations: 195
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

My Machine Reports...

Condition variable wake-ups: 1
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Sample Output

Worker 1 Processing 42
Worker 2 Processing 43
Worker 3 Processing 44
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)