Wait Groups and Encapsulation

Updated

September 8, 2026

Wait Groups and Encapsulation

Overview

A wait group waits for a set of goroutines to finish. Prefer it over done channels when you need join without a result payload. Hide it behind APIs so callers never manage counters.

Done channel vs WaitGroup

// done channel
done := make(chan struct{}, 1)
go func() {
    work()
    done <- struct{}{}
}()
<-done

// wait group
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    work()
}()
wg.Wait()
Tool Best for
done channel single completion + optional result on another channel
WaitGroup N workers, no per-worker result at join site
wg.Go less boilerplate (Go 1.25+)

Mental model (naive)

// conceptual — real impl is concurrent-safe and parks, not busy-waits
type WaitGroup struct{ n int }

func (wg *WaitGroup) Add(delta int) {
    wg.n += delta
    if wg.n < 0 {
        panic("negative WaitGroup counter")
    }
}
func (wg *WaitGroup) Done() { wg.Add(-1) }
func (wg *WaitGroup) Wait() {
    for wg.n > 0 { /* real code blocks efficiently */ }
}
state machine:
  start --> Zero
  Zero --> Positive
  Positive --> Positive
  Positive --> Zero
  Zero --> Zero

Properties that match the real sync.WaitGroup:

  • Knows nothing about goroutines—only a counter
  • Wait before any Add does not block (counter already 0)
  • Reusable after returning to zero
  • Negative counter panics

Pass by pointer

// BUG: copy of WaitGroup — Wait sees counter 0
func runWork(wg sync.WaitGroup) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        work()
    }()
}

// OK
func runWork(wg *sync.WaitGroup) { ... }
value pass:  main.wg  ≠  runWork.wg_copy
pointer:     both share same counter

Encapsulation

Hide inside a function

func RunConc(fns ...func()) {
    var wg sync.WaitGroup
    wg.Add(len(fns))
    for _, fn := range fns {
        go func(f func()) {
            defer wg.Done()
            f()
        }(fn)
    }
    wg.Wait()
}
// client: RunConc(work, work, work)

Hide inside a type

type ConcRunner struct {
    wg    sync.WaitGroup
    funcs []func()
}

func (c *ConcRunner) Add(fn func()) { c.funcs = append(c.funcs, fn) }

func (c *ConcRunner) Run() {
    c.wg.Add(len(c.funcs))
    for _, fn := range c.funcs {
        go func(f func()) {
            defer c.wg.Done()
            f()
        }(fn)
    }
    c.wg.Wait()
}

Clients should not see WaitGroup unless they are writing infrastructure.

Add after Wait (advanced)

Technically possible if a waiter blocks on Wait while other goroutines still Add—but easy to get wrong. Prefer all Add calls before Wait from the coordinating goroutine, or use wg.Go which pairs add/start/done.

Runnable example

go mod init example && go run .
package main

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

func RunConc(fns ...func()) {
    var wg sync.WaitGroup
    for _, fn := range fns {
        wg.Go(fn)
    }
    wg.Wait()
}

func main() {
    start := time.Now()
    RunConc(
        func() { time.Sleep(50 * time.Millisecond); fmt.Print(".") },
        func() { time.Sleep(50 * time.Millisecond); fmt.Print(".") },
        func() { time.Sleep(50 * time.Millisecond); fmt.Print(".") },
    )
    fmt.Printf("\ntook %v\n", time.Since(start).Round(time.Millisecond))
}

What to notice: Three sleeps overlap → ~50ms, not 150ms.

Try next: Deliberately pass WaitGroup by value and watch Wait return early.