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:

ElementRole
Guard ConditionA boolean expression that must be true before execution can continue
SuspensionThe goroutine sleeps without wasting CPU while the condition is unmet
Wakeup SignalThe 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 → continue

Signal 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:

SituationChoice
Only one item available, many consumersSignal() — only one needs to wake
The condition changed and all waiters need to re-evaluateBroadcast()
Shutdown or cancellation — everyone must stopBroadcast()
Resources available in excess for all waitersBroadcast()
// 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| CH

Connection 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
AspectChannelsync.Cond
Idiomatic in GoYes — recommended by the Go teamLess common
ConditionImplicit (channel empty/full)Explicit, any condition
BroadcastNot directly availableBroadcast() available
Cancellationselect with ctx.Done()Needs a separate watcher goroutine
Buffer capacityControlled at make(chan, N)Needs manual logic
Best forProducer-consumer, pipelinesConnection 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, not if, before Wait() — spurious wakeups and Broadcast() can wake a goroutine even when the condition is unmet; for ensures 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 ensure Wait() is called inside the lock.
  • Always call Signal() or Broadcast() after the condition changes — forgetting to signal causes waiters to sleep forever (deadlock).
  • Choose Signal() for one waiter, Broadcast() for allSignal() 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.Cond only 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() on ctx.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-patternfor { check(); sleep(1ms) } wastes CPU and adds latency; always replace it with cond.Wait() or a blocking channel receive.

← Previous: Immutable Object   Next: Overview →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact