Guarded Suspension Pattern #
Imagine a warehouse worker whose job is to take packages off a shelf and process them. If the shelf is empty, there are two options: he keeps walking back and forth checking the shelf every second (busy waiting), or he sits down and waits for a bell to ring — waking up only when someone puts a new package on the shelf. The first option wastes energy for nothing; the second is efficient and precisely targeted. This is the intuition behind the Guarded Suspension Pattern: a goroutine that cannot continue its work because a condition is not yet satisfied — data not yet available, a resource still in use, an event not yet happened — suspends itself until that condition changes. No polling. No busy loops. The CPU is free to do other work while waiting. In Go, the two main mechanisms for this are sync.Cond for complex condition scenarios, and channels for the more common and idiomatic cases.
What Is Guarded Suspension? #
The Guarded Suspension Pattern is a concurrency pattern where a goroutine checks a guard condition before continuing execution, and if the condition is not yet satisfied, the goroutine is suspended until the condition becomes true.
flowchart TD
A([Goroutine wants to continue]) --> B{Guard Condition\\nsatisfied?}
B -- Yes --> C[Continue execution]
B -- No --> D[Suspend / wait]
D --> E([Another goroutine changes the condition])
E --> F[Send wakeup signal]
F --> B
C --> G([Done])Three elements are always present in Guarded Suspension:
| Element | Role |
|---|---|
| Guard Condition | A boolean expression that must be true before execution can continue |
| Suspension | The goroutine sleeps without wasting CPU while the condition is unmet |
| Wakeup Signal | The mechanism that wakes the goroutine when the condition changes |
The critical difference between Guarded Suspension and busy waiting:
// ANTI-PATTERN: busy waiting — CPU fully consumed just to wait
for len(queue) == 0 {
// spin loop — wasting CPU cycles for nothing
time.Sleep(1 * time.Millisecond) // even with a sleep, this is still polling
}
process(queue[0])
// CORRECT: guarded suspension — CPU free for other goroutines
mu.Lock()
for len(queue) == 0 {
cond.Wait() // release the mutex, suspend, wait for a signal — no CPU wasted
}
item := queue[0]
mu.Unlock()
process(item)
Implementation with sync.Cond #
sync.Cond is Go’s condition variable — a low-level primitive that lets goroutines wait on complex conditions. This is the most explicit implementation of Guarded Suspension.
package main
import (
"fmt"
"sync"
"time"
)
// BoundedQueue is a queue with a maximum capacity
// Producers wait when full, consumers wait when empty
type BoundedQueue struct {
items []int
capacity int
mu sync.Mutex
notEmpty *sync.Cond // signal: there is a new item (for consumers)
notFull *sync.Cond // signal: there is a free slot (for producers)
}
func NewBoundedQueue(capacity int) *BoundedQueue {
q := &BoundedQueue{
items: make([]int, 0, capacity),
capacity: capacity,
}
q.notEmpty = sync.NewCond(&q.mu)
q.notFull = sync.NewCond(&q.mu)
return q
}
// Enqueue adds an item — waits if the queue is full
func (q *BoundedQueue) Enqueue(item int) {
q.mu.Lock()
defer q.mu.Unlock()
// GUARDED SUSPENSION: wait until there is a free slot
// MUST use 'for', not 'if' — the reason is explained below
for len(q.items) >= q.capacity {
fmt.Printf("[Producer] Queue full (%d/%d), waiting...\n", len(q.items), q.capacity)
q.notFull.Wait() // release the mutex, suspend, wait for the notFull signal
}
q.items = append(q.items, item)
fmt.Printf("[Producer] Enqueue %d (size: %d/%d)\n", item, len(q.items), q.capacity)
q.notEmpty.Signal() // wake one waiting consumer
}
// Dequeue takes an item — waits if the queue is empty
func (q *BoundedQueue) Dequeue() int {
q.mu.Lock()
defer q.mu.Unlock()
// GUARDED SUSPENSION: wait until there is an item
for len(q.items) == 0 {
fmt.Println("[Consumer] Queue empty, waiting...")
q.notEmpty.Wait() // release the mutex, suspend, wait for the notEmpty signal
}
item := q.items[0]
q.items = q.items[1:]
fmt.Printf("[Consumer] Dequeue %d (size: %d/%d)\n", item, len(q.items), q.capacity)
q.notFull.Signal() // wake one waiting producer
return item
}
// Size returns the current number of items
func (q *BoundedQueue) Size() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
func main() {
queue := NewBoundedQueue(3) // queue with capacity 3
var wg sync.WaitGroup
// Producer: produces 6 items (more than the capacity)
wg.Add(1)
go func() {
defer wg.Done()
for i := 1; i <= 6; i++ {
queue.Enqueue(i)
time.Sleep(100 * time.Millisecond)
}
}()
// Consumer: processes slower than the producer
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 6; i++ {
time.Sleep(300 * time.Millisecond) // consumer is slower
item := queue.Dequeue()
_ = item
}
}()
wg.Wait()
fmt.Println("All items processed")
}
Why for, Not if, Before Wait()
#
This is one of the most important rules in using sync.Cond — and also one of the most frequently violated.
// ANTI-PATTERN: using 'if' before Wait()
func (q *Queue) DequeueWrong() int {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 { // ✗ 'if' only checks once
q.cond.Wait()
// After waking up, continue directly — without re-checking!
// Problem: the goroutine can be woken by a spurious wakeup
// or by Broadcast() even though the condition is not satisfied
}
return q.items[0] // ✗ can panic if the queue is still empty!
}
// CORRECT: using 'for' before Wait()
func (q *Queue) DequeueCorrect() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 { // ✓ 'for' always re-checks after waking
q.cond.Wait()
// After waking up, the loop returns to the condition
// If the condition is still unmet → Wait() again
}
return q.items[0] // ✓ guaranteed the queue is not empty here
}
There are two reasons why for is mandatory:
Spurious wakeups — a condition variable can wake a goroutine without anyone calling Signal() or Broadcast(). This is not a bug in Go, but behavior permitted by the underlying operating system. With for, the goroutine will immediately go back to sleep if the condition turns out to be unmet.
Broadcast and multiple waiters — when Broadcast() is called, all waiting goroutines are woken at once. But there may be only one item available. With for, only the goroutine that first checks the condition and finds it true continues; the others go back to waiting.
sequenceDiagram
participant G1 as Goroutine 1
participant G2 as Goroutine 2
participant Cond as sync.Cond
participant P as Producer
G1->>Cond: Wait() — waiting
G2->>Cond: Wait() — waiting
P->>Cond: Signal() — wake ONE
Cond-->>G1: woken
G1->>G1: for: check condition → true → continue
Note over G2: still waiting
P->>Cond: Signal() — wake ONE
Cond-->>G2: woken
G2->>G2: for: check condition → true → continueSignal vs Broadcast #
sync.Cond provides two ways to wake waiting goroutines:
// Signal: wake ONE waiting goroutine
// Use when only one goroutine can take advantage of the change
q.cond.Signal()
// Broadcast: wake ALL waiting goroutines
// Use when the change is relevant to every waiter
q.cond.Broadcast()
Guidelines for choosing the right one:
| Situation | Choice |
|---|---|
| Only one item available, many consumers | Signal() — only one needs to wake |
| The condition changed and all waiters need to re-evaluate | Broadcast() |
| Shutdown or cancellation — everyone must stop | Broadcast() |
| Resources available in excess for all waiters | Broadcast() |
// Example of using Broadcast for shutdown
type WorkerPool struct {
mu sync.Mutex
cond *sync.Cond
jobs []Job
shutdown bool
}
func (p *WorkerPool) Shutdown() {
p.mu.Lock()
p.shutdown = true
p.mu.Unlock()
p.cond.Broadcast() // wake ALL workers so each one can exit
}
func (p *WorkerPool) worker() {
p.mu.Lock()
defer p.mu.Unlock()
for {
// Guard condition: there is work OR shutdown was requested
for len(p.jobs) == 0 && !p.shutdown {
p.cond.Wait()
}
if p.shutdown && len(p.jobs) == 0 {
return // exit cleanly
}
job := p.jobs[0]
p.jobs = p.jobs[1:]
p.mu.Unlock() // release the lock while processing
job.Execute()
p.mu.Lock() // re-acquire the lock for the next iteration
}
}
Idiomatic Implementation with Channels #
In Go, channels implement Guarded Suspension built-in: receiving from an empty channel automatically blocks, and sending to a full channel also blocks. This is the most idiomatic way to do Guarded Suspension in Go.
package main
import (
"context"
"fmt"
"time"
)
// GuardedQueue uses a channel as the guarded suspension mechanism
type GuardedQueue struct {
ch chan int
}
func NewGuardedQueue(capacity int) *GuardedQueue {
return &GuardedQueue{
ch: make(chan int, capacity), // buffered = queue capacity
}
}
// Enqueue sends an item — blocks if full (built-in guarded suspension)
func (q *GuardedQueue) Enqueue(ctx context.Context, item int) error {
select {
case q.ch <- item: // ✓ guarded suspension: wait if the channel is full
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Dequeue takes an item — blocks if empty (built-in guarded suspension)
func (q *GuardedQueue) Dequeue(ctx context.Context) (int, error) {
select {
case item := <-q.ch: // ✓ guarded suspension: wait if the channel is empty
return item, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
// Close closes the queue — waiting consumers will receive the zero value
func (q *GuardedQueue) Close() {
close(q.ch)
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
queue := NewGuardedQueue(3)
done := make(chan struct{})
// Producer
go func() {
for i := 1; i <= 8; i++ {
if err := queue.Enqueue(ctx, i); err != nil {
fmt.Println("[Producer] Context cancelled:", err)
return
}
fmt.Printf("[Producer] Enqueued: %d\n", i)
time.Sleep(150 * time.Millisecond)
}
queue.Close()
}()
// Consumer
go func() {
defer close(done)
for {
item, err := queue.Dequeue(ctx)
if err != nil {
fmt.Println("[Consumer] Context cancelled:", err)
return
}
fmt.Printf("[Consumer] Processing: %d\n", item)
time.Sleep(400 * time.Millisecond)
}
}()
<-done
}
The guarded suspension flow with channels can be visualized:
flowchart LR
P([Producer]) -->|ch <- item| B{Channel\\nfull?}
B -- Yes --> PW[Producer waits\\nguarded suspension]
B -- No --> CH[(Buffered\\nChannel)]
CH -->|<-ch| C([Consumer])
C --> CW{Channel\\nempty?}
CW -- Yes --> CWait[Consumer waits\\nguarded suspension]
CWait -->|new item arrives| CH
PW -->|free slot available| CHConnection Pool: A Real-World Use Case #
A connection pool is a perfect example of Guarded Suspension — a goroutine needing a connection must wait if all connections are in use.
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// Connection represents a database connection
type Connection struct {
id int
busy bool
}
func (c *Connection) Execute(query string) string {
time.Sleep(200 * time.Millisecond) // simulated query
return fmt.Sprintf("result from conn-%d: %s", c.id, query)
}
// ConnectionPool is a pool with guarded suspension
type ConnectionPool struct {
connections []*Connection
available []*Connection
mu sync.Mutex
cond *sync.Cond
}
func NewConnectionPool(size int) *ConnectionPool {
pool := &ConnectionPool{}
pool.cond = sync.NewCond(&pool.mu)
for i := 0; i < size; i++ {
conn := &Connection{id: i + 1}
pool.connections = append(pool.connections, conn)
pool.available = append(pool.available, conn)
}
return pool
}
// Acquire gets a connection — waits if all are in use
func (p *ConnectionPool) Acquire(ctx context.Context) (*Connection, error) {
p.mu.Lock()
defer p.mu.Unlock()
// GUARDED SUSPENSION: wait until a connection is available
for len(p.available) == 0 {
// Check the context inside the loop — no infinite wait if ctx is cancelled
select {
case <-ctx.Done():
return nil, errors.New("timeout waiting for connection")
default:
}
fmt.Println("[Pool] All connections busy, waiting...")
p.cond.Wait()
}
// Take the first available connection
conn := p.available[0]
p.available = p.available[1:]
conn.busy = true
fmt.Printf("[Pool] Connection %d handed out (remaining: %d)\n", conn.id, len(p.available))
return conn, nil
}
// Release returns a connection to the pool and wakes a waiter
func (p *ConnectionPool) Release(conn *Connection) {
p.mu.Lock()
defer p.mu.Unlock()
conn.busy = false
p.available = append(p.available, conn)
fmt.Printf("[Pool] Connection %d returned (available: %d)\n", conn.id, len(p.available))
p.cond.Signal() // wake one waiting goroutine
}
func main() {
pool := NewConnectionPool(2) // only 2 connections
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var wg sync.WaitGroup
queries := []string{"SELECT users", "SELECT orders", "SELECT products", "SELECT inventory"}
for i, query := range queries {
wg.Add(1)
go func(id int, q string) {
defer wg.Done()
conn, err := pool.Acquire(ctx)
if err != nil {
fmt.Printf("[Worker %d] Failed to get a connection: %v\n", id, err)
return
}
defer pool.Release(conn)
result := conn.Execute(q)
fmt.Printf("[Worker %d] %s\n", id, result)
}(i+1, query)
}
wg.Wait()
}
Timeout on Guarded Suspension #
Waiting without a limit is a recipe for deadlock. Always provide a timeout mechanism.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type TimedQueue struct {
items []int
mu sync.Mutex
cond *sync.Cond
}
func NewTimedQueue() *TimedQueue {
q := &TimedQueue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *TimedQueue) Enqueue(item int) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
q.cond.Signal()
}
// DequeueWithContext waits for an item with a deadline using a context
func (q *TimedQueue) DequeueWithContext(ctx context.Context) (int, error) {
// Run a watcher goroutine to cancel Wait() when the context finishes
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
q.cond.Broadcast() // wake all waiters so they can check ctx.Done()
case <-done:
}
}()
defer close(done)
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
// Check ctx after every wakeup
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
q.cond.Wait()
}
item := q.items[0]
q.items = q.items[1:]
return item, nil
}
func main() {
queue := NewTimedQueue()
// Consumer with a 1-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
go func() {
time.Sleep(2 * time.Second) // too late — past the deadline
queue.Enqueue(42)
}()
item, err := queue.DequeueWithContext(ctx)
if err != nil {
fmt.Println("Timeout:", err)
} else {
fmt.Println("Received:", item)
}
}
sync.Cond vs Channel: When to Choose Which #
flowchart TD
Q{What shape is the\\nwaiting condition?} --> Q1{Waiting for\\nan item/data?}
Q1 -- Yes --> CH[Channel ✓\\nMore idiomatic in Go]
Q1 -- No --> Q2{Depends on\\ncomplex state?}
Q2 -- Yes --> SC[sync.Cond ✓\\nFits arbitrary conditions]
Q2 -- No --> Q3{Need\\nBroadcast?}
Q3 -- Yes --> SC
Q3 -- No --> CH| Aspect | Channel | sync.Cond |
|---|---|---|
| Idiomatic in Go | Yes — recommended by the Go team | Less common |
| Condition | Implicit (channel empty/full) | Explicit, any condition |
| Broadcast | Not directly available | Broadcast() available |
| Cancellation | select with ctx.Done() | Needs a separate watcher goroutine |
| Buffer capacity | Controlled at make(chan, N) | Needs manual logic |
| Best for | Producer-consumer, pipelines | Connection pools, complex conditions |
Anti-Patterns to Avoid #
// ✗ Busy waiting — wasted CPU
for len(queue) == 0 {
time.Sleep(1 * time.Millisecond) // polling = CPU waste
}
// ✓ Guarded suspension — CPU free while waiting
mu.Lock()
for len(queue) == 0 {
cond.Wait() // sleeps, does not poll
}
mu.Unlock()
// ✗ Using 'if' before Wait() — can panic on a spurious wakeup
func (q *Queue) BadDequeue() int {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 { // ✗ checks only once
q.cond.Wait()
}
return q.items[0] // ✗ can panic!
}
// ✓ Always use 'for' before Wait()
func (q *Queue) GoodDequeue() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 { // ✓ re-checks after waking
q.cond.Wait()
}
return q.items[0] // ✓ safe
}
// ✗ Forgetting to call Signal/Broadcast — waiters wait forever
func (q *Queue) BrokenEnqueue(item int) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
// ✗ no Signal() — consumers sleep forever
}
// ✓ Always Signal/Broadcast after the condition changes
func (q *Queue) CorrectEnqueue(item int) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
q.cond.Signal() // ✓ wake the waiting consumer
}
// ✗ Wait() outside the mutex — panic: sync: unlock of unlocked mutex
func (q *Queue) PanicDequeue() int {
for len(q.items) == 0 {
q.cond.Wait() // ✗ must be inside the mutex lock!
}
return q.items[0]
}
// ✓ Wait() must always be inside the mutex lock
func (q *Queue) SafeDequeue() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait() // ✓ inside the lock — safe
}
return q.items[0]
}
Guarded Suspension Review Checklist #
CONDITION DESIGN:
□ The guard condition is clearly defined and documented
□ 'for' is used (not 'if') before every Wait()
□ The condition is re-evaluated after every wakeup
□ All access to shared state is protected by the same mutex
WAKEUP SIGNALS:
□ Signal() or Broadcast() is called every time the condition changes
□ The Signal vs Broadcast choice matches the number of waiters that need waking
□ Broadcast() is used for shutdown/cancellation so all waiters exit
LIFECYCLE AND TIMEOUT:
□ Every Wait() has an exit path besides the condition being met (timeout, ctx cancel)
□ A context is used to bound the maximum wait time
□ A watcher goroutine is launched to Broadcast on ctx.Done()
PRIMITIVE SELECTION:
□ Channels are considered before sync.Cond for standard use cases
□ sync.Cond is chosen only when the condition cannot be represented as a channel
□ No busy waiting (polling loops) — always use Wait() or a blocking channel receive
TESTING:
□ Tested with go test -race to detect data races
□ Timeout and cancellation scenarios are tested explicitly
□ Deadlocks are verified not to occur when Signal/Broadcast is not called
Summary #
- Guarded Suspension waits for a condition without wasting CPU — the goroutine is fully suspended, not polling, so resources are available for other productive goroutines.
- Three mandatory elements — the guard condition (what is being waited for), suspension (the goroutine sleeps), and the wakeup signal (notification that the condition changed).
- Always use
for, notif, beforeWait()— spurious wakeups and Broadcast() can wake a goroutine even when the condition is unmet;forensures the condition is re-checked after every wakeup.Wait()atomically releases the mutex and suspends the goroutine — when woken, the mutex is automatically re-acquired before execution continues; always ensureWait()is called inside the lock.- Always call
Signal()orBroadcast()after the condition changes — forgetting to signal causes waiters to sleep forever (deadlock).- Choose
Signal()for one waiter,Broadcast()for all —Signal()is more efficient when only one goroutine can use the change;Broadcast()for shutdown or changes relevant to all waiters.- Channels are the most idiomatic Guarded Suspension in Go — receiving from an empty channel automatically blocks; use
sync.Condonly for conditions that cannot be represented as a channel.- Always integrate a context for timeouts — waiting without a limit is a deadlock recipe; use a watcher goroutine that calls
Broadcast()onctx.Done().- A connection pool is the classic use case — goroutines wait until a connection is available;
Signal()is called when a connection is returned to the pool.- Busy waiting is an absolute anti-pattern —
for { check(); sleep(1ms) }wastes CPU and adds latency; always replace it withcond.Wait()or a blocking channel receive.