Go sync.WaitGroup Explained: A Complete Guide with Examples

WaitGroup acts like a counting semaphore, letting one goroutine block until a group of other goroutines finish. It ensures tasks complete before moving forward.

Golang WaitGroup

sync.WaitGroup is a fundamental synchronization primitive in Go's concurrency toolkit that enables elegant coordination between multiple goroutines. This documentation provides complete technical coverage with verified examples, best practices, and expert guidance for developers at all levels.

What is WaitGroup in Golang?

WaitGroup functions as a counting semaphore that allows a goroutine to wait for a collection of other goroutines to complete their execution. It's particularly valuable in scenarios where you need to ensure all concurrent tasks finish before proceeding with subsequent operations.

Key Characteristics:

  • Zero-value ready: No explicit initialization required
  • Thread-safe operations: All methods use atomic operations internally
  • Memory efficient: Compact 16-byte structure on 64-bit systems
  • Panic protection: Prevents negative counter states
  • Memory model compliance: Provides proper happens-before guarantees

Internal Architecture:

The WaitGroup uses a sophisticated internal structure optimized for performance:

type WaitGroup struct {
            noCopy noCopy
            state atomic.Uint64  // Counter and waiter state
            sema  uint32        // Semaphore for blocking Wait()
        }

The state field encodes both the work counter (low 32 bits) and waiter count (high 32 bits) in a single atomic value, enabling lock-free operations.

Visual diagram illustrating Go WaitGroup synchronization pattern with multiple goroutines
Visual diagram illustrating Go WaitGroup synchronization pattern with multiple goroutines.

API Reference

Core Methods

Add(delta int)

  • Increments or decrements the internal counter by delta
  • Must be called before the corresponding Wait() when counter is zero
  • Panics if counter becomes negative
  • Thread-safe for concurrent access

Done()

  • Equivalent to Add(-1)
  • Should always be called with defer to ensure execution
  • Synchronizes-before the return of any Wait() call it unblocks
  • Critical for preventing deadlocks

Wait()

  • Blocks the calling goroutine until counter reaches zero
  • Uses runtime semaphores for efficient blocking
  • Returns when all tracked goroutines have called Done()
  • Provides memory synchronization guarantees

Go 1.25 Enhancement: Go() Method

The Go(f func()) method introduced in Go 1.25 significantly improves ergonomics:

func (wg *WaitGroup) Go(f func()) {
        wg.Add(1)
        go func() {
            defer wg.Done()
            f()
        }()
    }

This method automatically handles Add(1) and defer Done(), eliminating common errors and reducing boilerplate code.

Usage Patterns and Examples

Traditional Pattern

var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)  // Add BEFORE launching goroutine
        go func(id int) {
            defer wg.Done()  // Always use defer
            // Perform work
            fmt.Printf("Worker %d completed\\n", id)
        }(i)
    }
    wg.Wait()  // Block until all complete

Go 1.25 Simplified Pattern

var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Go(func() {
            // Work here - no manual Add()/Done()
            fmt.Printf("Worker %d completed\\n", i)
        })
    }
    wg.Wait()

Error Handling with Context

func workerWithTimeout(ctx context.Context, wg *sync.WaitGroup) {
        defer wg.Done()
        select {
        case <-time.After(workDuration):
            // Normal completion
        case <-ctx.Done():
            // Respect cancellation
            return
        }
    }

Best Practices

Always Use defer with Done()

This ensures Done() is called even if the goroutine panics or has multiple return paths:

func worker(wg *sync.WaitGroup) {
        defer wg.Done()  // Guaranteed execution
        if err := riskyOperation(); err != nil {
            return  // Done() still called
        }
        // Additional work...
    }

Avoids:

  • Deadlocks from missing Done() on error paths.
  • Negative counter panics from mismatched Add()/Done().

Call Add() Before Launching Goroutines

// CORRECT
    wg.Add(1)
    go worker(&wg)
    // WRONG - Race condition possible
    go func() {
        wg.Add(1)  // Might happen after Wait()
        defer wg.Done()
        // work...
    }()

Avoids:

  • Race conditions where Wait() returns before all goroutines are counted.
  • Intermittent deadlocks caused by late Add().

Pass WaitGroup by Pointer

WaitGroup must not be copied after first use due to its internal state:

// CORRECT
    func startWorkers(wg *sync.WaitGroup) {
        wg.Add(1)
        go worker(wg)
    }
    // WRONG - Copies the WaitGroup
    func startWorkers(wg sync.WaitGroup) {
        wg.Add(1)  // Operating on copy
    }

Avoids:

  • Hidden races and stuck waits from manipulating a copied WaitGroup.
  • Undefined behavior due to diverging internal counters.

Integrate Proper Cancellation

Use context for graceful shutdown and timeout handling:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            select {
            case <-ctx.Done():
                return  // Proper cleanup
            default:
                // Perform work with context checking
            }
        }()
    }

Performance Considerations

WaitGroup vs Channels Performance

Research shows performance characteristics vary by concurrency level:

  • Low concurrency (≤100 goroutines): WaitGroup/Mutex ~3x faster than channels
  • Medium concurrency (100-1000): Performance roughly equivalent
  • High concurrency (1000+): Channels can be 7% faster than WaitGroup
WaitGroup vs Channels performance across different concurrency levels
WaitGroup vs Channels performance across different concurrency levels.

Memory Efficiency

  • Zero allocation: WaitGroup operations don't allocate memory
  • Atomic operations: Uses compare-and-swap instead of mutex locks
  • Cache-friendly: Compact structure minimizes cache line contention

Integration with Error Handling

Using errgroup Package

For scenarios requiring error propagation:

import "golang.org/x/sync/errgroup"

func processWithErrors(items []Item) error {
    g := new(errgroup.Group)

    for _, item := range items {
        item := item  // Capture loop variable
        g.Go(func() error {
            return item.Process()
        })
    }

    return g.Wait()  // Returns first error
}

Custom Error Collection

func processWithErrorCollection(items []Item) []error {
    var wg sync.WaitGroup
    errorCh := make(chan error, len(items))

    for _, item := range items {
        wg.Add(1)
        go func(item Item) {
            defer wg.Done()
            if err := item.Process(); err != nil {
                select {
                case errorCh <- err:
                default: // Channel full, skip
                }
            }
        }(item)
    }

    wg.Wait()
    close(errorCh)

    var errors []error
    for err := range errorCh {
        errors = append(errors, err)
    }
    return errors
}

Testing and Debugging

Race Detection

Always test concurrent code with the race detector:

go test -race ./...
go run -race main.go

Testing WaitGroup Synchronization

func TestWorkerCompletion(t *testing.T) {
    var wg sync.WaitGroup
    var completed int32

    numWorkers := 100
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt32(&completed, 1)
        }()
    }

    wg.Wait()

    if atomic.LoadInt32(&completed) != int32(numWorkers) {
        t.Errorf("Expected %d completed, got %d",
                numWorkers, completed)
    }
}

Memory Model and Synchronization

WaitGroup provides critical happens-before guarantees:

  1. Done() synchronizes-before Wait() return
  2. All writes before Done() are visible after Wait() returns
  3. Program order within goroutines is preserved
  4. Atomic state updates prevent race conditions

These guarantees ensure that data written by worker goroutines is visible to the goroutine that calls Wait(), providing proper memory synchronization without additional locks.

Version Compatibility and Future

Go 1.25 Improvements

  • New Go() method for simplified usage
  • Enhanced go vet checks for common WaitGroup mistakes
  • Improved documentation and examples
  • Backward compatibility maintained for existing code

Integration with Modern Go Features

WaitGroup works seamlessly with:

Conclusion

sync.WaitGroup remains an essential tool for Go developers. Its efficient design, synchronization guarantees, and new Go 1.25 enhancements make it a go-to choice for managing goroutines that don’t require data exchange.

Key Takeaways:

  • Always use defer wg.Done()
  • Call Add() before goroutines
  • Use Go() for cleaner code in Go 1.25+
  • Combine with context and error handling where needed
  • Test with race detection
  • Understand performance trade-offs at scale

>>> Follow and Contact Relia Software for more information!

  • coding
  • golang
  • Web application Development
  • web development