sync.WaitGroup Internals

A sync.WaitGroup is a counting barrier: it lets one or more goroutines block until a set of concurrent tasks has finished. Add(delta) raises a counter, Done() lowers it by one, and Wait() blocks until the counter reaches zero, at which point all waiters are released at once. Internally the entire WaitGroup is one 64-bit atomic word plus a single runtime semaphore — the high bits hold the task counter, the low bits hold the number of blocked waiters, and a single atomic add or compare-and-swap mutates both halves together. Go 1.25 added the convenience method WaitGroup.Go and folded a testing/synctest-bubble membership flag into a previously unused bit of the state word (sync/waitgroup.go, Go 1.25 release notes).

Mental Model

The trick that makes a WaitGroup correct without a lock is packing two independent counts into one atomic 64-bit integer. The high 32 bits are the task counter (how many tasks are still outstanding); the low 31 bits are the waiter count (how many goroutines are parked in Wait); and one bit in the middle is a flag. Because both counts live in the same word, a single atomic.Add of delta << 32 can change the counter, and the operation atomically returns the combined new value — so the goroutine that performs the decrement can, in one instruction, learn both “the counter is now zero” and “there are N waiters to wake.” No separate lock is needed to keep the two numbers consistent with each other.

flowchart TB
    subgraph word["state: one atomic.Uint64"]
        direction LR
        C["bits 63:32<br/>task counter (int32)"]
        F["bit 31<br/>synctest bubble flag"]
        W["bits 30:0<br/>waiter count (uint31)"]
    end
    Add["Add(delta): state.Add(delta &lt;&lt; 32)"] --> word
    Wait["Wait(): CAS state -> state+1<br/>(increment waiter count)"] --> word
    word -->|counter hits 0 with W&gt;0| Wake["state.Store(0); release sema W times"]
    Wake --> Sema[("runtime semaphore<br/>wg.sema")]
    Sema -->|wakes| Waiters["all parked Wait goroutines"]

Diagram: the WaitGroup’s single 64-bit state word and how the three operations touch it. The insight: Add works on the high half, Wait works on the low half, and only the decrement that drives the counter to zero while waiters exist reads the whole word, resets it, and releases the semaphore once per waiter.

Internal State Layout

As of Go 1.25 the struct is (sync/waitgroup.go):

type WaitGroup struct {
	noCopy noCopy
 
	// Bits (high to low):
	//   bits[0:32]  counter
	//   bits[32]    flag: synctest bubble membership
	//   bits[33:64] wait count
	state atomic.Uint64
	sema  uint32
}
 
const waitGroupBubbleFlag = 0x8000_0000

A note on the bit-numbering convention. The struct comment numbers the fields as bits[0:32] counter, bits[32] flag, bits[33:64] wait count — counting positions from the most-significant end down. The code, by contrast, uses ordinary little-end shift arithmetic: it does state.Add(uint64(delta) << 32) to bump the counter (so the counter occupies the high 32 bits, reachable by int32(state >> 32)), extracts the waiter count with uint32(state & 0x7fffffff) (the low 31 bits), and defines waitGroupBubbleFlag = 0x8000_0000, which is bit 31. The two descriptions agree on the physical layout — they just count bit positions from opposite ends. Verified against the go1.26.0 source: counter = high 32 bits, synctest-bubble flag = bit 31, waiter count = low 31 bits, with 0x7fffffff masking off the flag bit when reading waiters. The rest of this note uses the code’s convention (counter in the high half).

Walking the fields:

  • noCopy noCopy — a zero-size marker. noCopy has no-op Lock/Unlock methods so that go vet’s copylocks analyzer flags any code that copies a WaitGroup by value. It costs zero bytes at runtime; it exists purely for static analysis (sync/cond.go defines noCopy).
  • state atomic.Uint64 — the packed counter/flag/waiter word described above. Using one word (not two atomic.Int32s) is what allows the “is the counter zero and how many waiters” read to be a single atomic operation. Earlier Go versions (pre-1.20) used a [3]uint32 with alignment juggling; the current single-Uint64 form is cleaner because atomic.Uint64 guarantees its own 64-bit alignment via the compiler’s align64 marker — see Atomic Operations in Go.
  • sema uint32 — the address of a runtime semaphore. Waiters park on it; the zeroing decrement releases it once per waiter.

The waitGroupBubbleFlag (0x8000_0000, i.e. bit 31) marks that this WaitGroup is associated with a testing/synctest bubble — the fake-time, fully-deterministic goroutine sandbox that graduated to stable in Go 1.25. The flag exists so the runtime can detect the misuse “Add called from inside one bubble and also from outside (or another bubble)” and fatal on it (sync/waitgroup.go).

Mechanical Walk-through

Add and Done

Done() is literally wg.Add(-1). All the real logic is in Add:

func (wg *WaitGroup) Add(delta int) {
	// ... race-detector and synctest-bubble bookkeeping omitted ...
	state := wg.state.Add(uint64(delta) << 32)   // 1. atomically bump counter
	v := int32(state >> 32)                      // 2. new counter value
	w := uint32(state & 0x7fffffff)              // 3. current waiter count
	if v < 0 {
		panic("sync: negative WaitGroup counter")
	}
	if w != 0 && delta > 0 && v == int32(delta) {
		panic("sync: WaitGroup misuse: Add called concurrently with Wait")
	}
	if v > 0 || w == 0 {
		return                                   // 4. counter not zero, or nobody waiting
	}
	// 5. counter just hit 0 AND there are waiters
	if wg.state.Load() != state {
		panic("sync: WaitGroup misuse: Add called concurrently with Wait")
	}
	wg.state.Store(0)                            // 6. reset whole word
	for ; w != 0; w-- {
		runtime_Semrelease(&wg.sema, false, 0)   // 7. wake every waiter
	}
}

Step 1: state.Add(uint64(delta) << 32) shifts delta into the high 32 bits and atomically adds it; the new whole word is returned, so steps 2–3 can decode both the new counter v and the still-unchanged waiter count w from one read. Step 2 sign-extends the high half into an int32, so a counter that has gone below zero shows as negative — caught by the panic("sync: negative WaitGroup counter") immediately after. Step 4 is the common, fast exit: if the counter is still positive, or there are no waiters, Add is done. Step 5 is reached only when this Add drove the counter to exactly zero while goroutines are parked in Wait. Step 6 stores 0 into the whole word — resetting both counter and waiter count — and step 7 calls runtime_Semrelease once for each of the w waiters, waking them.

The double misuse check (w != 0 && delta > 0 && v == int32(delta) between steps 3 and 4, and wg.state.Load() != state in step 5) detects the classic bug of calling Add with a positive delta concurrently with Wait. The contract is: a positive Add that starts from a zero counter must happen before the corresponding Wait; the runtime can cheaply detect many violations and turns them into a panic rather than silent corruption.

Wait

func (wg *WaitGroup) Wait() {
	for {
		state := wg.state.Load()
		v := int32(state >> 32)            // counter
		w := uint32(state & 0x7fffffff)    // waiter count
		if v == 0 {
			return                         // nothing to wait for
		}
		if wg.state.CompareAndSwap(state, state+1) {  // register as a waiter
			runtime_SemacquireWaitGroup(&wg.sema, synctestDurable)  // park
			if wg.state.Load() != 0 {
				panic("sync: WaitGroup is reused before previous Wait has returned")
			}
			return
		}
		// CAS lost a race; loop and retry
	}
}

Wait loads the state. If the counter is already zero it returns at once — no parking. Otherwise it must atomically register itself as a waiter by incrementing the low 31 bits; it does this with CompareAndSwap(state, state+1) rather than a blind Add because between the Load and the CAS the counter may have hit zero (the waiter must not increment the waiter count if the counter is now zero, or it would leak a phantom waiter). If the CAS fails, some concurrent Add/Wait changed the word; Wait loops and re-evaluates. Once the CAS succeeds, the goroutine parks on the semaphore via runtime_SemacquireWaitGroup. When it wakes, the zeroing Add has already done state.Store(0), so wg.state.Load() should be 0; if it is not zero, the WaitGroup was reused (Add called again) before this Wait returned — a misuse, and Wait panics.

runtime_SemacquireWaitGroup is a WaitGroup-specific entry point into the Runtime Semaphore machinery; it differs from the generic runtime_Semacquire only in the wait reason it records (waitReasonSyncWaitGroupWait, or waitReasonSynctestWaitGroupWait inside a synctest bubble), which is what shows up in goroutine dumps and the profiler (runtime/sema.go).

WaitGroup.Go — the Go 1.25 convenience method

func (wg *WaitGroup) Go(f func()) {
	wg.Add(1)
	go func() {
		defer func() {
			if x := recover(); x != nil {
				panic(x)   // re-panic; do NOT call Done
			}
			wg.Done()
		}()
		f()
	}()
}

Go collapses the universal wg.Add(1); go func(){ defer wg.Done(); f() }() boilerplate into one call (Go 1.25 release notes). The subtle part is the panic handling: if f panics, Go deliberately re-panics without calling Done. The doc comment explains why — if it called Done, the unblocked Wait in the main goroutine could race the crashing goroutine and let the process exit cleanly before the panic is reported, hiding the bug. So Go requires that “the function f must not panic.” This is a real behavioral contract, not just a style note.

Memory Model Guarantees

The Go Memory Model does not give WaitGroup a clause of its own; instead the package documentation specifies the guarantee: a call to Done “synchronizes before the return of any Wait call that it unblocks,” and (for Go) “the return from f synchronizes before the return of any Wait call that it unblocks” (pkg.go.dev/sync). Concretely: everything a task writes before its Done/f-return is guaranteed visible to the goroutine that returns from Wait. The implementation realizes this with the race-detector race.ReleaseMerge/race.Acquire calls keyed on the WaitGroup pointer, and by routing the wake-up through the runtime semaphore, which itself establishes happens-before.

Failure Modes and Common Misunderstandings

  • Add after the goroutines have started. The canonical bug is calling wg.Add(1) inside the spawned goroutine instead of before go. If Wait runs first it sees a zero counter and returns immediately. The fix — and the reason WaitGroup.Go exists — is to Add before spawning. The runtime’s “Add called concurrently with Wait” panic catches many but not all instances of this.
  • Negative counter panics, hard. More Dones than Adds drives the counter below zero and panic("sync: negative WaitGroup counter"). This is recoverable with recover() (it is a panic, not fatal), but it almost always indicates a logic bug.
  • Reuse before Wait returns. Calling Add to start a new batch while a previous Wait is still unwinding triggers panic("sync: WaitGroup is reused before previous Wait has returned"). Reuse is allowed, but only strictly after all prior Waits have returned.
  • Copying. A WaitGroup carries a live atomic word and semaphore address; copying it by value (e.g. passing wg instead of &wg) gives each copy an independent counter. The noCopy marker makes go vet flag this.
  • Wait in the wrong goroutine relative to Add. If the producing goroutine does Add and the consuming goroutine does Wait, you must ensure the first Add happens-before Wait — typically by doing all Adds on the goroutine that later calls Wait, before spawning workers.

Alternatives and When to Choose Them

  • errgroup — when tasks can fail, errgroup.Group is a WaitGroup that also collects the first error and cancels a shared context. Prefer it over a bare WaitGroup whenever the tasks return errors.
  • Channels — a buffered channel that each worker sends a token to, drained N times, is an equivalent barrier and is sometimes clearer when you also want to collect results.
  • sync.Cond — for waiting on an arbitrary predicate rather than a simple “all tasks done” count.
  • A counting atomic plus a channel — for fan-in where you want to know which task finished, not just that all did.

For the plain “spawn N, wait for all N” pattern, WaitGroup (now via WaitGroup.Go) remains the simplest and cheapest tool.

Production Notes

The testing/synctest integration (stable in Go 1.25) is the most consequential recent change for test authors: inside a synctest.Test bubble, a WaitGroup.Wait() counts as durably blocking, so the bubble’s fake clock advances and tests of timeout/retry logic become deterministic and instant. The waitGroupBubbleFlag bit and the fatal checks for cross-bubble Add exist to keep this sandbox sound — they will crash a test that smuggles Add calls across the bubble boundary (pkg.go.dev/testing/synctest).

A second practical point: because Add driving the counter to zero calls runtime_Semrelease in a loop — once per waiter — broadcasting to a large number of Waiters is O(waiters). In practice the waiter count is one (a single coordinator), so this is a non-issue, but a design that has thousands of goroutines all Waiting on the same group pays a measurable wake-up cost.

All code excerpts and constants in this note were verified against the go1.26.0 release tag (Go 1.26, released February 2026, per the Go 1.26 release announcement). Confirmed verbatim in go1.26.0: the WaitGroup struct with noCopy, the packed state atomic.Uint64, waitGroupBubbleFlag = 0x8000_0000; the Add body with state.Add(uint64(delta) << 32), the int32(state >> 32) counter and uint32(state & 0x7fffffff) waiter extractions, the negative-counter and concurrent-misuse panics, the state.Store(0) reset, and the runtime_Semrelease(&wg.sema, false, 0) wake loop; the Wait body with its CompareAndSwap(state, state+1) waiter registration and runtime_SemacquireWaitGroup(&wg.sema, synctestDurable) park; and the WaitGroup.Go method with its re-panic-without-Done semantics. The Go method, the synctest-bubble flag, and the runtime_SemacquireWaitGroup entry point were introduced in Go 1.25 and are unchanged in Go 1.26. The runtime wait reasons recorded by runtime_SemacquireWaitGroup are waitReasonSyncWaitGroupWait (and waitReasonSynctestWaitGroupWait inside a bubble), confirmed in runtime/sema.go.

See Also