Wait Groups and Encapsulation
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
Waitbefore anyAdddoes 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.