2026-08-25Busy-Waiting: When Doing Nothing Uses Everything[published]

Busy-Waiting: When Doing Nothing Uses Everything

I met busy-waiting as a warning in a concurrency problem: do not busy-wait. I understood the words, but not the mechanism. How can a thread be doing nothing and still waste a CPU core?

The answer changed how I think about waiting. A thread can repeatedly ask whether something has changed, or it can stop running and ask the scheduler to wake it when change is possible. Both approaches wait. Only one of them keeps sending the CPU a bill.

Network cables connected to rows of servers in a data centre

Photo: Taylor Vick · view the original on Unsplash · free to use under the Unsplash License


Two ways for a thread to wait

Suppose a worker cannot continue until ready becomes true. The most direct implementation is a loop:

busy_wait.go
go
for !ready.Load() {
    // check again immediately
}
 
doWork()

That loop is busy-waiting, also called spinning. The thread stays runnable and repeatedly loads the same state. If it is scheduled on a core, it can consume nearly all of that core while making no useful progress.

The important question is not simply “does this code contain a loop?” It is: what happens to the thread when progress is impossible?

worker thread
strategybusy-wait
predicate checks0
CPU while waitingidle
press start to make the worker wait
fig 1 — the same wait, paid for differently. busy-waiting spends CPU on repeated checks; a condition variable parks the worker until a signal arrives.

A busy-wait keeps the thread active. A blocking wait parks it: the operating system can run something else until the thread has a reason to try again.

A subtle Go trap

My first example used a select over channels:

go
select {
case <-condition:
    doSomething()
case <-conditionTwo:
    doAnotherThing()
}

This is not busy-waiting. With no default case, Go blocks the goroutine until one of the channel operations can proceed.

The busy version looks like this:

do_not_do_this.go
go
for {
    select {
    case <-condition:
        doSomething()
        return
    default:
        // Nothing is ready, but the loop runs again immediately.
    }
}

The default makes the select non-blocking. Wrapped in a tight loop, it continuously polls. That distinction is tiny in code and enormous at runtime.

Why not just use a mutex?

A mutex and a condition variable solve different parts of the problem.

  • A mutex protects an invariant while shared state is inspected or changed.
  • A condition variable lets a thread sleep until that shared state may satisfy a predicate.

The condition variable is attached to a mutex because “check the state” and “go to sleep” must behave like one atomic transition. Otherwise, another thread could change the state and send a signal in the gap, leaving the waiter asleep forever. This is the classic lost-wakeup bug.

The waiting pattern is always a loop:

POSIX condition-variable pattern
c
pthread_mutex_lock(&mutex);
 
while (!ready) {
    pthread_cond_wait(&condition, &mutex);
}
 
use_shared_resource();
pthread_mutex_unlock(&mutex);

pthread_cond_wait atomically releases the mutex and parks the thread. Before it returns, it reacquires the mutex. The predicate still belongs in a while loop because a wakeup is permission to check again, not proof that the condition is still true.

Signal and broadcast

The thread that changes shared state normally holds the same mutex, updates the predicate, and then signals the condition variable.

  • signal wakes at least one waiting thread.
  • broadcast wakes every waiting thread.

Waking does not hand over the mutex. It makes the waiter runnable; the awakened thread must still reacquire the mutex and re-check the predicate.

worker 1
parked → consumes no CPU
worker 2
parked → consumes no CPU
worker 3
parked → consumes no CPU
shared mutexone owner at a time
fig 2 — signal makes at least one waiter runnable; broadcast makes every waiter runnable. each one must reacquire the mutex before it can inspect shared state.

This separation helped the idea click for me:

  1. The mutex protects the shared state.
  2. The predicate says whether work can proceed.
  3. Wait releases the mutex and parks the caller.
  4. A state-changing thread signals one or all waiters.
  5. Each awakened waiter reacquires the mutex and checks the predicate again.

The same pattern in Go

Go exposes condition variables as sync.Cond:

queue.go
go
type Queue struct {
    mu       sync.Mutex
    notEmpty *sync.Cond
    items    []Item
}
 
func NewQueue() *Queue {
    q := &Queue{}
    q.notEmpty = sync.NewCond(&q.mu)
    return q
}
 
func (q *Queue) Pop() Item {
    q.mu.Lock()
    defer q.mu.Unlock()
 
    for len(q.items) == 0 {
        q.notEmpty.Wait()
    }
 
    item := q.items[0]
    q.items = q.items[1:]
    return item
}
 
func (q *Queue) Push(item Item) {
    q.mu.Lock()
    q.items = append(q.items, item)
    q.notEmpty.Signal()
    q.mu.Unlock()
}

Wait unlocks q.mu, suspends the goroutine, and locks q.mu again before returning. That lets producers call Push while consumers sleep instead of forcing either side to poll.

Condition variables or channels?

Go usually makes channels the clearer default. A channel combines notification with ownership transfer or a value moving between goroutines. It is a natural fit for jobs, results, streams, cancellation, and pipelines.

A condition variable becomes useful when the thing being communicated is not a message but a predicate over shared state:

  • many goroutines wait for the same state transition;
  • a broadcast must wake all current waiters;
  • the shared state is already protected by a mutex;
  • no value needs to be sent or received.

The useful rule is not “condition variables are better than channels” or the reverse. It is: choose the primitive that matches what is being coordinated.

Is spinning always wrong?

No. Busy-waiting can make sense when the expected wait is shorter than the cost of putting a thread to sleep and waking it again. Low-level runtimes, kernels, and lock implementations sometimes spin briefly before blocking. On a multicore machine, a spinlock can be reasonable when another core is expected to release the lock almost immediately.

That is a narrow optimization with strict assumptions. In application code, an unbounded spin loop is usually a warning sign: it wastes energy, steals CPU time from useful work, and behaves worse as contention grows.

My default is now simple: if a wait may last, park the work and arrange a wakeup. Spin only when measurement shows that the wait is truly tiny and the trade-off is intentional.


Further reading