Signaling: Cond and Broadcast

Updated

September 8, 2026

Signaling: Cond and Broadcast

Overview

Sometimes many waiters must wake when shared state changes. Channels cover most Go code; sync.Cond is the low-level “wait for a condition under a mutex” tool. Prefer channels unless you need broadcast to many waiters that all re-check a shared predicate protected by the same lock.

Related: 252 channels, 257 mutex, 258 semaphores.

Diagram: wait / signal

sequence (top → bottom):
  actors: waiter, Cond, producer
  waiter --> Cond  : L.Lock
  waiter --> Cond  : Wait  (unlocks + parks)
  producer --> Cond  : L.Lock; change state
  producer --> Cond  : Signal or Broadcast
  producer --> Cond  : Unlock
  Cond --> waiter  : wake; re-Lock
  waiter --> waiter  : recheck condition in for-loop

Always wait in a loop—spurious wakes, broad Broadcast, and stale signals happen.

mu.Lock()
for !ready {
    cond.Wait() // atomically unlocks mu while waiting; re-locks on wake
}
// invariant: ready == true while holding mu
mu.Unlock()

Signal vs Broadcast

Method Wakes Use when
Signal one waiter single consumer can take the item
Broadcast all waiters many must re-check (e.g. state machine flip)

Wrong choice:

  • Signal when many waiters each need to proceed → starvation / stuck waiters
  • Broadcast when one consumer would suffice → thundering herd
  producers write queue
       │
       ▼
  ┌──────────┐   Signal     one waiter takes item
  │  Cond    │ ──────────►  others stay parked
  └──────────┘
       │
       │ Broadcast
       ▼
  all waiters wake, re-lock, recheck predicate

vs channel

Need Prefer
One result / pipeline stage channel
Close = fan-out “done” close(ch)
select on many events channels
Complex shared-state predicate under one mutex Cond or redesign with channels
Bounded queue with many waiters often channel; Cond for classic textbooks

Rule of thumb in modern Go: if a channel models the problem cleanly, use a channel.

Runnable: ready flag + Broadcast

mkdir cond-lab && cd cond-lab
go mod init example.com/cond-lab

main.go:

package main

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

func main() {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    ready := false

    var wg sync.WaitGroup
    for id := 1; id <= 3; id++ {
        wg.Go(func() {
            mu.Lock()
            for !ready {
                fmt.Println("waiter", id, "waiting")
                cond.Wait()
            }
            fmt.Println("waiter", id, "proceeding")
            mu.Unlock()
        })
    }

    time.Sleep(50 * time.Millisecond)
    mu.Lock()
    ready = true
    fmt.Println("producer: broadcast")
    cond.Broadcast()
    mu.Unlock()

    wg.Wait()
}
go run .
# waiter N waiting (×3)
# producer: broadcast
# waiter N proceeding (×3)

What to notice: Waiters block without busy-spin; Broadcast unblocks all; each re-checks ready under the lock.

Runnable: bounded buffer with Cond

Classic producer/consumer where both “not full” and “not empty” are predicates:

package main

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

type Buffer struct {
    mu   sync.Mutex
    cond *sync.Cond
    q    []int
    cap  int
}

func NewBuffer(cap int) *Buffer {
    b := &Buffer{cap: cap}
    b.cond = sync.NewCond(&b.mu)
    return b
}

func (b *Buffer) Put(v int) {
    b.mu.Lock()
    for len(b.q) == b.cap {
        b.cond.Wait()
    }
    b.q = append(b.q, v)
    b.cond.Broadcast() // wake getters (and other putters if needed)
    b.mu.Unlock()
}

func (b *Buffer) Get() int {
    b.mu.Lock()
    for len(b.q) == 0 {
        b.cond.Wait()
    }
    v := b.q[0]
    b.q = b.q[1:]
    b.cond.Broadcast() // wake putters
    b.mu.Unlock()
    return v
}

func main() {
    b := NewBuffer(2)
    var wg sync.WaitGroup

    wg.Go(func() {
        for i := 1; i <= 5; i++ {
            b.Put(i)
            fmt.Println("put", i)
            time.Sleep(10 * time.Millisecond)
        }
    })
    wg.Go(func() {
        for i := 0; i < 5; i++ {
            v := b.Get()
            fmt.Println("get", v)
            time.Sleep(30 * time.Millisecond)
        }
    })
    wg.Wait()
}
go run -race .

What to notice: Full buffer blocks Put; empty blocks Get; no spinning. Race detector stays clean.

Channel equivalent (usually clearer)

ch := make(chan int, 2) // capacity = bound
// put: ch <- v
// get: v := <-ch

Prefer this unless you already hold a larger mutex protecting more state than the queue alone.

Spurious and broad wakes

// WRONG: if instead of for
mu.Lock()
if !ready {
    cond.Wait()
}
// ready might still be false after Broadcast from another condition
mu.Unlock()

Always:

for !predicate() {
    cond.Wait()
}

Signal vs Broadcast lab

package main

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

func main() {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    items := 0

    // Three consumers each want one item.
    var wg sync.WaitGroup
    for id := 1; id <= 3; id++ {
        wg.Go(func() {
            mu.Lock()
            for items == 0 {
                cond.Wait()
            }
            items--
            fmt.Println("consumer", id, "took item; left", items)
            mu.Unlock()
        })
    }

    time.Sleep(20 * time.Millisecond)
    mu.Lock()
    items = 3
    // Try Signal() once — only one consumer proceeds; others hang.
    // cond.Signal()
    cond.Broadcast() // correct here: three items, three waiters
    mu.Unlock()

    wg.Wait()
}

Comment switch: with a single Signal and items=3, two waiters never wake unless someone signals again.

Pitfalls

  1. Waiting without holding the lock. Undefined / panics / lost signals.
  2. Forgetting the loop. Spurious wake or multi-predicate Broadcast.
  3. Copying sync.Cond. Always pass by pointer; create with sync.NewCond(&mu).
  4. Using Cond when a channel would do. Harder to select, harder to cancel.
  5. Broadcast thundering herd on hot paths—consider per-waiter channels or a work queue channel.

Cancellation and Cond

Cond.Wait does not take a context. Patterns:

  • Use channels for cancelable waits (select on ctx.Done()).
  • Or set a done flag under the mutex and Broadcast, then check ctx / done in the wait loop.
mu.Lock()
for !ready && !closed {
    cond.Wait()
}
ok := ready && !closed
mu.Unlock()

Checklist

  • Predicate checked in a for loop under the mutex
  • Same mutex for state and Cond
  • Signal vs Broadcast matches waiter count semantics
  • -race clean on producer/consumer tests
  • Documented why channel was not enough (if using Cond)

Keep going

Next Why
252 Channels Preferred signaling for most apps
258 Semaphores Bound concurrency with channels
262 Race vs data race Predicate bugs under interleaving
260 Testing Stress the wait loops

Try next: Rewrite the ready-flag example with close(done) and compare readability, cancelability, and select composition.