Signaling: Cond and Broadcast
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:
Signalwhen many waiters each need to proceed → starvation / stuck waitersBroadcastwhen 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-labmain.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 := <-chPrefer 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
- Waiting without holding the lock. Undefined / panics / lost signals.
- Forgetting the loop. Spurious wake or multi-predicate Broadcast.
- Copying
sync.Cond. Always pass by pointer; create withsync.NewCond(&mu). - Using Cond when a channel would do. Harder to
select, harder to cancel. - 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 (
selectonctx.Done()). - Or set a
doneflag under the mutex andBroadcast, then checkctx/donein the wait loop.
mu.Lock()
for !ready && !closed {
cond.Wait()
}
ok := ready && !closed
mu.Unlock()Checklist
- Predicate checked in a
forloop under the mutex - Same mutex for state and
Cond - Signal vs Broadcast matches waiter count semantics
-raceclean 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.